2026-03-14 10:19:57 +08:00
|
|
|
|
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 {
|
2026-03-30 01:55:07 +08:00
|
|
|
|
app *TestApp
|
|
|
|
|
|
path string
|
|
|
|
|
|
t *testing.T
|
|
|
|
|
|
session Map
|
|
|
|
|
|
lastResp *ApiResponse
|
2026-03-14 10:19:57 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-21 13:38:22 +08:00
|
|
|
|
// 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...)
|
2026-03-14 10:19:57 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-21 13:38:22 +08:00
|
|
|
|
// Post 直接发送 POST 请求
|
|
|
|
|
|
func (a *Api) Post(desc string, status int, expect ...interface{}) *ApiResponse {
|
|
|
|
|
|
return a.newCase().Post(desc, status, expect...)
|
2026-03-14 10:19:57 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-21 13:38:22 +08:00
|
|
|
|
// Put 直接发送 PUT 请求
|
|
|
|
|
|
func (a *Api) Put(desc string, status int, expect ...interface{}) *ApiResponse {
|
|
|
|
|
|
return a.newCase().Put(desc, status, expect...)
|
2026-03-14 10:19:57 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-21 13:38:22 +08:00
|
|
|
|
// Delete 直接发送 DELETE 请求
|
|
|
|
|
|
func (a *Api) Delete(desc string, status int, expect ...interface{}) *ApiResponse {
|
|
|
|
|
|
return a.newCase().Delete(desc, status, expect...)
|
2026-03-14 10:19:57 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-30 01:55:07 +08:00
|
|
|
|
// Verify 设置请求后的校验函数,返回 ApiCase 构建器
|
|
|
|
|
|
// 在回调中可用 a.Resp() 做响应值断言 + a.DB() 做数据库校验
|
|
|
|
|
|
func (a *Api) Verify(fn func(a *Api) error) *ApiCase {
|
|
|
|
|
|
c := a.newCase()
|
|
|
|
|
|
c.verifyFn = fn
|
|
|
|
|
|
return c
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-15 11:45:21 +08:00
|
|
|
|
// Note 为下一条用例设置备注,返回可继续链式的 ApiCase
|
|
|
|
|
|
func (a *Api) Note(note string) *ApiCase {
|
|
|
|
|
|
c := a.newCase()
|
|
|
|
|
|
c.note = note
|
|
|
|
|
|
return c
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-14 10:19:57 +08:00
|
|
|
|
// DB 获取数据库实例(在测试事务内)
|
|
|
|
|
|
func (a *Api) DB() *HoTimeDB {
|
|
|
|
|
|
return &a.app.Db
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-30 01:55:07 +08:00
|
|
|
|
// Resp 获取最近一次请求的响应,在 Verify 回调中用于响应值断言
|
|
|
|
|
|
func (a *Api) Resp() *ApiResponse {
|
|
|
|
|
|
return a.lastResp
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-14 10:19:57 +08:00
|
|
|
|
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
|
2026-03-15 11:45:21 +08:00
|
|
|
|
note string
|
2026-03-21 13:38:22 +08:00
|
|
|
|
verifyFn func(a *Api) error
|
2026-03-14 10:19:57 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-15 11:45:21 +08:00
|
|
|
|
// Note 为本条用例添加可选备注,备注将显示在 swagger 调试控制台
|
|
|
|
|
|
func (c *ApiCase) Note(note string) *ApiCase {
|
|
|
|
|
|
c.note = note
|
|
|
|
|
|
return c
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-21 13:38:22 +08:00
|
|
|
|
// Verify 设置请求后的自定义校验函数(如查库验证数据状态)
|
|
|
|
|
|
// 函数返回 nil 表示校验通过,返回 error 则用例失败并记录错误原因
|
|
|
|
|
|
func (c *ApiCase) Verify(fn func(a *Api) error) *ApiCase {
|
|
|
|
|
|
c.verifyFn = fn
|
|
|
|
|
|
return c
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-14 10:19:57 +08:00
|
|
|
|
// Get 终端操作:GET 请求 + 断言
|
2026-03-21 13:38:22 +08:00
|
|
|
|
func (c *ApiCase) Get(desc string, status int, expect ...interface{}) *ApiResponse {
|
|
|
|
|
|
return c.execute("GET", desc, status, expect...)
|
2026-03-14 10:19:57 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Post 终端操作:POST 请求 + 断言
|
2026-03-21 13:38:22 +08:00
|
|
|
|
func (c *ApiCase) Post(desc string, status int, expect ...interface{}) *ApiResponse {
|
|
|
|
|
|
return c.execute("POST", desc, status, expect...)
|
2026-03-14 10:19:57 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Put 终端操作:PUT 请求 + 断言
|
2026-03-21 13:38:22 +08:00
|
|
|
|
func (c *ApiCase) Put(desc string, status int, expect ...interface{}) *ApiResponse {
|
|
|
|
|
|
return c.execute("PUT", desc, status, expect...)
|
2026-03-14 10:19:57 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Delete 终端操作:DELETE 请求 + 断言
|
2026-03-21 13:38:22 +08:00
|
|
|
|
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
|
|
|
|
|
|
}
|
2026-03-14 10:19:57 +08:00
|
|
|
|
|
|
|
|
|
|
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 {
|
2026-03-21 13:38:22 +08:00
|
|
|
|
if obj, err := JsonToObj(w.Body.String()); err == nil {
|
|
|
|
|
|
if m, ok := obj.(map[string]interface{}); ok {
|
|
|
|
|
|
body = Map(m)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-03-14 10:19:57 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-21 13:38:22 +08:00
|
|
|
|
if passed && expectMsg != "" {
|
2026-03-14 10:19:57 +08:00
|
|
|
|
actualMsg := resp.GetMsg()
|
2026-03-21 13:38:22 +08:00
|
|
|
|
if actualMsg != expectMsg {
|
|
|
|
|
|
t.Errorf("消息不匹配: 期望 %q, 实际 %q\n响应: %s", expectMsg, actualMsg, w.Body.String())
|
2026-03-14 10:19:57 +08:00
|
|
|
|
passed = false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-21 13:38:22 +08:00
|
|
|
|
if passed && hasExpectResult {
|
|
|
|
|
|
if !validateShape(body["result"], expectResult, "result", t) {
|
|
|
|
|
|
passed = false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-30 01:55:07 +08:00
|
|
|
|
c.api.lastResp = resp
|
2026-03-21 13:38:22 +08:00
|
|
|
|
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()
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-14 10:19:57 +08:00
|
|
|
|
record := TestRecord{
|
2026-03-21 13:38:22 +08:00
|
|
|
|
Path: c.api.path,
|
|
|
|
|
|
Desc: desc,
|
|
|
|
|
|
CaseName: desc,
|
|
|
|
|
|
Passed: passed,
|
|
|
|
|
|
Duration: duration,
|
|
|
|
|
|
Method: method,
|
|
|
|
|
|
Note: c.note,
|
|
|
|
|
|
VerifyError: verifyError,
|
2026-03-14 10:19:57 +08:00
|
|
|
|
}
|
|
|
|
|
|
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
|
|
|
|
|
|
}
|
2026-03-21 13:38:22 +08:00
|
|
|
|
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
|
2026-03-14 10:19:57 +08:00
|
|
|
|
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 ""
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-30 01:55:07 +08:00
|
|
|
|
// GetBody 获取完整的响应 body(JSON 解析后的 Map,可链式 Get*)
|
2026-03-14 10:19:57 +08:00
|
|
|
|
func (r *ApiResponse) GetBody() Map {
|
|
|
|
|
|
return r.Body
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-30 01:55:07 +08:00
|
|
|
|
// GetRawBody 获取原始响应字节(用于非 JSON 响应如文件下载、二进制流校验)
|
|
|
|
|
|
func (r *ApiResponse) GetRawBody() []byte {
|
|
|
|
|
|
return r.RawBody
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-14 10:19:57 +08:00
|
|
|
|
// 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)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-03-21 13:38:22 +08:00
|
|
|
|
|
|
|
|
|
|
// 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)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|