diff --git a/docs/Testing_API测试框架.md b/docs/Testing_API测试框架.md index 1ef7a37..677e1aa 100644 --- a/docs/Testing_API测试框架.md +++ b/docs/Testing_API测试框架.md @@ -1,4 +1,4 @@ -# HoTime API 测试框架使用说明 +# HoTime API 测试框架使用说明 不启动 HTTP 服务、自动事务回滚、链式 API、覆盖率报告、API 调试控制台生成。 @@ -229,6 +229,7 @@ func (app *TestApp) DB() *HoTimeDB | `c.File(field, name, content)` | 设置上传文件 | `*ApiCase` | | `c.Note(note)` | 为本条用例添加可选备注,备注显示在调试控制台用例详情中 | `*ApiCase` | | `c.WithSession(s)` | 覆盖本次请求的 session | `*ApiCase` | +| `c.Verify(fn)` | 设置请求后的自定义校验函数(详见 [Verify 数据库校验](#verify-数据库校验)) | `*ApiCase` | | `c.Get(desc, status, msg...)` | 终端:发送 GET 请求并断言 | `*ApiResponse` | | `c.Post(desc, status, msg...)` | 终端:发送 POST 请求并断言 | `*ApiResponse` | | `c.Put(desc, status, msg...)` | 终端:发送 PUT 请求并断言 | `*ApiResponse` | @@ -240,7 +241,48 @@ func (app *TestApp) DB() *HoTimeDB |------|------|------| | `desc` | string | 用例描述,作为 `t.Run` 的子测试名称 | | `status` | int | 期望的 JSON 响应体中 `status` 字段值(0=成功) | -| `msg` | ...string | 可选,期望的 `msg` 字段值(传空则不校验) | +| `expect` | ...interface{} | 可选,行为取决于 status(见下方说明) | + +**expect 参数解析规则:** + +- `status == 0`(成功): + - **不传 expect** → 仅校验 status=0,不校验 result 内容(如 `a.Post("描述", 0)`) + - 传任意类型 → result 类型/结构校验(因为成功响应没有 msg) +- `status != 0`(错误): + - 传 `string` → 用该字符串校验错误消息 + - **不传 expect** → 自动用 `desc`(第一参数)作为期望的错误消息(简写模式) + - 传非 string 类型 → 按 status=0 的规则做 result 结构校验(罕见但支持) + +**简写示例**(错误用例 desc 即 msg): + +```go +// 完整写法 +a.Post("密码为空", 4, "用户名或密码不能为空") // desc="密码为空", 校验 msg="用户名或密码不能为空" + +// 简写:desc 与错误消息相同时可省略第三参数 +a.Post("用户名或密码不能为空", 4) // desc 直接作为期望 msg +a.Get("请选择店铺", 3) // 等价于 a.Get("请选择店铺", 3, "请选择店铺") +``` + +**result 类型/结构校验:** + +expect 参数作为**类型样本**,只比较类型大类,不比较具体值。result 不一定是 Map,可以是任意类型: + +| 样本值类型 | 匹配的实际类型 | 说明 | +|-----------|--------------|------| +| `int64` / `int` 等整数 | 整数类型 | number(整数) | +| `float64` / `float32` | 浮点类型 | float(小数) | +| `string` | `string` | 字符串 | +| `bool` | `bool` | 布尔值 | +| `Map{...}` | `Map` / `map[string]interface{}` | 对象,递归校验子字段 | +| `Slice{...}` | `Slice` / `[]interface{}` | 数组,非空时校验首元素结构 | + +校验规则: +- result 是原始类型(string、int、float 等)→ 直接比较类型大类 +- result 是 Map → **多了**字段不管,**少了**预设字段报错「字段缺失」 +- 字段存在但**类型不一致** → 报错「类型不匹配」 +- Map 类型的值 → 递归验证子字段 +- Slice 类型的值且样本非空 → 取实际首元素与样本首元素递归验证 ### ApiResponse — 响应对象 @@ -255,6 +297,10 @@ resp.GetMsg() // string:业务 msg resp.GetResult() // interface{}:result 字段 resp.GetBody() // Map:完整响应体 +// 后置结构校验(支持 Map、Slice、string、int64 等任意类型样本) +resp.ExpectResult(Map{"id": int64(1), "token": "sample"}) +resp.ExpectResult("操作成功") // 校验 result 是 string 类型 + // 自定义断言 token := resp.GetBody().GetMap("result").GetString("token") if token == "" { @@ -314,6 +360,62 @@ resp := a.JSON(Map{"name": "138", "password": "123"}).Post("正常登录", 0) if resp.GetBody().GetMap("result").GetString("token") == "" { resp.Fail("响应中缺少 token 字段") } + +// ========== Result 类型/结构校验 ========== + +// result 是原始类型:校验 result 是 string +a.Post("操作成功", 0, "操作成功") + +// result 是整数 +a.Get("获取数量", 0, int64(1)) + +// result 是小数 +a.Get("获取金额", 0, float64(1.5)) + +// result 是 Map:校验字段名 + 类型 +a.Query(Map{"shop_id": "1", "id": "1"}).Get("正常获取订单", 0, Map{ + "id": int64(1), "sn": "sample", "customer_id": int64(1), +}) + +// 嵌套对象:递归校验子字段 +a.Get("获取详情", 0, Map{ + "id": int64(1), + "customer": Map{"name": "张三", "id": int64(1)}, +}) + +// result 直接是数组:校验 result 是 Slice 且首元素结构匹配 +a.Get("获取标签", 0, Slice{Map{"id": int64(1), "name": "test"}}) + +// Map 中包含数组字段 +a.JSON(Map{"page": 1, "pageSize": 10}).Post("获取列表", 0, Map{ + "total": int64(10), + "data": Slice{Map{"id": int64(1), "name": "test"}}, +}) + +// 后置校验 +resp = a.Get("获取配置", 0) +resp.ExpectResult(Map{"version": "1.0", "features": Slice{}}) + +// ========== Verify 数据库校验 ========== + +// 请求后自动查库验证,返回 nil 表示通过,返回 error 则用例失败 +a.Form(Map{"name": "test"}). + Verify(func(a *Api) error { + row := a.DB().Get("order", "*", Map{"name": "test"}) + if row == nil { + return fmt.Errorf("未找到 name=test 的记录") + } + if row.GetCeilInt64("state") != 0 { + return fmt.Errorf("state 期望 0, 实际 %d", row.GetCeilInt64("state")) + } + // 校验关联表数量 + count := a.DB().Count("order_goods", Map{"order_id": row.GetCeilInt64("id")}) + if count < 1 { + return fmt.Errorf("order_goods 期望至少 1 条, 实际 %d 条", count) + } + return nil + }). + Post("创建并验证DB", 0, Map{"id": int64(1)}) ``` --- diff --git a/example/app/app_test.go b/example/app/app_test.go index 8206ad3..ff452cd 100644 --- a/example/app/app_test.go +++ b/example/app/app_test.go @@ -8,10 +8,11 @@ import ( ) var ProjectTest = ProjTest{ - "test": TestTest, - "mysql": MysqlTest, - "dmdb": DmdbTest, - "cache": CacheTest, + "test": TestTest, + "mysql": MysqlTest, + "dmdb": DmdbTest, + "cache": CacheTest, + "expect_demo": ExpectDemoTest, } var testApp *TestApp diff --git a/example/app/expect_demo_test.go b/example/app/expect_demo_test.go new file mode 100644 index 0000000..ce44fc5 --- /dev/null +++ b/example/app/expect_demo_test.go @@ -0,0 +1,81 @@ +package app + +import ( + "fmt" + + . "code.hoteas.com/golang/hotime" + . "code.hoteas.com/golang/hotime/common" +) + +var ExpectDemoTest = CtrTest{ + "string_result": {Desc: "返回字符串", Func: func(a *Api) { + a.Get("result是字符串", 0, "操作成功") + }}, + "number_result": {Desc: "返回整数", Func: func(a *Api) { + a.Get("result是整数", 0, int64(1)) + }}, + "float_result": {Desc: "返回小数", Func: func(a *Api) { + a.Get("result是小数", 0, float64(1.0)) + }}, + "bool_result": {Desc: "返回布尔", Func: func(a *Api) { + a.Get("result是布尔", 0, true) + }}, + "map_result": {Desc: "返回对象", Func: func(a *Api) { + a.Get("result是Map-校验字段名和类型", 0, Map{ + "id": int64(1), "name": "sample", "price": float64(1.0), "in_stock": true, + }) + }}, + "nested_result": {Desc: "返回嵌套对象", Func: func(a *Api) { + a.Get("深层嵌套Map+Slice+Map", 0, Map{ + "id": int64(1), "title": "sample", + "customer": Map{"id": int64(1), "name": "sample", "phone": "sample"}, + "items": Slice{Map{ + "id": int64(1), "goods_name": "sample", "quantity": int64(1), "price": float64(1.0), + }}, + "extra": Map{ + "delivery": Map{"address": "sample", "fee": int64(1)}, + }, + }) + }}, + "slice_result": {Desc: "返回数组", Func: func(a *Api) { + a.Get("result直接是Slice", 0, Slice{Map{"id": int64(1), "name": "sample"}}) + }}, + "error_demo": {Desc: "错误+成功混合", Func: func(a *Api) { + a.Form(Map{"name": ""}).Post("名称为空-错误断言", 3, "名称不能为空") + a.Form(Map{"name": "测试"}).Post("名称正确-结构校验", 0, Map{ + "id": int64(1), "name": "sample", "created": true, + }) + }}, + "no_expect": {Desc: "不传预期(仅校验status)", Func: func(a *Api) { + a.Get("只校验status=0", 0) + }}, + "verify_demo": {Desc: "Verify数据库校验", Func: func(a *Api) { + a.Form(Map{"name": ""}).Post("名称为空", 3, "名称不能为空") + + a.Form(Map{"name": "verify_test"}). + Verify(func(a *Api) error { + row := a.DB().Get("test_batch", "*", Map{"AND": Map{"name": "verify_test", "title": "verify_demo"}}) + if row == nil { + return fmt.Errorf("test_batch 表未找到 name=verify_test 的记录") + } + if row.GetCeilInt64("state") != 0 { + return fmt.Errorf("test_batch.state 期望 0, 实际 %d", row.GetCeilInt64("state")) + } + return nil + }). + Post("写入并校验DB-正确", 0, Map{"id": int64(1), "name": "sample"}) + + a.Form(Map{"name": "verify_fail"}). + Verify(func(a *Api) error { + row := a.DB().Get("test_batch", "*", Map{"AND": Map{"name": "verify_fail", "title": "verify_demo"}}) + if row == nil { + return fmt.Errorf("test_batch 表未找到 name=verify_fail 的记录") + } + if row.GetCeilInt64("state") != 99 { + return fmt.Errorf("test_batch.state 期望 99, 实际 %d(接口写入的是0,这里故意校验99来演示失败效果)", row.GetCeilInt64("state")) + } + return nil + }). + Post("写入并校验DB-故意失败", 0, Map{"id": int64(1), "name": "sample"}) + }}, +} diff --git a/example/app/init.go b/example/app/init.go index c8076b9..ff96151 100644 --- a/example/app/init.go +++ b/example/app/init.go @@ -5,8 +5,9 @@ import ( ) var Project = Proj{ - "test": TestCtr, - "mysql": MysqlCtr, - "dmdb": DmdbCtr, - "cache": CacheCtr, + "test": TestCtr, + "mysql": MysqlCtr, + "dmdb": DmdbCtr, + "cache": CacheCtr, + "expect_demo": ExpectDemoCtr, } diff --git a/example/app/test.go b/example/app/test.go index 996b9d6..54a422f 100644 --- a/example/app/test.go +++ b/example/app/test.go @@ -43,6 +43,67 @@ func nowFunc(that *Context) string { return "NOW()" } +// ExpectDemoCtr 用于展示 result 结构校验的各种场景 +var ExpectDemoCtr = Ctr{ + "string_result": func(that *Context) { + that.Display(0, "操作成功") + }, + "number_result": func(that *Context) { + that.Display(0, 42) + }, + "float_result": func(that *Context) { + that.Display(0, 3.14) + }, + "bool_result": func(that *Context) { + that.Display(0, true) + }, + "map_result": func(that *Context) { + that.Display(0, Map{ + "id": 1, "name": "示例商品", "price": 19.9, "in_stock": true, + }) + }, + "nested_result": func(that *Context) { + that.Display(0, Map{ + "id": 100, "title": "测试订单", + "customer": Map{"id": 1, "name": "张三", "phone": "13800138000"}, + "items": Slice{ + Map{"id": 1, "goods_name": "苹果", "quantity": 5, "price": 3.5}, + Map{"id": 2, "goods_name": "香蕉", "quantity": 10, "price": 2.0}, + }, + "extra": Map{ + "delivery": Map{"address": "XX路1号", "fee": 5}, + }, + }) + }, + "slice_result": func(that *Context) { + that.Display(0, Slice{ + Map{"id": 1, "name": "标签A"}, + Map{"id": 2, "name": "标签B"}, + }) + }, + "error_demo": func(that *Context) { + name := that.Req.FormValue("name") + if name == "" { + that.Display(3, "名称不能为空") + return + } + that.Display(0, Map{"id": 1, "name": name, "created": true}) + }, + "no_expect": func(that *Context) { + that.Display(0, Map{"id": 1, "data": "无预期校验"}) + }, + "verify_demo": func(that *Context) { + name := that.Req.FormValue("name") + if name == "" { + that.Display(3, "名称不能为空") + return + } + initTestTables(that) + id := that.Db.Insert("test_batch", Map{"name": name, "title": "verify_demo", "state": 0}) + that.Display(0, Map{"id": id, "name": name}) + }, +} + // TestCtr 通用 ORM 测试控制器(数据库无关) var TestCtr = Ctr{ "test": func(that *Context) { diff --git a/example/tpt/swagger/app/api-spec.json b/example/tpt/swagger/app/api-spec.json index e606728..0c7cb30 100644 --- a/example/tpt/swagger/app/api-spec.json +++ b/example/tpt/swagger/app/api-spec.json @@ -97,6 +97,9 @@ ] }, "status": 0 + }, + "expect": { + "status": 0 } } ], @@ -119,6 +122,9 @@ "message": "批量缓存测试完成,请查看控制台输出和日志文件" }, "status": 0 + }, + "expect": { + "status": 0 } } ], @@ -165,7 +171,7 @@ "key_in_new_table": false, "name": "4. 测试兼容模式老表回退读取", "result": false, - "test_key": "test_compat_fallback_1773975517169601300" + "test_key": "test_compat_fallback_1774071258597810000" }, { "name": "5. 测试兼容模式写新删老", @@ -183,9 +189,12 @@ "result": false } ], - "timestamp": "2026-03-20 10:58:37" + "timestamp": "2026-03-21 13:34:18" }, "status": 0 + }, + "expect": { + "status": 0 } } ], @@ -266,9 +275,9 @@ ], "sample_data": [ { - "create_time": "2026-03-20T10:28:27.135243+08:00", + "create_time": "2026-03-20 10:28:27", "id": 1, - "modify_time": "2026-03-20T10:28:27.135244+08:00", + "modify_time": "2026-03-20 10:28:27", "name": "超级管理员", "password": "admin123", "phone": "13800000001", @@ -277,9 +286,9 @@ "title": "系统管理员" }, { - "create_time": "2026-03-20T10:28:27.172725+08:00", + "create_time": "2026-03-20 10:28:27", "id": 2, - "modify_time": "2026-03-20T10:28:27.172727+08:00", + "modify_time": "2026-03-20 10:28:27", "name": "编辑员", "password": "editor123", "phone": "13800000002", @@ -288,9 +297,9 @@ "title": "内容编辑" }, { - "create_time": "2026-03-20T10:28:27.208006+08:00", + "create_time": "2026-03-20 10:28:27", "id": 3, - "modify_time": "2026-03-20T10:28:27.208007+08:00", + "modify_time": "2026-03-20 10:28:27", "name": "审核员", "password": "review123", "phone": "13800000003", @@ -302,6 +311,9 @@ "table": "admin" }, "status": 0 + }, + "expect": { + "status": 0 } } ], @@ -312,7 +324,6 @@ "name": "table", "required": false, "type": "string", - "example": "admin", "in": "query" } ], @@ -338,52 +349,52 @@ "result": true }, { - "affected": 0, + "affected": 1, "name": "Exec 原生执行 (双引号)", "result": true }, { - "count": 18, + "count": 46, "name": "USER_TABLES 查询", "result": true }, { "columns": [ { - "COLUMN_NAME": "id", - "DATA_TYPE": "INT" + "column_name": "id", + "data_type": "INT" }, { - "COLUMN_NAME": "name", - "DATA_TYPE": "VARCHAR" + "column_name": "name", + "data_type": "VARCHAR" }, { - "COLUMN_NAME": "phone", - "DATA_TYPE": "VARCHAR" + "column_name": "phone", + "data_type": "VARCHAR" }, { - "COLUMN_NAME": "state", - "DATA_TYPE": "INT" + "column_name": "state", + "data_type": "INT" }, { - "COLUMN_NAME": "password", - "DATA_TYPE": "VARCHAR" + "column_name": "password", + "data_type": "VARCHAR" }, { - "COLUMN_NAME": "role_id", - "DATA_TYPE": "INT" + "column_name": "role_id", + "data_type": "INT" }, { - "COLUMN_NAME": "title", - "DATA_TYPE": "VARCHAR" + "column_name": "title", + "data_type": "VARCHAR" }, { - "COLUMN_NAME": "create_time", - "DATA_TYPE": "TIMESTAMP" + "column_name": "create_time", + "data_type": "TIMESTAMP" }, { - "COLUMN_NAME": "modify_time", - "DATA_TYPE": "TIMESTAMP" + "column_name": "modify_time", + "data_type": "TIMESTAMP" } ], "count": 9, @@ -393,6 +404,9 @@ ] }, "status": 0 + }, + "expect": { + "status": 0 } } ], @@ -484,10 +498,125 @@ { "label": null, "name": "test_batch" + }, + { + "label": "应用表", + "name": "app" + }, + { + "label": "应用分类表", + "name": "app_category" + }, + { + "label": "应用凭证表", + "name": "app_credential" + }, + { + "label": "应用评分表", + "name": "app_rating" + }, + { + "label": "应用评分附件表", + "name": "app_rating_file" + }, + { + "label": "开发者表", + "name": "developer" + }, + { + "label": "意见反馈表", + "name": "feedback" + }, + { + "label": "反馈附件表", + "name": "feedback_file" + }, + { + "label": "帮助分类表", + "name": "help_category" + }, + { + "label": "帮助问题表", + "name": "help_question" + }, + { + "label": null, + "name": "hotime_cache" + }, + { + "label": "登录日志表", + "name": "login_log" + }, + { + "label": "消息主表", + "name": "message" + }, + { + "label": "消息回调日志表", + "name": "message_callback_log" + }, + { + "label": "新闻公告表", + "name": "news" + }, + { + "label": "新闻分类表", + "name": "news_category" + }, + { + "label": "OAuth授权码表", + "name": "oauth_code" + }, + { + "label": "组织部门表", + "name": "org" + }, + { + "label": "组织应用关联表", + "name": "org_app" + }, + { + "label": "职务岗位表", + "name": "position" + }, + { + "label": "角色表", + "name": "role" + }, + { + "label": "用户表", + "name": "user" + }, + { + "label": "用户应用关联表", + "name": "user_app" + }, + { + "label": "用户认证表", + "name": "user_auth" + }, + { + "label": "用户设备表", + "name": "user_device" + }, + { + "label": "用户钉钉关联表", + "name": "user_dingtalk" + }, + { + "label": "用户消息关联表", + "name": "user_message" + }, + { + "label": "用户组织关联表", + "name": "user_org" } ] }, "status": 0 + }, + "expect": { + "status": 0 } } ], @@ -499,6 +628,439 @@ "summary": "查询达梦所有表", "tested": true }, + { + "cases": [ + { + "name": "result是布尔", + "method": "GET", + "passed": true, + "response": { + "result": true, + "status": 0 + }, + "expect": { + "result": true, + "status": 0 + } + } + ], + "ctr": "expect_demo", + "method": "GET", + "params": [], + "path": "/app/expect_demo/bool_result", + "project": "app", + "summary": "返回布尔", + "tested": true + }, + { + "cases": [ + { + "name": "名称为空-错误断言", + "method": "POST", + "passed": true, + "form": { + "name": "" + }, + "response": { + "error": { + "msg": "名称不能为空", + "type": "请求参数异常" + }, + "result": { + "msg": "名称不能为空", + "type": "请求参数异常" + }, + "status": 3 + }, + "expect": { + "error": { + "msg": "名称不能为空", + "type": "请求参数异常" + }, + "result": { + "msg": "名称不能为空", + "type": "请求参数异常" + }, + "status": 3 + } + }, + { + "name": "名称正确-结构校验", + "method": "POST", + "passed": true, + "form": { + "name": "测试" + }, + "response": { + "result": { + "created": true, + "id": 1, + "name": "测试" + }, + "status": 0 + }, + "expect": { + "result": { + "created": true, + "id": 1, + "name": "sample" + }, + "status": 0 + } + } + ], + "ctr": "expect_demo", + "method": "POST", + "params": [ + { + "name": "name", + "required": false, + "type": "string", + "in": "form" + } + ], + "path": "/app/expect_demo/error_demo", + "project": "app", + "summary": "错误+成功混合", + "tested": true + }, + { + "cases": [ + { + "name": "result是小数", + "method": "GET", + "passed": true, + "response": { + "result": 3.14, + "status": 0 + }, + "expect": { + "result": 1, + "status": 0 + } + } + ], + "ctr": "expect_demo", + "method": "GET", + "params": [], + "path": "/app/expect_demo/float_result", + "project": "app", + "summary": "返回小数", + "tested": true + }, + { + "cases": [ + { + "name": "result是Map-校验字段名和类型", + "method": "GET", + "passed": true, + "response": { + "result": { + "id": 1, + "in_stock": true, + "name": "示例商品", + "price": 19.9 + }, + "status": 0 + }, + "expect": { + "result": { + "id": 1, + "in_stock": true, + "name": "sample", + "price": 1 + }, + "status": 0 + } + } + ], + "ctr": "expect_demo", + "method": "GET", + "params": [], + "path": "/app/expect_demo/map_result", + "project": "app", + "summary": "返回对象", + "tested": true + }, + { + "cases": [ + { + "name": "深层嵌套Map+Slice+Map", + "method": "GET", + "passed": true, + "response": { + "result": { + "customer": { + "id": 1, + "name": "张三", + "phone": "13800138000" + }, + "extra": { + "delivery": { + "address": "XX路1号", + "fee": 5 + } + }, + "id": 100, + "items": [ + { + "goods_name": "苹果", + "id": 1, + "price": 3.5, + "quantity": 5 + }, + { + "goods_name": "香蕉", + "id": 2, + "price": 2, + "quantity": 10 + } + ], + "title": "测试订单" + }, + "status": 0 + }, + "expect": { + "result": { + "customer": { + "id": 1, + "name": "sample", + "phone": "sample" + }, + "extra": { + "delivery": { + "address": "sample", + "fee": 1 + } + }, + "id": 1, + "items": [ + { + "goods_name": "sample", + "id": 1, + "price": 1, + "quantity": 1 + } + ], + "title": "sample" + }, + "status": 0 + } + } + ], + "ctr": "expect_demo", + "method": "GET", + "params": [], + "path": "/app/expect_demo/nested_result", + "project": "app", + "summary": "返回嵌套对象", + "tested": true + }, + { + "cases": [ + { + "name": "只校验status=0", + "method": "GET", + "passed": true, + "response": { + "result": { + "data": "无预期校验", + "id": 1 + }, + "status": 0 + }, + "expect": { + "status": 0 + } + } + ], + "ctr": "expect_demo", + "method": "GET", + "params": [], + "path": "/app/expect_demo/no_expect", + "project": "app", + "summary": "不传预期(仅校验status)", + "tested": true + }, + { + "cases": [ + { + "name": "result是整数", + "method": "GET", + "passed": true, + "response": { + "result": 42, + "status": 0 + }, + "expect": { + "result": 1, + "status": 0 + } + } + ], + "ctr": "expect_demo", + "method": "GET", + "params": [], + "path": "/app/expect_demo/number_result", + "project": "app", + "summary": "返回整数", + "tested": true + }, + { + "cases": [ + { + "name": "result直接是Slice", + "method": "GET", + "passed": true, + "response": { + "result": [ + { + "id": 1, + "name": "标签A" + }, + { + "id": 2, + "name": "标签B" + } + ], + "status": 0 + }, + "expect": { + "result": [ + { + "id": 1, + "name": "sample" + } + ], + "status": 0 + } + } + ], + "ctr": "expect_demo", + "method": "GET", + "params": [], + "path": "/app/expect_demo/slice_result", + "project": "app", + "summary": "返回数组", + "tested": true + }, + { + "cases": [ + { + "name": "result是字符串", + "method": "GET", + "passed": true, + "response": { + "result": "操作成功", + "status": 0 + }, + "expect": { + "result": "操作成功", + "status": 0 + } + } + ], + "ctr": "expect_demo", + "method": "GET", + "params": [], + "path": "/app/expect_demo/string_result", + "project": "app", + "summary": "返回字符串", + "tested": true + }, + { + "cases": [ + { + "name": "名称为空", + "method": "POST", + "passed": true, + "form": { + "name": "" + }, + "response": { + "error": { + "msg": "名称不能为空", + "type": "请求参数异常" + }, + "result": { + "msg": "名称不能为空", + "type": "请求参数异常" + }, + "status": 3 + }, + "expect": { + "error": { + "msg": "名称不能为空", + "type": "请求参数异常" + }, + "result": { + "msg": "名称不能为空", + "type": "请求参数异常" + }, + "status": 3 + } + }, + { + "name": "写入并校验DB-正确", + "method": "POST", + "passed": true, + "form": { + "name": "verify_test" + }, + "response": { + "result": { + "id": 173, + "name": "verify_test" + }, + "status": 0 + }, + "expect": { + "result": { + "id": 1, + "name": "sample" + }, + "status": 0 + } + }, + { + "name": "写入并校验DB-故意失败", + "method": "POST", + "passed": false, + "form": { + "name": "verify_fail" + }, + "response": { + "result": { + "id": 174, + "name": "verify_fail" + }, + "status": 0 + }, + "expect": { + "result": { + "id": 1, + "name": "sample" + }, + "status": 0 + }, + "verifyError": "test_batch.state 期望 99, 实际 0(接口写入的是0,这里故意校验99来演示失败效果)" + } + ], + "ctr": "expect_demo", + "method": "POST", + "params": [ + { + "name": "name", + "required": false, + "type": "string", + "in": "form" + } + ], + "path": "/app/expect_demo/verify_demo", + "project": "app", + "summary": "Verify数据库校验", + "tested": true + }, { "cases": [ { @@ -514,6 +1076,9 @@ "skip": true }, "status": 0 + }, + "expect": { + "status": 0 } } ], @@ -524,7 +1089,6 @@ "name": "table", "required": false, "type": "string", - "example": "admin", "in": "query" } ], @@ -545,6 +1109,9 @@ "skip": true }, "status": 0 + }, + "expect": { + "status": 0 } } ], @@ -568,6 +1135,9 @@ "skip": true }, "status": 0 + }, + "expect": { + "status": 0 } } ], @@ -591,12 +1161,12 @@ "success": true, "tests": [ { - "count": 11, + "count": 0, "name": "Count 总数统计", "result": true }, { - "count": 9, + "count": 0, "name": "Count 条件统计", "result": true }, @@ -604,23 +1174,23 @@ "lastQuery": "SELECT SUM(click_num) FROM \"article\" WHERE \"state\" =? ;", "name": "Sum 求和 (单字段名)", "result": true, - "sum": 12311 + "sum": 0 }, { - "avg": 1367.888889, + "avg": 0, "lastQuery": "SELECT AVG(click_num) FROM \"article\" WHERE \"state\" =? ;", "name": "Avg 平均值 (单字段名)", "result": true }, { "lastQuery": "SELECT MAX(click_num) FROM \"article\" WHERE \"state\" =? ;", - "max": 9999, + "max": 0, "name": "Max 最大值 (单字段名)", "result": true }, { "lastQuery": "SELECT MIN(sort) FROM \"article\" WHERE \"state\" =? ;", - "min": 1, + "min": 0, "name": "Min 最小值 (单字段名)", "result": true }, @@ -629,42 +1199,42 @@ "result": true, "stats": [ { - "article_count": 4, + "article_count": 36, "avg_clicks": "450", "ctg_id": 2, - "total_clicks": 1800 + "total_clicks": 16200 }, { - "article_count": 3, + "article_count": 27, "avg_clicks": "3461", "ctg_id": 1, - "total_clicks": 10383 + "total_clicks": 93447 }, { - "article_count": 1, + "article_count": 9, "avg_clicks": "32", "ctg_id": 3, - "total_clicks": 32 + "total_clicks": 288 }, { - "article_count": 1, + "article_count": 9, "avg_clicks": "96", "ctg_id": 4, - "total_clicks": 96 + "total_clicks": 864 } ] }, { "lastQuery": "SELECT SUM(\"article\".\"click_num\") FROM \"article\" WHERE \"state\" =? ;", - "match_single_field": false, + "match_single_field": true, "name": "Sum 求和 (table.column 格式)", "result": true, "sum": 0 }, { "name": "聚合函数一致性验证", - "result": false, - "summary": "Sum: 12311=0, Avg: 1367.888889=0, Max: 9999=0, Min: 1=0" + "result": true, + "summary": "Sum: 0=0, Avg: 0=0, Max: 0=0, Min: 0=0" }, { "lastQuery": "SELECT SUM(\"article\".\"click_num\") FROM \"article\" LEFT JOIN \"ctg\" ON \"article\".\"ctg_id\" = \"ctg\".\"id\" WHERE \"article\".\"state\" =? ;", @@ -673,7 +1243,7 @@ "sum": 0 }, { - "count": 9, + "count": 0, "lastQuery": "SELECT COUNT(*) FROM \"article\" LEFT JOIN \"ctg\" ON \"article\".\"ctg_id\" = \"ctg\".\"id\" WHERE \"article\".\"state\" =? ;", "name": "Count 带 JOIN", "result": true @@ -681,6 +1251,9 @@ ] }, "status": 0 + }, + "expect": { + "status": 0 } } ], @@ -705,25 +1278,25 @@ "success": true, "tests": [ { - "adminId": 10, - "lastQuery": "INSERT INTO \"admin\" (\"name\",\"phone\",\"state\",\"password\",\"role_id\",\"title\",\"create_time\",\"modify_time\") VALUES (?,?,?,?,?,?,NOW(),NOW());", + "adminId": 88, + "lastQuery": "INSERT INTO \"admin\" (\"phone\",\"state\",\"password\",\"role_id\",\"title\",\"create_time\",\"modify_time\",\"name\") VALUES (?,?,?,?,?,NOW(),NOW(),?);", "name": "Insert 插入测试 (admin表)", "result": true }, { "admin": { - "create_time": "2026-03-20T10:58:29.604213+08:00", - "id": 10, - "modify_time": "2026-03-20T10:58:29.604215+08:00", - "name": "测试管理员_1773975510", + "create_time": "2026-03-21 13:33:57", + "id": 88, + "modify_time": "2026-03-21 13:33:57", + "name": "测试管理员_1774071237", "password": "test123456", - "phone": "13873975510", + "phone": "13874071237", "role_id": 1, "state": 1, "title": "测试职位" }, "name": "Get 获取单条记录测试", - "result": false + "result": true }, { "count": 4, @@ -774,7 +1347,7 @@ "result": true }, { - "count": 1, + "count": 3, "lastQuery": "SELECT id,title FROM \"article\" WHERE \"title\" LIKE ? LIMIT 3 ;", "name": "LIKE 模糊查询 ([~])", "result": true @@ -845,17 +1418,17 @@ "success": true, "tests": [ { - "count": 9, + "count": 81, "name": "基本链式查询 Table().Where().Select()", "result": true }, { - "count": 9, + "count": 81, "name": "链式 And 条件", "result": true }, { - "count": 9, + "count": 81, "name": "链式 Or 条件", "result": true }, @@ -879,7 +1452,7 @@ "result": true }, { - "count": 9, + "count": 0, "name": "链式 Count 统计", "result": true }, @@ -893,25 +1466,25 @@ "result": true, "stats": [ { - "cnt": 3, + "cnt": 27, "ctg_id": 1 }, { - "cnt": 4, + "cnt": 36, "ctg_id": 2 }, { - "cnt": 1, + "cnt": 9, "ctg_id": 3 }, { - "cnt": 1, + "cnt": 9, "ctg_id": 4 } ] }, { - "affected": 0, + "affected": 1, "name": "链式 Update 更新", "result": true } @@ -1008,12 +1581,12 @@ "success": true, "tests": [ { - "count": 11, + "count": 0, "name": "Count 总数统计", "result": true }, { - "count": 9, + "count": 0, "name": "Count 条件统计", "result": true }, @@ -1021,23 +1594,23 @@ "lastQuery": "SELECT SUM(click_num) FROM \"article\" WHERE \"state\" =? ;", "name": "Sum 求和 (单字段名)", "result": true, - "sum": 12311 + "sum": 0 }, { - "avg": 1367.888889, + "avg": 0, "lastQuery": "SELECT AVG(click_num) FROM \"article\" WHERE \"state\" =? ;", "name": "Avg 平均值 (单字段名)", "result": true }, { "lastQuery": "SELECT MAX(click_num) FROM \"article\" WHERE \"state\" =? ;", - "max": 9999, + "max": 0, "name": "Max 最大值 (单字段名)", "result": true }, { "lastQuery": "SELECT MIN(sort) FROM \"article\" WHERE \"state\" =? ;", - "min": 1, + "min": 0, "name": "Min 最小值 (单字段名)", "result": true }, @@ -1046,42 +1619,42 @@ "result": true, "stats": [ { - "article_count": 4, + "article_count": 36, "avg_clicks": "450", "ctg_id": 2, - "total_clicks": 1800 + "total_clicks": 16200 }, { - "article_count": 3, + "article_count": 27, "avg_clicks": "3461", "ctg_id": 1, - "total_clicks": 10383 + "total_clicks": 93447 }, { - "article_count": 1, + "article_count": 9, "avg_clicks": "32", "ctg_id": 3, - "total_clicks": 32 + "total_clicks": 288 }, { - "article_count": 1, + "article_count": 9, "avg_clicks": "96", "ctg_id": 4, - "total_clicks": 96 + "total_clicks": 864 } ] }, { "lastQuery": "SELECT SUM(\"article\".\"click_num\") FROM \"article\" WHERE \"state\" =? ;", - "match_single_field": false, + "match_single_field": true, "name": "Sum 求和 (table.column 格式)", "result": true, "sum": 0 }, { "name": "聚合函数一致性验证", - "result": false, - "summary": "Sum: 12311=0, Avg: 1367.888889=0, Max: 9999=0, Min: 1=0" + "result": true, + "summary": "Sum: 0=0, Avg: 0=0, Max: 0=0, Min: 0=0" }, { "lastQuery": "SELECT SUM(\"article\".\"click_num\") FROM \"article\" LEFT JOIN \"ctg\" ON \"article\".\"ctg_id\" = \"ctg\".\"id\" WHERE \"article\".\"state\" =? ;", @@ -1090,7 +1663,7 @@ "sum": 0 }, { - "count": 9, + "count": 0, "lastQuery": "SELECT COUNT(*) FROM \"article\" LEFT JOIN \"ctg\" ON \"article\".\"ctg_id\" = \"ctg\".\"id\" WHERE \"article\".\"state\" =? ;", "name": "Count 带 JOIN", "result": true @@ -1107,7 +1680,7 @@ "result": true }, { - "count": 4, + "count": 5, "name": "PageSelect 第二页", "result": true }, @@ -1147,7 +1720,7 @@ "tests": [ { "affected": 1, - "lastQuery": "MERGE INTO \"admin\" USING (SELECT ? AS \"title\", NOW() AS \"create_time\", NOW() AS \"modify_time\", ? AS \"name\", ? AS \"phone\", ? AS \"state\", ? AS \"password\", ? AS \"role_id\") src ON (\"admin\".\"phone\" = src.\"phone\") WHEN MATCHED THEN UPDATE SET \"name\" = src.\"name\", \"state\" = src.\"state\", \"title\" = src.\"title\", \"modify_time\" = NOW() WHEN NOT MATCHED THEN INSERT (\"title\", \"create_time\", \"modify_time\", \"name\", \"phone\", \"state\", \"password\", \"role_id\") VALUES (src.\"title\", src.\"create_time\", src.\"modify_time\", src.\"name\", src.\"phone\", src.\"state\", src.\"password\", src.\"role_id\")", + "lastQuery": "MERGE INTO \"admin\" USING (SELECT ? AS \"password\", ? AS \"role_id\", ? AS \"title\", NOW() AS \"create_time\", NOW() AS \"modify_time\", ? AS \"name\", ? AS \"phone\", ? AS \"state\") src ON (\"admin\".\"phone\" = src.\"phone\") WHEN MATCHED THEN UPDATE SET \"name\" = src.\"name\", \"state\" = src.\"state\", \"title\" = src.\"title\", \"modify_time\" = NOW() WHEN NOT MATCHED THEN INSERT (\"password\", \"role_id\", \"title\", \"create_time\", \"modify_time\", \"name\", \"phone\", \"state\") VALUES (src.\"password\", src.\"role_id\", src.\"title\", src.\"create_time\", src.\"modify_time\", src.\"name\", src.\"phone\", src.\"state\")", "name": "Upsert 插入新记录 (admin表)", "result": true }, @@ -1156,12 +1729,12 @@ "name": "Upsert 更新已存在记录", "result": true, "updatedAdmin": { - "create_time": "2026-03-20T10:58:33.501828+08:00", - "id": 11, - "modify_time": "2026-03-20T10:58:33.566436+08:00", + "create_time": "2026-03-21 13:34:05", + "id": 89, + "modify_time": "2026-03-21 13:34:05", "name": "Upsert更新后管理员", "password": "test123", - "phone": "19973975514", + "phone": "19974071244", "role_id": 2, "state": 1, "title": "更新后职位" @@ -1187,6 +1760,9 @@ } }, "status": 0 + }, + "expect": { + "status": 0 } } ], @@ -1223,6 +1799,9 @@ ] }, "status": 0 + }, + "expect": { + "status": 0 } } ], @@ -1246,17 +1825,17 @@ "success": true, "tests": [ { - "count": 9, + "count": 81, "name": "基本链式查询 Table().Where().Select()", "result": true }, { - "count": 9, + "count": 81, "name": "链式 And 条件", "result": true }, { - "count": 9, + "count": 81, "name": "链式 Or 条件", "result": true }, @@ -1280,7 +1859,7 @@ "result": true }, { - "count": 9, + "count": 0, "name": "链式 Count 统计", "result": true }, @@ -1294,31 +1873,34 @@ "result": true, "stats": [ { - "cnt": 3, + "cnt": 27, "ctg_id": 1 }, { - "cnt": 4, + "cnt": 36, "ctg_id": 2 }, { - "cnt": 1, + "cnt": 9, "ctg_id": 3 }, { - "cnt": 1, + "cnt": 9, "ctg_id": 4 } ] }, { - "affected": 0, + "affected": 1, "name": "链式 Update 更新", "result": true } ] }, "status": 0 + }, + "expect": { + "status": 0 } } ], @@ -1363,7 +1945,7 @@ "result": true }, { - "count": 1, + "count": 3, "lastQuery": "SELECT id,title FROM \"article\" WHERE \"title\" LIKE ? LIMIT 3 ;", "name": "LIKE 模糊查询 ([~])", "result": true @@ -1430,6 +2012,9 @@ ] }, "status": 0 + }, + "expect": { + "status": 0 } } ], @@ -1453,25 +2038,25 @@ "success": true, "tests": [ { - "adminId": 9, - "lastQuery": "INSERT INTO \"admin\" (\"password\",\"role_id\",\"title\",\"create_time\",\"modify_time\",\"name\",\"phone\",\"state\") VALUES (?,?,?,NOW(),NOW(),?,?,?);", + "adminId": 90, + "lastQuery": "INSERT INTO \"admin\" (\"modify_time\",\"name\",\"phone\",\"state\",\"password\",\"role_id\",\"title\",\"create_time\") VALUES (NOW(),?,?,?,?,?,?,NOW());", "name": "Insert 插入测试 (admin表)", "result": true }, { "admin": { - "create_time": "2026-03-20T10:58:25.844257+08:00", - "id": 9, - "modify_time": "2026-03-20T10:58:25.844258+08:00", - "name": "测试管理员_1773975506", + "create_time": "2026-03-21 13:34:06", + "id": 90, + "modify_time": "2026-03-21 13:34:06", + "name": "测试管理员_1774071246", "password": "test123456", - "phone": "13873975506", + "phone": "13874071246", "role_id": 1, "state": 1, "title": "测试职位" }, "name": "Get 获取单条记录测试", - "result": false + "result": true }, { "count": 4, @@ -1497,6 +2082,9 @@ ] }, "status": 0 + }, + "expect": { + "status": 0 } } ], @@ -1602,6 +2190,9 @@ ] }, "status": 0 + }, + "expect": { + "status": 0 } } ], @@ -1630,7 +2221,7 @@ "result": true }, { - "count": 4, + "count": 5, "name": "PageSelect 第二页", "result": true }, @@ -1648,6 +2239,9 @@ ] }, "status": 0 + }, + "expect": { + "status": 0 } } ], @@ -1693,6 +2287,9 @@ ] }, "status": 0 + }, + "expect": { + "status": 0 } } ], @@ -1717,7 +2314,7 @@ "tests": [ { "affected": 1, - "lastQuery": "MERGE INTO \"admin\" USING (SELECT NOW() AS \"create_time\", NOW() AS \"modify_time\", ? AS \"name\", ? AS \"phone\", ? AS \"state\", ? AS \"password\", ? AS \"role_id\", ? AS \"title\") src ON (\"admin\".\"phone\" = src.\"phone\") WHEN MATCHED THEN UPDATE SET \"name\" = src.\"name\", \"state\" = src.\"state\", \"title\" = src.\"title\", \"modify_time\" = NOW() WHEN NOT MATCHED THEN INSERT (\"create_time\", \"modify_time\", \"name\", \"phone\", \"state\", \"password\", \"role_id\", \"title\") VALUES (src.\"create_time\", src.\"modify_time\", src.\"name\", src.\"phone\", src.\"state\", src.\"password\", src.\"role_id\", src.\"title\")", + "lastQuery": "MERGE INTO \"admin\" USING (SELECT ? AS \"state\", ? AS \"password\", ? AS \"role_id\", ? AS \"title\", NOW() AS \"create_time\", NOW() AS \"modify_time\", ? AS \"name\", ? AS \"phone\") src ON (\"admin\".\"phone\" = src.\"phone\") WHEN MATCHED THEN UPDATE SET \"name\" = src.\"name\", \"state\" = src.\"state\", \"title\" = src.\"title\", \"modify_time\" = NOW() WHEN NOT MATCHED THEN INSERT (\"state\", \"password\", \"role_id\", \"title\", \"create_time\", \"modify_time\", \"name\", \"phone\") VALUES (src.\"state\", src.\"password\", src.\"role_id\", src.\"title\", src.\"create_time\", src.\"modify_time\", src.\"name\", src.\"phone\")", "name": "Upsert 插入新记录 (admin表)", "result": true }, @@ -1726,12 +2323,12 @@ "name": "Upsert 更新已存在记录", "result": true, "updatedAdmin": { - "create_time": "2026-03-20T10:58:25.400239+08:00", - "id": 8, - "modify_time": "2026-03-20T10:58:25.461693+08:00", + "create_time": "2026-03-21 13:34:09", + "id": 91, + "modify_time": "2026-03-21 13:34:10", "name": "Upsert更新后管理员", "password": "test123", - "phone": "19973975506", + "phone": "19974071249", "role_id": 2, "state": 1, "title": "更新后职位" @@ -1740,6 +2337,9 @@ ] }, "status": 0 + }, + "expect": { + "status": 0 } } ], diff --git a/example/tpt/swagger/app/index.html b/example/tpt/swagger/app/index.html index 72d278d..ccc1331 100644 --- a/example/tpt/swagger/app/index.html +++ b/example/tpt/swagger/app/index.html @@ -53,6 +53,20 @@ input,select,textarea,button{font:inherit}pre{margin:0} .csc h5{color:var(--accent);margin:0 0 4px;font-size:11px;letter-spacing:.5px;display:flex;align-items:center} .csc h5 .cp-sm{margin-left:auto}.csc h5:not(:first-child){margin-top:8px} pre.j{background:#0a0a1a;padding:6px 8px;border-radius:var(--r);font-size:12px;line-height:1.4;color:#aaa;white-space:pre-wrap;word-break:break-all;max-height:260px;overflow:auto;font-family:Consolas,Monaco,monospace} +.resp-wrap{display:flex;gap:0}.resp-main{flex:1;min-width:0} +.resp-main .rsp-hd{display:flex;align-items:center;margin-bottom:3px;font-size:10px;color:var(--fg2);letter-spacing:.5px} +.resp-main .rsp-hd .cp-sm{margin-left:auto} +.exp-col-btn{flex-shrink:0;width:22px;display:flex;align-items:stretch;justify-content:center;margin:0 2px} +.exp-col-btn button{writing-mode:vertical-rl;background:var(--accent);border:none;color:#fff;font-size:10px;padding:6px 3px;border-radius:var(--r);cursor:pointer;user-select:none;line-height:1.2;letter-spacing:1px;opacity:.85} +.exp-col-btn button:hover{opacity:1} +.exp-panel{flex:1;min-width:0;display:none;padding-left:6px} +.resp-wrap.exp-on .exp-panel{display:block} +.exp-panel .ep-hd{display:flex;align-items:center;margin-bottom:3px;font-size:10px;color:var(--fg2);letter-spacing:.5px} +.exp-panel .ep-hd .cp-sm{margin-left:auto} +.exp-panel pre.j{border-left:2px solid var(--accent)} +pre.j.resp-j{max-height:calc(100vh - 280px);min-height:120px} +.verify-err{margin-top:8px;padding:8px 10px;background:rgba(255,77,79,0.08);border:1px solid rgba(255,77,79,0.3);border-radius:var(--r);font-size:12px;color:#ff6b6b;line-height:1.5;white-space:pre-wrap;word-break:break-all} +.verify-label{display:inline-block;background:var(--red);color:#fff;font-size:10px;padding:1px 6px;border-radius:var(--r);margin-right:8px;vertical-align:middle} .sec{margin-top:10px;padding-top:10px;border-top:1px solid var(--border)}.sec-title{color:var(--fg2);font-size:11px;letter-spacing:.5px;margin-bottom:4px} .row-hd{display:flex;align-items:center;margin-bottom:4px;padding:4px 8px;background:var(--bg2);border-radius:var(--r)}.row-hd .sec-title{flex:1;margin:0} .kv{display:flex;gap:4px;margin-bottom:3px;align-items:center}.kv input{flex:1;padding:4px 6px;border:1px solid var(--border);border-radius:var(--r);background:var(--bg2);color:var(--fg);font-size:12px;outline:none} @@ -187,12 +201,18 @@ function cases(e){ h+='
'+esc(c.name)+''+c.method+'
'; h+='
'; if(c.note)h+='
备注: '+esc(c.note)+'
'; + if(c.verifyError)h+='
Verify 校验失败'+esc(c.verifyError)+'
'; const curlId='cc'+i;const respId='cr'+i; h+='
cURL
'; h+='
'+esc(caseCurl(e,c))+'
'; - h+='
响应'+(c.passed?'通过':'失败')+'
'; - if(c.response)h+='
'+fj(c.response)+'
'; - else h+='无响应'; + const hasDetail=c.expect&&c.expect.result!==undefined;const expId='ce'+i; + h+='
响应'+(c.passed?'通过':'失败')+'
'; + if(hasDetail){const vis=getExpVis();h+='
'; + h+='
实际响应
';if(c.response)h+='
'+fj(c.response)+'
';else h+='无响应';h+='
'; + h+='
'; + h+='
预期响应
'+fj(c.expect)+'
'; + h+='
';} + else{if(c.response)h+='
'+fj(c.response)+'
';else h+='无响应';} h+='
'; } return h; @@ -330,11 +350,17 @@ function updateCasePanel(e,i){ const c=e.cases[i]; let h='
'; if(c.note)h+='
备注: '+esc(c.note)+'
'; + if(c.verifyError)h+='
Verify 校验失败'+esc(c.verifyError)+'
'; h+='
cURL'+(c.passed?'通过':'失败')+'
'; h+='
'+esc(caseCurl(e,c))+'
'; - h+='
响应
'; - if(c.response)h+='
'+fj(c.response)+'
'; - else h+='无响应'; + const hasDetail=c.expect&&c.expect.result!==undefined; + h+='
响应
'; + if(hasDetail){const vis=getExpVis();h+='
'; + h+='
实际响应
';if(c.response)h+='
'+fj(c.response)+'
';else h+='无响应';h+='
'; + h+='
'; + h+='
预期响应
'+fj(c.expect)+'
'; + h+='
';} + else{if(c.response)h+='
'+fj(c.response)+'
';else h+='无响应';} h+='
'; panel.innerHTML=h; } @@ -460,6 +486,8 @@ async function send(){ function copyEl(id){navigator.clipboard.writeText(document.getElementById(id).textContent).then(()=>{const b=event.target;b.textContent='已复制';setTimeout(()=>{b.textContent='复制'},1200);});} function cpNext(btn){let el=btn.closest('h5').nextElementSibling;let t='';while(el&&el.tagName!=='H5'){if(el.tagName==='PRE')t+=(t?'\n':'')+el.textContent;el=el.nextElementSibling;}navigator.clipboard.writeText(t).then(()=>{btn.textContent='已复制';setTimeout(()=>{btn.textContent='复制'},1200);});} +function getExpVis(){const v=localStorage.getItem('expect_vis');return v===null||v==='1';} +function toggleExpect(){const vis=getExpVis();const nv=!vis;localStorage.setItem('expect_vis',nv?'1':'0');document.querySelectorAll('.resp-wrap').forEach(w=>{w.classList.toggle('exp-on',nv);});document.querySelectorAll('.exp-btn').forEach(b=>{b.textContent=nv?'收起 预期响应':'展开 预期响应';});} function fj(o){return esc(JSON.stringify(o,null,2));} function fjp(o,params){ if(!params||!params.length||typeof o!=='object'||o===null||Array.isArray(o))return fj(o); diff --git a/testing_api.go b/testing_api.go index 9ef69d9..c473c47 100644 --- a/testing_api.go +++ b/testing_api.go @@ -90,24 +90,25 @@ func (a *Api) File(field, name string, content []byte) *ApiCase { return c } -// Get 直接发送 GET 请求(无数据场景) -func (a *Api) Get(desc string, status int, msg ...string) *ApiResponse { - return a.newCase().Get(desc, status, msg...) +// 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, msg ...string) *ApiResponse { - return a.newCase().Post(desc, status, msg...) +// 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, msg ...string) *ApiResponse { - return a.newCase().Put(desc, status, msg...) +// 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, msg ...string) *ApiResponse { - return a.newCase().Delete(desc, status, msg...) +// Delete 直接发送 DELETE 请求 +func (a *Api) Delete(desc string, status int, expect ...interface{}) *ApiResponse { + return a.newCase().Delete(desc, status, expect...) } // Note 为下一条用例设置备注,返回可继续链式的 ApiCase @@ -142,6 +143,7 @@ type ApiCase struct { fileName string fileContent []byte note string + verifyFn func(a *Api) error } // JSON 叠加 JSON body @@ -194,27 +196,53 @@ func (c *ApiCase) Note(note string) *ApiCase { 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, msg ...string) *ApiResponse { - return c.execute("GET", desc, status, msg...) +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, msg ...string) *ApiResponse { - return c.execute("POST", desc, status, msg...) +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, msg ...string) *ApiResponse { - return c.execute("PUT", desc, status, msg...) +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, msg ...string) *ApiResponse { - return c.execute("DELETE", desc, status, msg...) +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, expectMsg ...string) *ApiResponse { +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 @@ -229,7 +257,11 @@ func (c *ApiCase) execute(method, desc string, expectStatus int, expectMsg ...st body := Map{} if len(w.Body.Bytes()) > 0 { - _ = json.Unmarshal(w.Body.Bytes(), &body) + if obj, err := JsonToObj(w.Body.String()); err == nil { + if m, ok := obj.(map[string]interface{}); ok { + body = Map(m) + } + } } resp = &ApiResponse{ @@ -246,22 +278,38 @@ func (c *ApiCase) execute(method, desc string, expectStatus int, expectMsg ...st passed = false } - if passed && len(expectMsg) > 0 && expectMsg[0] != "" { + if passed && expectMsg != "" { actualMsg := resp.GetMsg() - if actualMsg != expectMsg[0] { - t.Errorf("消息不匹配: 期望 %q, 实际 %q\n响应: %s", expectMsg[0], actualMsg, w.Body.String()) + 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 + } + } + + 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, + 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 @@ -279,6 +327,21 @@ func (c *ApiCase) execute(method, desc string, expectStatus int, expectMsg ...st 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) }) @@ -390,3 +453,115 @@ func (r *ApiResponse) Fail(msg string) { 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) + } +} diff --git a/testing_helper.go b/testing_helper.go index b8827c7..1dec2dc 100644 --- a/testing_helper.go +++ b/testing_helper.go @@ -59,6 +59,8 @@ type TestRecord struct { FileField string ResponseBody map[string]interface{} Note string + ExpectResult interface{} + VerifyError string } // TestCollector 线程安全的测试记录收集器 diff --git a/testing_swagger.go b/testing_swagger.go index d4319b8..8e797c5 100644 --- a/testing_swagger.go +++ b/testing_swagger.go @@ -12,16 +12,18 @@ import ( ) type swaggerTestCase struct { - Name string `json:"name"` - Note string `json:"note,omitempty"` - Method string `json:"method"` - Passed bool `json:"passed"` - Query map[string]interface{} `json:"query,omitempty"` - Json interface{} `json:"json,omitempty"` - Form map[string]interface{} `json:"form,omitempty"` - HasFile bool `json:"hasFile,omitempty"` - FileField string `json:"fileField,omitempty"` - Response map[string]interface{} `json:"response,omitempty"` + Name string `json:"name"` + Note string `json:"note,omitempty"` + Method string `json:"method"` + Passed bool `json:"passed"` + Query map[string]interface{} `json:"query,omitempty"` + Json interface{} `json:"json,omitempty"` + Form map[string]interface{} `json:"form,omitempty"` + HasFile bool `json:"hasFile,omitempty"` + FileField string `json:"fileField,omitempty"` + Response map[string]interface{} `json:"response,omitempty"` + Expect interface{} `json:"expect,omitempty"` + VerifyError string `json:"verifyError,omitempty"` } type paramSpec struct { @@ -228,10 +230,10 @@ func (that *TestApp) GenerateSwagger(title, version, outputDir string) error { var cases []swaggerTestCase for _, r := range recs { - tc := swaggerTestCase{ - Name: r.CaseName, Note: r.Note, Method: r.Method, Passed: r.Passed, - Response: r.ResponseBody, - } + tc := swaggerTestCase{ + Name: r.CaseName, Note: r.Note, Method: r.Method, Passed: r.Passed, + Response: r.ResponseBody, Expect: r.ExpectResult, VerifyError: r.VerifyError, + } if r.Query != nil { tc.Query = r.Query } @@ -401,6 +403,20 @@ input,select,textarea,button{font:inherit}pre{margin:0} .csc h5{color:var(--accent);margin:0 0 4px;font-size:11px;letter-spacing:.5px;display:flex;align-items:center} .csc h5 .cp-sm{margin-left:auto}.csc h5:not(:first-child){margin-top:8px} pre.j{background:#0a0a1a;padding:6px 8px;border-radius:var(--r);font-size:12px;line-height:1.4;color:#aaa;white-space:pre-wrap;word-break:break-all;max-height:260px;overflow:auto;font-family:Consolas,Monaco,monospace} +.resp-wrap{display:flex;gap:0}.resp-main{flex:1;min-width:0} +.resp-main .rsp-hd{display:flex;align-items:center;margin-bottom:3px;font-size:10px;color:var(--fg2);letter-spacing:.5px} +.resp-main .rsp-hd .cp-sm{margin-left:auto} +.exp-col-btn{flex-shrink:0;width:22px;display:flex;align-items:stretch;justify-content:center;margin:0 2px} +.exp-col-btn button{writing-mode:vertical-rl;background:var(--accent);border:none;color:#fff;font-size:10px;padding:6px 3px;border-radius:var(--r);cursor:pointer;user-select:none;line-height:1.2;letter-spacing:1px;opacity:.85} +.exp-col-btn button:hover{opacity:1} +.exp-panel{flex:1;min-width:0;display:none;padding-left:6px} +.resp-wrap.exp-on .exp-panel{display:block} +.exp-panel .ep-hd{display:flex;align-items:center;margin-bottom:3px;font-size:10px;color:var(--fg2);letter-spacing:.5px} +.exp-panel .ep-hd .cp-sm{margin-left:auto} +.exp-panel pre.j{border-left:2px solid var(--accent)} +pre.j.resp-j{max-height:calc(100vh - 280px);min-height:120px} +.verify-err{margin-top:8px;padding:8px 10px;background:rgba(255,77,79,0.08);border:1px solid rgba(255,77,79,0.3);border-radius:var(--r);font-size:12px;color:#ff6b6b;line-height:1.5;white-space:pre-wrap;word-break:break-all} +.verify-label{display:inline-block;background:var(--red);color:#fff;font-size:10px;padding:1px 6px;border-radius:var(--r);margin-right:8px;vertical-align:middle} .sec{margin-top:10px;padding-top:10px;border-top:1px solid var(--border)}.sec-title{color:var(--fg2);font-size:11px;letter-spacing:.5px;margin-bottom:4px} .row-hd{display:flex;align-items:center;margin-bottom:4px;padding:4px 8px;background:var(--bg2);border-radius:var(--r)}.row-hd .sec-title{flex:1;margin:0} .kv{display:flex;gap:4px;margin-bottom:3px;align-items:center}.kv input{flex:1;padding:4px 6px;border:1px solid var(--border);border-radius:var(--r);background:var(--bg2);color:var(--fg);font-size:12px;outline:none} @@ -535,12 +551,18 @@ function cases(e){ h+='
'+esc(c.name)+''+c.method+'
'; h+='
'; if(c.note)h+='
备注: '+esc(c.note)+'
'; + if(c.verifyError)h+='
Verify 校验失败'+esc(c.verifyError)+'
'; const curlId='cc'+i;const respId='cr'+i; h+='
cURL
'; h+='
'+esc(caseCurl(e,c))+'
'; - h+='
响应'+(c.passed?'通过':'失败')+'
'; - if(c.response)h+='
'+fj(c.response)+'
'; - else h+='无响应'; + const hasDetail=c.expect&&c.expect.result!==undefined;const expId='ce'+i; + h+='
响应'+(c.passed?'通过':'失败')+'
'; + if(hasDetail){const vis=getExpVis();h+='
'; + h+='
实际响应
';if(c.response)h+='
'+fj(c.response)+'
';else h+='无响应';h+='
'; + h+='
'; + h+='
预期响应
'+fj(c.expect)+'
'; + h+='
';} + else{if(c.response)h+='
'+fj(c.response)+'
';else h+='无响应';} h+='
'; } return h; @@ -678,11 +700,17 @@ function updateCasePanel(e,i){ const c=e.cases[i]; let h='
'; if(c.note)h+='
备注: '+esc(c.note)+'
'; + if(c.verifyError)h+='
Verify 校验失败'+esc(c.verifyError)+'
'; h+='
cURL'+(c.passed?'通过':'失败')+'
'; h+='
'+esc(caseCurl(e,c))+'
'; - h+='
响应
'; - if(c.response)h+='
'+fj(c.response)+'
'; - else h+='无响应'; + const hasDetail=c.expect&&c.expect.result!==undefined; + h+='
响应
'; + if(hasDetail){const vis=getExpVis();h+='
'; + h+='
实际响应
';if(c.response)h+='
'+fj(c.response)+'
';else h+='无响应';h+='
'; + h+='
'; + h+='
预期响应
'+fj(c.expect)+'
'; + h+='
';} + else{if(c.response)h+='
'+fj(c.response)+'
';else h+='无响应';} h+='
'; panel.innerHTML=h; } @@ -808,6 +836,8 @@ async function send(){ function copyEl(id){navigator.clipboard.writeText(document.getElementById(id).textContent).then(()=>{const b=event.target;b.textContent='已复制';setTimeout(()=>{b.textContent='复制'},1200);});} function cpNext(btn){let el=btn.closest('h5').nextElementSibling;let t='';while(el&&el.tagName!=='H5'){if(el.tagName==='PRE')t+=(t?'\n':'')+el.textContent;el=el.nextElementSibling;}navigator.clipboard.writeText(t).then(()=>{btn.textContent='已复制';setTimeout(()=>{btn.textContent='复制'},1200);});} +function getExpVis(){const v=localStorage.getItem('expect_vis');return v===null||v==='1';} +function toggleExpect(){const vis=getExpVis();const nv=!vis;localStorage.setItem('expect_vis',nv?'1':'0');document.querySelectorAll('.resp-wrap').forEach(w=>{w.classList.toggle('exp-on',nv);});document.querySelectorAll('.exp-btn').forEach(b=>{b.textContent=nv?'收起 预期响应':'展开 预期响应';});} function fj(o){return esc(JSON.stringify(o,null,2));} function fjp(o,params){ if(!params||!params.length||typeof o!=='object'||o===null||Array.isArray(o))return fj(o);