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 lastResp *ApiResponse } // 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 请求 // expect 可选参数: string 为期望 msg,Map 为期望 result 结构(按样本值类型校验) func (a *Api) Get(desc string, status int, expect ...interface{}) *ApiResponse { return a.newCase().Get(desc, status, expect...) } // Post 直接发送 POST 请求 func (a *Api) Post(desc string, status int, expect ...interface{}) *ApiResponse { return a.newCase().Post(desc, status, expect...) } // Put 直接发送 PUT 请求 func (a *Api) Put(desc string, status int, expect ...interface{}) *ApiResponse { return a.newCase().Put(desc, status, expect...) } // Delete 直接发送 DELETE 请求 func (a *Api) Delete(desc string, status int, expect ...interface{}) *ApiResponse { return a.newCase().Delete(desc, status, expect...) } // Verify 设置请求后的校验函数,返回 ApiCase 构建器 // 在回调中可用 a.Resp() 做响应值断言 + a.DB() 做数据库校验 func (a *Api) Verify(fn func(a *Api) error) *ApiCase { c := a.newCase() c.verifyFn = fn return c } // Note 为下一条用例设置备注,返回可继续链式的 ApiCase func (a *Api) Note(note string) *ApiCase { c := a.newCase() c.note = note return c } // DB 获取数据库实例(在测试事务内) func (a *Api) DB() *HoTimeDB { return &a.app.Db } // Resp 获取最近一次请求的响应,在 Verify 回调中用于响应值断言 func (a *Api) Resp() *ApiResponse { return a.lastResp } 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 note string verifyFn func(a *Api) error } // 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 } // Note 为本条用例添加可选备注,备注将显示在 swagger 调试控制台 func (c *ApiCase) Note(note string) *ApiCase { c.note = note return c } // Verify 设置请求后的自定义校验函数(如查库验证数据状态) // 函数返回 nil 表示校验通过,返回 error 则用例失败并记录错误原因 func (c *ApiCase) Verify(fn func(a *Api) error) *ApiCase { c.verifyFn = fn return c } // Get 终端操作:GET 请求 + 断言 func (c *ApiCase) Get(desc string, status int, expect ...interface{}) *ApiResponse { return c.execute("GET", desc, status, expect...) } // Post 终端操作:POST 请求 + 断言 func (c *ApiCase) Post(desc string, status int, expect ...interface{}) *ApiResponse { return c.execute("POST", desc, status, expect...) } // Put 终端操作:PUT 请求 + 断言 func (c *ApiCase) Put(desc string, status int, expect ...interface{}) *ApiResponse { return c.execute("PUT", desc, status, expect...) } // Delete 终端操作:DELETE 请求 + 断言 func (c *ApiCase) Delete(desc string, status int, expect ...interface{}) *ApiResponse { return c.execute("DELETE", desc, status, expect...) } func (c *ApiCase) execute(method, desc string, expectStatus int, expectArgs ...interface{}) *ApiResponse { // status!=0 时: 第一个 string → msg 校验,其余 → result 结构校验 // status==0 时: 所有参数 → result 类型/结构校验(因为成功响应没有 msg) var expectMsg string var expectResult interface{} var hasExpectResult bool for _, arg := range expectArgs { if s, ok := arg.(string); ok && expectStatus != 0 && expectMsg == "" { expectMsg = s continue } if !hasExpectResult { expectResult = arg hasExpectResult = true } } if expectStatus != 0 && expectMsg == "" { expectMsg = desc } 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 { if obj, err := JsonToObj(w.Body.String()); err == nil { if m, ok := obj.(map[string]interface{}); ok { body = Map(m) } } } 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 && expectMsg != "" { actualMsg := resp.GetMsg() if actualMsg != expectMsg { t.Errorf("消息不匹配: 期望 %q, 实际 %q\n响应: %s", expectMsg, actualMsg, w.Body.String()) passed = false } } if passed && hasExpectResult { if !validateShape(body["result"], expectResult, "result", t) { passed = false } } c.api.lastResp = resp var verifyError string if passed && c.verifyFn != nil { if err := c.verifyFn(c.api); err != nil { t.Errorf("Verify 校验失败: %s", err.Error()) passed = false verifyError = err.Error() } } record := TestRecord{ Path: c.api.path, Desc: desc, CaseName: desc, Passed: passed, Duration: duration, Method: method, Note: c.note, VerifyError: verifyError, } 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 } expectResp := Map{"status": expectStatus} if expectStatus != 0 { if expectMsg != "" { errorInfo := Map{"msg": expectMsg} errorType := c.api.app.Config.GetMap("error").GetString(ObjToStr(expectStatus)) if errorType != "" { errorInfo["type"] = errorType } expectResp["result"] = errorInfo expectResp["error"] = errorInfo } } else if hasExpectResult { expectResp["result"] = expectResult } record.ExpectResult = expectResp 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(JSON 解析后的 Map,可链式 Get*) func (r *ApiResponse) GetBody() Map { return r.Body } // GetRawBody 获取原始响应字节(用于非 JSON 响应如文件下载、二进制流校验) func (r *ApiResponse) GetRawBody() []byte { return r.RawBody } // 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) } } // ExpectResult 后置校验 result 结构,expected 作为类型样本(只比类型,不比值) // expected 可以是 Map(校验字段)、Slice(校验数组)、或 string/int64/float64 等原始类型 // // res.ExpectResult(Map{"id": int64(1), "name": "样本"}) // res.ExpectResult("操作成功") // 校验 result 是 string 类型 // res.ExpectResult(Slice{Map{"id": int64(1)}}) // 校验 result 是数组 func (r *ApiResponse) ExpectResult(expected interface{}) *ApiResponse { if !validateShape(r.Body["result"], expected, "result", r.t) && r.t == nil { fmt.Printf("ExpectResult 校验失败\n") } return r } // === 结构校验 === // validateShape 递归校验 actual 是否符合 expected 的结构(字段名+类型大类) func validateShape(actual, expected interface{}, path string, t *testing.T) bool { if expected == nil { return true } if actual == nil { if t != nil { t.Errorf("字段 %s 缺失", path) } return false } switch exp := expected.(type) { case Map: return validateMapShape(actual, exp, path, t) case map[string]interface{}: return validateMapShape(actual, Map(exp), path, t) case Slice: return validateSliceShape(actual, exp, path, t) case []interface{}: return validateSliceShape(actual, Slice(exp), path, t) default: if !sameTypeCategory(actual, expected) { if t != nil { t.Errorf("字段 %s 类型不匹配: 期望 %s, 实际 %s(%v)", path, typeCategory(expected), typeCategory(actual), actual) } return false } return true } } func validateMapShape(actual interface{}, exp Map, path string, t *testing.T) bool { actualMap := ObjToMap(actual) if actualMap == nil { if t != nil { t.Errorf("字段 %s 类型不匹配: 期望 map, 实际 %s", path, typeCategory(actual)) } return false } ok := true for field, expectedVal := range exp { fieldPath := path + "." + field actualVal, exists := actualMap[field] if !exists { if t != nil { t.Errorf("字段 %s 缺失", fieldPath) } ok = false continue } if !validateShape(actualVal, expectedVal, fieldPath, t) { ok = false } } return ok } func validateSliceShape(actual interface{}, exp Slice, path string, t *testing.T) bool { actualSlice := ObjToSlice(actual) if actualSlice == nil { if t != nil { t.Errorf("字段 %s 类型不匹配: 期望 slice, 实际 %s", path, typeCategory(actual)) } return false } if len(exp) > 0 && len(actualSlice) > 0 { return validateShape(actualSlice[0], exp[0], path+"[0]", t) } return true } func sameTypeCategory(a, b interface{}) bool { return typeCategory(a) == typeCategory(b) } func typeCategory(v interface{}) string { switch v.(type) { case int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64: return "number" case float32, float64: return "float" case string: return "string" case bool: return "bool" case Map, map[string]interface{}: return "map" case Slice, []interface{}: return "slice" default: return fmt.Sprintf("%T", v) } }