refactor(db): 增强测试事务支持和保存点管理
- 在 HoTimeDB 中新增测试事务功能,允许在测试模式下使用事务进行操作 - 实现 BeginTestTx 和 RollbackTestTx 方法,支持测试事务的开启和回滚 - 在 Action 方法中集成保存点管理,确保在测试模式下的嵌套事务处理 - 更新 README.md,添加 API 测试框架的相关说明,提升文档完整性
This commit is contained in:
+377
@@ -0,0 +1,377 @@
|
||||
package hotime
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
. "code.hoteas.com/golang/hotime/common"
|
||||
. "code.hoteas.com/golang/hotime/db"
|
||||
)
|
||||
|
||||
// === 注册类型 ===
|
||||
|
||||
// TestProj 测试项目集合(项目名 → 定义)
|
||||
type TestProj map[string]TestProjDef
|
||||
|
||||
// TestProjDef 单个项目的路由 + 测试定义
|
||||
type TestProjDef struct {
|
||||
Proj Proj
|
||||
Tests ProjTest
|
||||
}
|
||||
|
||||
// ProjTest 项目级测试(控制器名 → 控制器测试)
|
||||
type ProjTest map[string]CtrTest
|
||||
|
||||
// CtrTest 控制器级测试(方法名 → 用例定义)
|
||||
type CtrTest map[string]ApiTestDef
|
||||
|
||||
// ApiTestDef 单个接口的测试定义
|
||||
type ApiTestDef struct {
|
||||
Desc string
|
||||
Func func(a *Api)
|
||||
}
|
||||
|
||||
// === Api:测试起点 ===
|
||||
|
||||
// Api 测试 API 入口,由 RunTests 自动创建并注入
|
||||
type Api struct {
|
||||
app *TestApp
|
||||
path string
|
||||
t *testing.T
|
||||
session Map
|
||||
}
|
||||
|
||||
// WithSession 返回一个携带 session 的新 Api 实例
|
||||
func (a *Api) WithSession(s Map) *Api {
|
||||
return &Api{
|
||||
app: a.app,
|
||||
path: a.path,
|
||||
t: a.t,
|
||||
session: s,
|
||||
}
|
||||
}
|
||||
|
||||
// JSON 设置 JSON body,返回 ApiCase 构建器
|
||||
func (a *Api) JSON(body interface{}) *ApiCase {
|
||||
c := a.newCase()
|
||||
c.jsonBody = body
|
||||
return c
|
||||
}
|
||||
|
||||
// Query 设置 URL 查询参数,返回 ApiCase 构建器
|
||||
func (a *Api) Query(params Map) *ApiCase {
|
||||
c := a.newCase()
|
||||
c.query = params
|
||||
return c
|
||||
}
|
||||
|
||||
// Form 设置表单 body,返回 ApiCase 构建器
|
||||
func (a *Api) Form(body Map) *ApiCase {
|
||||
c := a.newCase()
|
||||
c.formBody = body
|
||||
return c
|
||||
}
|
||||
|
||||
// File 设置上传文件,返回 ApiCase 构建器
|
||||
func (a *Api) File(field, name string, content []byte) *ApiCase {
|
||||
c := a.newCase()
|
||||
c.fileField = field
|
||||
c.fileName = name
|
||||
c.fileContent = content
|
||||
return c
|
||||
}
|
||||
|
||||
// Get 直接发送 GET 请求(无数据场景)
|
||||
func (a *Api) Get(desc string, status int, msg ...string) *ApiResponse {
|
||||
return a.newCase().Get(desc, status, msg...)
|
||||
}
|
||||
|
||||
// Post 直接发送 POST 请求(无数据场景)
|
||||
func (a *Api) Post(desc string, status int, msg ...string) *ApiResponse {
|
||||
return a.newCase().Post(desc, status, msg...)
|
||||
}
|
||||
|
||||
// Put 直接发送 PUT 请求(无数据场景)
|
||||
func (a *Api) Put(desc string, status int, msg ...string) *ApiResponse {
|
||||
return a.newCase().Put(desc, status, msg...)
|
||||
}
|
||||
|
||||
// Delete 直接发送 DELETE 请求(无数据场景)
|
||||
func (a *Api) Delete(desc string, status int, msg ...string) *ApiResponse {
|
||||
return a.newCase().Delete(desc, status, msg...)
|
||||
}
|
||||
|
||||
// DB 获取数据库实例(在测试事务内)
|
||||
func (a *Api) DB() *HoTimeDB {
|
||||
return &a.app.Db
|
||||
}
|
||||
|
||||
func (a *Api) newCase() *ApiCase {
|
||||
return &ApiCase{
|
||||
api: a,
|
||||
session: a.session,
|
||||
}
|
||||
}
|
||||
|
||||
// === ApiCase:链式构建器 ===
|
||||
|
||||
// ApiCase 测试用例构建器,支持链式调用叠加请求数据
|
||||
type ApiCase struct {
|
||||
api *Api
|
||||
session Map
|
||||
query Map
|
||||
jsonBody interface{}
|
||||
formBody Map
|
||||
fileField string
|
||||
fileName string
|
||||
fileContent []byte
|
||||
}
|
||||
|
||||
// JSON 叠加 JSON body
|
||||
func (c *ApiCase) JSON(body interface{}) *ApiCase {
|
||||
c.jsonBody = body
|
||||
return c
|
||||
}
|
||||
|
||||
// Query 叠加 URL 查询参数
|
||||
func (c *ApiCase) Query(params Map) *ApiCase {
|
||||
if c.query == nil {
|
||||
c.query = params
|
||||
} else {
|
||||
for k, v := range params {
|
||||
c.query[k] = v
|
||||
}
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// Form 叠加表单 body
|
||||
func (c *ApiCase) Form(body Map) *ApiCase {
|
||||
if c.formBody == nil {
|
||||
c.formBody = body
|
||||
} else {
|
||||
for k, v := range body {
|
||||
c.formBody[k] = v
|
||||
}
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// File 设置上传文件
|
||||
func (c *ApiCase) File(field, name string, content []byte) *ApiCase {
|
||||
c.fileField = field
|
||||
c.fileName = name
|
||||
c.fileContent = content
|
||||
return c
|
||||
}
|
||||
|
||||
// WithSession 覆盖本次请求的 session
|
||||
func (c *ApiCase) WithSession(s Map) *ApiCase {
|
||||
c.session = s
|
||||
return c
|
||||
}
|
||||
|
||||
// Get 终端操作:GET 请求 + 断言
|
||||
func (c *ApiCase) Get(desc string, status int, msg ...string) *ApiResponse {
|
||||
return c.execute("GET", desc, status, msg...)
|
||||
}
|
||||
|
||||
// Post 终端操作:POST 请求 + 断言
|
||||
func (c *ApiCase) Post(desc string, status int, msg ...string) *ApiResponse {
|
||||
return c.execute("POST", desc, status, msg...)
|
||||
}
|
||||
|
||||
// Put 终端操作:PUT 请求 + 断言
|
||||
func (c *ApiCase) Put(desc string, status int, msg ...string) *ApiResponse {
|
||||
return c.execute("PUT", desc, status, msg...)
|
||||
}
|
||||
|
||||
// Delete 终端操作:DELETE 请求 + 断言
|
||||
func (c *ApiCase) Delete(desc string, status int, msg ...string) *ApiResponse {
|
||||
return c.execute("DELETE", desc, status, msg...)
|
||||
}
|
||||
|
||||
func (c *ApiCase) execute(method, desc string, expectStatus int, expectMsg ...string) *ApiResponse {
|
||||
t := c.api.t
|
||||
var resp *ApiResponse
|
||||
|
||||
t.Run(desc, func(t *testing.T) {
|
||||
start := time.Now()
|
||||
|
||||
req := c.buildRequest(method)
|
||||
w := httptest.NewRecorder()
|
||||
c.api.app.Application.ServeHTTP(w, req)
|
||||
|
||||
duration := time.Since(start)
|
||||
|
||||
body := Map{}
|
||||
if len(w.Body.Bytes()) > 0 {
|
||||
_ = json.Unmarshal(w.Body.Bytes(), &body)
|
||||
}
|
||||
|
||||
resp = &ApiResponse{
|
||||
StatusCode: w.Code,
|
||||
Body: body,
|
||||
RawBody: w.Body.Bytes(),
|
||||
t: t,
|
||||
}
|
||||
|
||||
passed := true
|
||||
actualStatus := body.GetInt("status")
|
||||
if actualStatus != expectStatus {
|
||||
t.Errorf("状态码不匹配: 期望 %d, 实际 %d\n响应: %s", expectStatus, actualStatus, w.Body.String())
|
||||
passed = false
|
||||
}
|
||||
|
||||
if passed && len(expectMsg) > 0 && expectMsg[0] != "" {
|
||||
actualMsg := resp.GetMsg()
|
||||
if actualMsg != expectMsg[0] {
|
||||
t.Errorf("消息不匹配: 期望 %q, 实际 %q\n响应: %s", expectMsg[0], actualMsg, w.Body.String())
|
||||
passed = false
|
||||
}
|
||||
}
|
||||
|
||||
record := TestRecord{
|
||||
Path: c.api.path,
|
||||
Desc: desc,
|
||||
CaseName: desc,
|
||||
Passed: passed,
|
||||
Duration: duration,
|
||||
Method: method,
|
||||
}
|
||||
if c.query != nil {
|
||||
record.Query = c.query
|
||||
}
|
||||
if c.jsonBody != nil {
|
||||
record.JsonBody = c.jsonBody
|
||||
}
|
||||
if c.formBody != nil {
|
||||
record.FormBody = c.formBody
|
||||
}
|
||||
if c.fileContent != nil {
|
||||
record.HasFile = true
|
||||
record.FileField = c.fileField
|
||||
}
|
||||
if len(body) > 0 {
|
||||
record.ResponseBody = body
|
||||
}
|
||||
c.api.app.collector.Add(record)
|
||||
})
|
||||
|
||||
if resp == nil {
|
||||
resp = &ApiResponse{t: t}
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
func (c *ApiCase) buildRequest(method string) *http.Request {
|
||||
path := c.api.path
|
||||
|
||||
if c.query != nil {
|
||||
q := url.Values{}
|
||||
for k, v := range c.query {
|
||||
q.Set(k, ObjToStr(v))
|
||||
}
|
||||
path += "?" + q.Encode()
|
||||
}
|
||||
|
||||
var body io.Reader
|
||||
var contentType string
|
||||
|
||||
switch {
|
||||
case c.fileContent != nil:
|
||||
buf := &bytes.Buffer{}
|
||||
writer := multipart.NewWriter(buf)
|
||||
part, _ := writer.CreateFormFile(c.fileField, c.fileName)
|
||||
_, _ = part.Write(c.fileContent)
|
||||
if c.formBody != nil {
|
||||
for k, v := range c.formBody {
|
||||
_ = writer.WriteField(k, ObjToStr(v))
|
||||
}
|
||||
}
|
||||
_ = writer.Close()
|
||||
body = buf
|
||||
contentType = writer.FormDataContentType()
|
||||
|
||||
case c.jsonBody != nil:
|
||||
jsonBytes, _ := json.Marshal(c.jsonBody)
|
||||
body = bytes.NewReader(jsonBytes)
|
||||
contentType = "application/json"
|
||||
|
||||
case c.formBody != nil:
|
||||
form := url.Values{}
|
||||
for k, v := range c.formBody {
|
||||
form.Set(k, ObjToStr(v))
|
||||
}
|
||||
body = strings.NewReader(form.Encode())
|
||||
contentType = "application/x-www-form-urlencoded"
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(method, path, body)
|
||||
if contentType != "" {
|
||||
req.Header.Set("Content-Type", contentType)
|
||||
}
|
||||
|
||||
if c.session != nil {
|
||||
sessionId := Md5("test_session_" + ObjToStr(time.Now().UnixNano()))
|
||||
c.api.app.HoTimeCache.Session(HEAD_SESSION_ADD+sessionId, c.session)
|
||||
req.Header.Set("Authorization", sessionId)
|
||||
}
|
||||
|
||||
return req
|
||||
}
|
||||
|
||||
// === ApiResponse ===
|
||||
|
||||
// ApiResponse 测试响应对象
|
||||
type ApiResponse struct {
|
||||
StatusCode int
|
||||
Body Map
|
||||
RawBody []byte
|
||||
t *testing.T
|
||||
}
|
||||
|
||||
// GetStatus 获取业务状态码(JSON body 中的 status 字段)
|
||||
func (r *ApiResponse) GetStatus() int {
|
||||
return r.Body.GetInt("status")
|
||||
}
|
||||
|
||||
// GetResult 获取响应结果(JSON body 中的 result 字段)
|
||||
func (r *ApiResponse) GetResult() interface{} {
|
||||
return r.Body.Get("result")
|
||||
}
|
||||
|
||||
// GetMsg 获取响应消息
|
||||
// Display 错误格式为 {"status":N, "result":{"msg":"xxx"}},成功时无 msg
|
||||
func (r *ApiResponse) GetMsg() string {
|
||||
if msg := r.Body.GetString("msg"); msg != "" {
|
||||
return msg
|
||||
}
|
||||
if result := r.Body.GetMap("result"); result != nil {
|
||||
return result.GetString("msg")
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// GetBody 获取完整的响应 body
|
||||
func (r *ApiResponse) GetBody() Map {
|
||||
return r.Body
|
||||
}
|
||||
|
||||
// Fail 手动标记测试失败(用于自定义断言)
|
||||
func (r *ApiResponse) Fail(msg string) {
|
||||
if r.t != nil {
|
||||
r.t.Errorf("自定义断言失败: %s\n响应: %s", msg, string(r.RawBody))
|
||||
} else {
|
||||
fmt.Printf("自定义断言失败: %s\n", msg)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user