feat(api): 增强 API 测试框架的断言功能
- 修改 ApiCase 和 ApiResponse 结构,新增 Verify 方法以支持自定义校验函数 - 更新 Get、Post、Put、Delete 方法,允许使用 expect 参数进行更灵活的响应断言 - 添加 ExpectResult 方法,支持对响应结果的结构和类型进行后置校验 - 更新文档,详细说明新功能的使用方法和示例,提升测试框架的可用性 - 增强 Swagger 生成逻辑,支持输出新的 expect 和 verifyError 字段
This commit is contained in:
+104
-2
@@ -1,4 +1,4 @@
|
|||||||
# HoTime API 测试框架使用说明
|
# HoTime API 测试框架使用说明
|
||||||
|
|
||||||
不启动 HTTP 服务、自动事务回滚、链式 API、覆盖率报告、API 调试控制台生成。
|
不启动 HTTP 服务、自动事务回滚、链式 API、覆盖率报告、API 调试控制台生成。
|
||||||
|
|
||||||
@@ -229,6 +229,7 @@ func (app *TestApp) DB() *HoTimeDB
|
|||||||
| `c.File(field, name, content)` | 设置上传文件 | `*ApiCase` |
|
| `c.File(field, name, content)` | 设置上传文件 | `*ApiCase` |
|
||||||
| `c.Note(note)` | 为本条用例添加可选备注,备注显示在调试控制台用例详情中 | `*ApiCase` |
|
| `c.Note(note)` | 为本条用例添加可选备注,备注显示在调试控制台用例详情中 | `*ApiCase` |
|
||||||
| `c.WithSession(s)` | 覆盖本次请求的 session | `*ApiCase` |
|
| `c.WithSession(s)` | 覆盖本次请求的 session | `*ApiCase` |
|
||||||
|
| `c.Verify(fn)` | 设置请求后的自定义校验函数(详见 [Verify 数据库校验](#verify-数据库校验)) | `*ApiCase` |
|
||||||
| `c.Get(desc, status, msg...)` | 终端:发送 GET 请求并断言 | `*ApiResponse` |
|
| `c.Get(desc, status, msg...)` | 终端:发送 GET 请求并断言 | `*ApiResponse` |
|
||||||
| `c.Post(desc, status, msg...)` | 终端:发送 POST 请求并断言 | `*ApiResponse` |
|
| `c.Post(desc, status, msg...)` | 终端:发送 POST 请求并断言 | `*ApiResponse` |
|
||||||
| `c.Put(desc, status, msg...)` | 终端:发送 PUT 请求并断言 | `*ApiResponse` |
|
| `c.Put(desc, status, msg...)` | 终端:发送 PUT 请求并断言 | `*ApiResponse` |
|
||||||
@@ -240,7 +241,48 @@ func (app *TestApp) DB() *HoTimeDB
|
|||||||
|------|------|------|
|
|------|------|------|
|
||||||
| `desc` | string | 用例描述,作为 `t.Run` 的子测试名称 |
|
| `desc` | string | 用例描述,作为 `t.Run` 的子测试名称 |
|
||||||
| `status` | int | 期望的 JSON 响应体中 `status` 字段值(0=成功) |
|
| `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 — 响应对象
|
### ApiResponse — 响应对象
|
||||||
|
|
||||||
@@ -255,6 +297,10 @@ resp.GetMsg() // string:业务 msg
|
|||||||
resp.GetResult() // interface{}:result 字段
|
resp.GetResult() // interface{}:result 字段
|
||||||
resp.GetBody() // Map:完整响应体
|
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")
|
token := resp.GetBody().GetMap("result").GetString("token")
|
||||||
if token == "" {
|
if token == "" {
|
||||||
@@ -314,6 +360,62 @@ resp := a.JSON(Map{"name": "138", "password": "123"}).Post("正常登录", 0)
|
|||||||
if resp.GetBody().GetMap("result").GetString("token") == "" {
|
if resp.GetBody().GetMap("result").GetString("token") == "" {
|
||||||
resp.Fail("响应中缺少 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)})
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -8,10 +8,11 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var ProjectTest = ProjTest{
|
var ProjectTest = ProjTest{
|
||||||
"test": TestTest,
|
"test": TestTest,
|
||||||
"mysql": MysqlTest,
|
"mysql": MysqlTest,
|
||||||
"dmdb": DmdbTest,
|
"dmdb": DmdbTest,
|
||||||
"cache": CacheTest,
|
"cache": CacheTest,
|
||||||
|
"expect_demo": ExpectDemoTest,
|
||||||
}
|
}
|
||||||
|
|
||||||
var testApp *TestApp
|
var testApp *TestApp
|
||||||
|
|||||||
@@ -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"})
|
||||||
|
}},
|
||||||
|
}
|
||||||
+5
-4
@@ -5,8 +5,9 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var Project = Proj{
|
var Project = Proj{
|
||||||
"test": TestCtr,
|
"test": TestCtr,
|
||||||
"mysql": MysqlCtr,
|
"mysql": MysqlCtr,
|
||||||
"dmdb": DmdbCtr,
|
"dmdb": DmdbCtr,
|
||||||
"cache": CacheCtr,
|
"cache": CacheCtr,
|
||||||
|
"expect_demo": ExpectDemoCtr,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,6 +43,67 @@ func nowFunc(that *Context) string {
|
|||||||
return "NOW()"
|
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 测试控制器(数据库无关)
|
// TestCtr 通用 ORM 测试控制器(数据库无关)
|
||||||
var TestCtr = Ctr{
|
var TestCtr = Ctr{
|
||||||
"test": func(that *Context) {
|
"test": func(that *Context) {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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{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}
|
.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}
|
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}
|
.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}
|
.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}
|
.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+='<div class="cs"><div class="csh" onclick="accordion(this)"><span class="ar'+(i===0?' o':'')+'">▶</span><span class="dot '+(c.passed?'ok':'er')+'"></span>'+esc(c.name)+'<span class="ml">'+c.method+'</span></div>';
|
h+='<div class="cs"><div class="csh" onclick="accordion(this)"><span class="ar'+(i===0?' o':'')+'">▶</span><span class="dot '+(c.passed?'ok':'er')+'"></span>'+esc(c.name)+'<span class="ml">'+c.method+'</span></div>';
|
||||||
h+='<div class="csb'+(i===0?' o':'')+'"><div style="padding:8px 10px">';
|
h+='<div class="csb'+(i===0?' o':'')+'"><div style="padding:8px 10px">';
|
||||||
if(c.note)h+='<div style="color:#aaa;font-size:12px;margin-bottom:8px;line-height:1.5">备注: '+esc(c.note)+'</div>';
|
if(c.note)h+='<div style="color:#aaa;font-size:12px;margin-bottom:8px;line-height:1.5">备注: '+esc(c.note)+'</div>';
|
||||||
|
if(c.verifyError)h+='<div class="verify-err"><span class="verify-label">Verify 校验失败</span>'+esc(c.verifyError)+'</div>';
|
||||||
const curlId='cc'+i;const respId='cr'+i;
|
const curlId='cc'+i;const respId='cr'+i;
|
||||||
h+='<div class="row-hd" style="margin-bottom:4px"><span class="sec-title">cURL</span><button class="cp-sm" onclick="event.stopPropagation();copyEl(\''+curlId+'\')">复制</button></div>';
|
h+='<div class="row-hd" style="margin-bottom:4px"><span class="sec-title">cURL</span><button class="cp-sm" onclick="event.stopPropagation();copyEl(\''+curlId+'\')">复制</button></div>';
|
||||||
h+='<div class="curl" id="'+curlId+'">'+esc(caseCurl(e,c))+'</div>';
|
h+='<div class="curl" id="'+curlId+'">'+esc(caseCurl(e,c))+'</div>';
|
||||||
h+='<div class="row-hd" style="margin:8px 0 4px"><span class="sec-title">响应</span><span class="'+(c.passed?'tg tg-ok':'tg tg-err')+'" style="margin-left:6px">'+(c.passed?'通过':'失败')+'</span><button class="cp-sm" onclick="event.stopPropagation();copyEl(\''+respId+'\')" style="margin-left:auto">复制</button></div>';
|
const hasDetail=c.expect&&c.expect.result!==undefined;const expId='ce'+i;
|
||||||
if(c.response)h+='<pre class="j" id="'+respId+'">'+fj(c.response)+'</pre>';
|
h+='<div class="row-hd" style="margin:8px 0 4px"><span class="sec-title">响应</span><span class="'+(c.passed?'tg tg-ok':'tg tg-err')+'" style="margin-left:6px">'+(c.passed?'通过':'失败')+'</span></div>';
|
||||||
else h+='<span style="color:#555" id="'+respId+'">无响应</span>';
|
if(hasDetail){const vis=getExpVis();h+='<div class="resp-wrap'+(vis?' exp-on':'')+'">';
|
||||||
|
h+='<div class="resp-main"><div class="rsp-hd"><span>实际响应</span><button class="cp-sm" onclick="event.stopPropagation();copyEl(\''+respId+'\')">复制</button></div>';if(c.response)h+='<pre class="j resp-j" id="'+respId+'">'+fj(c.response)+'</pre>';else h+='<span style="color:#555" id="'+respId+'">无响应</span>';h+='</div>';
|
||||||
|
h+='<div class="exp-col-btn"><button class="exp-btn" onclick="event.stopPropagation();toggleExpect()">'+(vis?'收起 预期响应':'展开 预期响应')+'</button></div>';
|
||||||
|
h+='<div class="exp-panel"><div class="ep-hd"><span>预期响应</span><button class="cp-sm" onclick="event.stopPropagation();copyEl(\''+expId+'\')">复制</button></div><pre class="j resp-j" id="'+expId+'">'+fj(c.expect)+'</pre></div>';
|
||||||
|
h+='</div>';}
|
||||||
|
else{if(c.response)h+='<pre class="j" id="'+respId+'">'+fj(c.response)+'</pre>';else h+='<span style="color:#555" id="'+respId+'">无响应</span>';}
|
||||||
h+='</div></div></div>';
|
h+='</div></div></div>';
|
||||||
}
|
}
|
||||||
return h;
|
return h;
|
||||||
@@ -330,11 +350,17 @@ function updateCasePanel(e,i){
|
|||||||
const c=e.cases[i];
|
const c=e.cases[i];
|
||||||
let h='<div style="padding:8px 10px">';
|
let h='<div style="padding:8px 10px">';
|
||||||
if(c.note)h+='<div style="color:#aaa;font-size:12px;margin-bottom:8px;line-height:1.5">备注: '+esc(c.note)+'</div>';
|
if(c.note)h+='<div style="color:#aaa;font-size:12px;margin-bottom:8px;line-height:1.5">备注: '+esc(c.note)+'</div>';
|
||||||
|
if(c.verifyError)h+='<div class="verify-err"><span class="verify-label">Verify 校验失败</span>'+esc(c.verifyError)+'</div>';
|
||||||
h+='<div class="row-hd" style="margin-bottom:4px"><span class="sec-title">cURL</span><span class="'+(c.passed?'tg tg-ok':'tg tg-err')+'" style="margin-left:6px">'+(c.passed?'通过':'失败')+'</span><button class="cp-sm" onclick="copyEl(\'c-curl\')" style="margin-left:auto">复制</button></div>';
|
h+='<div class="row-hd" style="margin-bottom:4px"><span class="sec-title">cURL</span><span class="'+(c.passed?'tg tg-ok':'tg tg-err')+'" style="margin-left:6px">'+(c.passed?'通过':'失败')+'</span><button class="cp-sm" onclick="copyEl(\'c-curl\')" style="margin-left:auto">复制</button></div>';
|
||||||
h+='<div class="curl" id="c-curl">'+esc(caseCurl(e,c))+'</div>';
|
h+='<div class="curl" id="c-curl">'+esc(caseCurl(e,c))+'</div>';
|
||||||
h+='<div class="row-hd" style="margin:8px 0 4px"><span class="sec-title">响应</span><button class="cp-sm" onclick="copyEl(\'c-resp\')" style="margin-left:auto">复制</button></div>';
|
const hasDetail=c.expect&&c.expect.result!==undefined;
|
||||||
if(c.response)h+='<pre class="j" id="c-resp">'+fj(c.response)+'</pre>';
|
h+='<div class="row-hd" style="margin:8px 0 4px"><span class="sec-title">响应</span></div>';
|
||||||
else h+='<span style="color:#555" id="c-resp">无响应</span>';
|
if(hasDetail){const vis=getExpVis();h+='<div class="resp-wrap'+(vis?' exp-on':'')+'">';
|
||||||
|
h+='<div class="resp-main"><div class="rsp-hd"><span>实际响应</span><button class="cp-sm" onclick="copyEl(\'c-resp\')">复制</button></div>';if(c.response)h+='<pre class="j resp-j" id="c-resp">'+fj(c.response)+'</pre>';else h+='<span style="color:#555" id="c-resp">无响应</span>';h+='</div>';
|
||||||
|
h+='<div class="exp-col-btn"><button class="exp-btn" onclick="toggleExpect()">'+(vis?'收起 预期响应':'展开 预期响应')+'</button></div>';
|
||||||
|
h+='<div class="exp-panel"><div class="ep-hd"><span>预期响应</span><button class="cp-sm" onclick="copyEl(\'c-exp\')">复制</button></div><pre class="j resp-j" id="c-exp">'+fj(c.expect)+'</pre></div>';
|
||||||
|
h+='</div>';}
|
||||||
|
else{if(c.response)h+='<pre class="j" id="c-resp">'+fj(c.response)+'</pre>';else h+='<span style="color:#555" id="c-resp">无响应</span>';}
|
||||||
h+='</div>';
|
h+='</div>';
|
||||||
panel.innerHTML=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 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 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 fj(o){return esc(JSON.stringify(o,null,2));}
|
||||||
function fjp(o,params){
|
function fjp(o,params){
|
||||||
if(!params||!params.length||typeof o!=='object'||o===null||Array.isArray(o))return fj(o);
|
if(!params||!params.length||typeof o!=='object'||o===null||Array.isArray(o))return fj(o);
|
||||||
|
|||||||
+207
-32
@@ -90,24 +90,25 @@ func (a *Api) File(field, name string, content []byte) *ApiCase {
|
|||||||
return c
|
return c
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get 直接发送 GET 请求(无数据场景)
|
// Get 直接发送 GET 请求
|
||||||
func (a *Api) Get(desc string, status int, msg ...string) *ApiResponse {
|
// expect 可选参数: string 为期望 msg,Map 为期望 result 结构(按样本值类型校验)
|
||||||
return a.newCase().Get(desc, status, msg...)
|
func (a *Api) Get(desc string, status int, expect ...interface{}) *ApiResponse {
|
||||||
|
return a.newCase().Get(desc, status, expect...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Post 直接发送 POST 请求(无数据场景)
|
// Post 直接发送 POST 请求
|
||||||
func (a *Api) Post(desc string, status int, msg ...string) *ApiResponse {
|
func (a *Api) Post(desc string, status int, expect ...interface{}) *ApiResponse {
|
||||||
return a.newCase().Post(desc, status, msg...)
|
return a.newCase().Post(desc, status, expect...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Put 直接发送 PUT 请求(无数据场景)
|
// Put 直接发送 PUT 请求
|
||||||
func (a *Api) Put(desc string, status int, msg ...string) *ApiResponse {
|
func (a *Api) Put(desc string, status int, expect ...interface{}) *ApiResponse {
|
||||||
return a.newCase().Put(desc, status, msg...)
|
return a.newCase().Put(desc, status, expect...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete 直接发送 DELETE 请求(无数据场景)
|
// Delete 直接发送 DELETE 请求
|
||||||
func (a *Api) Delete(desc string, status int, msg ...string) *ApiResponse {
|
func (a *Api) Delete(desc string, status int, expect ...interface{}) *ApiResponse {
|
||||||
return a.newCase().Delete(desc, status, msg...)
|
return a.newCase().Delete(desc, status, expect...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Note 为下一条用例设置备注,返回可继续链式的 ApiCase
|
// Note 为下一条用例设置备注,返回可继续链式的 ApiCase
|
||||||
@@ -142,6 +143,7 @@ type ApiCase struct {
|
|||||||
fileName string
|
fileName string
|
||||||
fileContent []byte
|
fileContent []byte
|
||||||
note string
|
note string
|
||||||
|
verifyFn func(a *Api) error
|
||||||
}
|
}
|
||||||
|
|
||||||
// JSON 叠加 JSON body
|
// JSON 叠加 JSON body
|
||||||
@@ -194,27 +196,53 @@ func (c *ApiCase) Note(note string) *ApiCase {
|
|||||||
return c
|
return c
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Verify 设置请求后的自定义校验函数(如查库验证数据状态)
|
||||||
|
// 函数返回 nil 表示校验通过,返回 error 则用例失败并记录错误原因
|
||||||
|
func (c *ApiCase) Verify(fn func(a *Api) error) *ApiCase {
|
||||||
|
c.verifyFn = fn
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
// Get 终端操作:GET 请求 + 断言
|
// Get 终端操作:GET 请求 + 断言
|
||||||
func (c *ApiCase) Get(desc string, status int, msg ...string) *ApiResponse {
|
func (c *ApiCase) Get(desc string, status int, expect ...interface{}) *ApiResponse {
|
||||||
return c.execute("GET", desc, status, msg...)
|
return c.execute("GET", desc, status, expect...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Post 终端操作:POST 请求 + 断言
|
// Post 终端操作:POST 请求 + 断言
|
||||||
func (c *ApiCase) Post(desc string, status int, msg ...string) *ApiResponse {
|
func (c *ApiCase) Post(desc string, status int, expect ...interface{}) *ApiResponse {
|
||||||
return c.execute("POST", desc, status, msg...)
|
return c.execute("POST", desc, status, expect...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Put 终端操作:PUT 请求 + 断言
|
// Put 终端操作:PUT 请求 + 断言
|
||||||
func (c *ApiCase) Put(desc string, status int, msg ...string) *ApiResponse {
|
func (c *ApiCase) Put(desc string, status int, expect ...interface{}) *ApiResponse {
|
||||||
return c.execute("PUT", desc, status, msg...)
|
return c.execute("PUT", desc, status, expect...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete 终端操作:DELETE 请求 + 断言
|
// Delete 终端操作:DELETE 请求 + 断言
|
||||||
func (c *ApiCase) Delete(desc string, status int, msg ...string) *ApiResponse {
|
func (c *ApiCase) Delete(desc string, status int, expect ...interface{}) *ApiResponse {
|
||||||
return c.execute("DELETE", desc, status, msg...)
|
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
|
t := c.api.t
|
||||||
var resp *ApiResponse
|
var resp *ApiResponse
|
||||||
|
|
||||||
@@ -229,7 +257,11 @@ func (c *ApiCase) execute(method, desc string, expectStatus int, expectMsg ...st
|
|||||||
|
|
||||||
body := Map{}
|
body := Map{}
|
||||||
if len(w.Body.Bytes()) > 0 {
|
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{
|
resp = &ApiResponse{
|
||||||
@@ -246,22 +278,38 @@ func (c *ApiCase) execute(method, desc string, expectStatus int, expectMsg ...st
|
|||||||
passed = false
|
passed = false
|
||||||
}
|
}
|
||||||
|
|
||||||
if passed && len(expectMsg) > 0 && expectMsg[0] != "" {
|
if passed && expectMsg != "" {
|
||||||
actualMsg := resp.GetMsg()
|
actualMsg := resp.GetMsg()
|
||||||
if actualMsg != expectMsg[0] {
|
if actualMsg != expectMsg {
|
||||||
t.Errorf("消息不匹配: 期望 %q, 实际 %q\n响应: %s", expectMsg[0], actualMsg, w.Body.String())
|
t.Errorf("消息不匹配: 期望 %q, 实际 %q\n响应: %s", expectMsg, actualMsg, w.Body.String())
|
||||||
passed = false
|
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{
|
record := TestRecord{
|
||||||
Path: c.api.path,
|
Path: c.api.path,
|
||||||
Desc: desc,
|
Desc: desc,
|
||||||
CaseName: desc,
|
CaseName: desc,
|
||||||
Passed: passed,
|
Passed: passed,
|
||||||
Duration: duration,
|
Duration: duration,
|
||||||
Method: method,
|
Method: method,
|
||||||
Note: c.note,
|
Note: c.note,
|
||||||
|
VerifyError: verifyError,
|
||||||
}
|
}
|
||||||
if c.query != nil {
|
if c.query != nil {
|
||||||
record.Query = c.query
|
record.Query = c.query
|
||||||
@@ -279,6 +327,21 @@ func (c *ApiCase) execute(method, desc string, expectStatus int, expectMsg ...st
|
|||||||
if len(body) > 0 {
|
if len(body) > 0 {
|
||||||
record.ResponseBody = body
|
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)
|
c.api.app.collector.Add(record)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -390,3 +453,115 @@ func (r *ApiResponse) Fail(msg string) {
|
|||||||
fmt.Printf("自定义断言失败: %s\n", msg)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -59,6 +59,8 @@ type TestRecord struct {
|
|||||||
FileField string
|
FileField string
|
||||||
ResponseBody map[string]interface{}
|
ResponseBody map[string]interface{}
|
||||||
Note string
|
Note string
|
||||||
|
ExpectResult interface{}
|
||||||
|
VerifyError string
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestCollector 线程安全的测试记录收集器
|
// TestCollector 线程安全的测试记录收集器
|
||||||
|
|||||||
+50
-20
@@ -12,16 +12,18 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type swaggerTestCase struct {
|
type swaggerTestCase struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Note string `json:"note,omitempty"`
|
Note string `json:"note,omitempty"`
|
||||||
Method string `json:"method"`
|
Method string `json:"method"`
|
||||||
Passed bool `json:"passed"`
|
Passed bool `json:"passed"`
|
||||||
Query map[string]interface{} `json:"query,omitempty"`
|
Query map[string]interface{} `json:"query,omitempty"`
|
||||||
Json interface{} `json:"json,omitempty"`
|
Json interface{} `json:"json,omitempty"`
|
||||||
Form map[string]interface{} `json:"form,omitempty"`
|
Form map[string]interface{} `json:"form,omitempty"`
|
||||||
HasFile bool `json:"hasFile,omitempty"`
|
HasFile bool `json:"hasFile,omitempty"`
|
||||||
FileField string `json:"fileField,omitempty"`
|
FileField string `json:"fileField,omitempty"`
|
||||||
Response map[string]interface{} `json:"response,omitempty"`
|
Response map[string]interface{} `json:"response,omitempty"`
|
||||||
|
Expect interface{} `json:"expect,omitempty"`
|
||||||
|
VerifyError string `json:"verifyError,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type paramSpec struct {
|
type paramSpec struct {
|
||||||
@@ -228,10 +230,10 @@ func (that *TestApp) GenerateSwagger(title, version, outputDir string) error {
|
|||||||
|
|
||||||
var cases []swaggerTestCase
|
var cases []swaggerTestCase
|
||||||
for _, r := range recs {
|
for _, r := range recs {
|
||||||
tc := swaggerTestCase{
|
tc := swaggerTestCase{
|
||||||
Name: r.CaseName, Note: r.Note, Method: r.Method, Passed: r.Passed,
|
Name: r.CaseName, Note: r.Note, Method: r.Method, Passed: r.Passed,
|
||||||
Response: r.ResponseBody,
|
Response: r.ResponseBody, Expect: r.ExpectResult, VerifyError: r.VerifyError,
|
||||||
}
|
}
|
||||||
if r.Query != nil {
|
if r.Query != nil {
|
||||||
tc.Query = r.Query
|
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{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}
|
.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}
|
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}
|
.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}
|
.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}
|
.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+='<div class="cs"><div class="csh" onclick="accordion(this)"><span class="ar'+(i===0?' o':'')+'">▶</span><span class="dot '+(c.passed?'ok':'er')+'"></span>'+esc(c.name)+'<span class="ml">'+c.method+'</span></div>';
|
h+='<div class="cs"><div class="csh" onclick="accordion(this)"><span class="ar'+(i===0?' o':'')+'">▶</span><span class="dot '+(c.passed?'ok':'er')+'"></span>'+esc(c.name)+'<span class="ml">'+c.method+'</span></div>';
|
||||||
h+='<div class="csb'+(i===0?' o':'')+'"><div style="padding:8px 10px">';
|
h+='<div class="csb'+(i===0?' o':'')+'"><div style="padding:8px 10px">';
|
||||||
if(c.note)h+='<div style="color:#aaa;font-size:12px;margin-bottom:8px;line-height:1.5">备注: '+esc(c.note)+'</div>';
|
if(c.note)h+='<div style="color:#aaa;font-size:12px;margin-bottom:8px;line-height:1.5">备注: '+esc(c.note)+'</div>';
|
||||||
|
if(c.verifyError)h+='<div class="verify-err"><span class="verify-label">Verify 校验失败</span>'+esc(c.verifyError)+'</div>';
|
||||||
const curlId='cc'+i;const respId='cr'+i;
|
const curlId='cc'+i;const respId='cr'+i;
|
||||||
h+='<div class="row-hd" style="margin-bottom:4px"><span class="sec-title">cURL</span><button class="cp-sm" onclick="event.stopPropagation();copyEl(\''+curlId+'\')">复制</button></div>';
|
h+='<div class="row-hd" style="margin-bottom:4px"><span class="sec-title">cURL</span><button class="cp-sm" onclick="event.stopPropagation();copyEl(\''+curlId+'\')">复制</button></div>';
|
||||||
h+='<div class="curl" id="'+curlId+'">'+esc(caseCurl(e,c))+'</div>';
|
h+='<div class="curl" id="'+curlId+'">'+esc(caseCurl(e,c))+'</div>';
|
||||||
h+='<div class="row-hd" style="margin:8px 0 4px"><span class="sec-title">响应</span><span class="'+(c.passed?'tg tg-ok':'tg tg-err')+'" style="margin-left:6px">'+(c.passed?'通过':'失败')+'</span><button class="cp-sm" onclick="event.stopPropagation();copyEl(\''+respId+'\')" style="margin-left:auto">复制</button></div>';
|
const hasDetail=c.expect&&c.expect.result!==undefined;const expId='ce'+i;
|
||||||
if(c.response)h+='<pre class="j" id="'+respId+'">'+fj(c.response)+'</pre>';
|
h+='<div class="row-hd" style="margin:8px 0 4px"><span class="sec-title">响应</span><span class="'+(c.passed?'tg tg-ok':'tg tg-err')+'" style="margin-left:6px">'+(c.passed?'通过':'失败')+'</span></div>';
|
||||||
else h+='<span style="color:#555" id="'+respId+'">无响应</span>';
|
if(hasDetail){const vis=getExpVis();h+='<div class="resp-wrap'+(vis?' exp-on':'')+'">';
|
||||||
|
h+='<div class="resp-main"><div class="rsp-hd"><span>实际响应</span><button class="cp-sm" onclick="event.stopPropagation();copyEl(\''+respId+'\')">复制</button></div>';if(c.response)h+='<pre class="j resp-j" id="'+respId+'">'+fj(c.response)+'</pre>';else h+='<span style="color:#555" id="'+respId+'">无响应</span>';h+='</div>';
|
||||||
|
h+='<div class="exp-col-btn"><button class="exp-btn" onclick="event.stopPropagation();toggleExpect()">'+(vis?'收起 预期响应':'展开 预期响应')+'</button></div>';
|
||||||
|
h+='<div class="exp-panel"><div class="ep-hd"><span>预期响应</span><button class="cp-sm" onclick="event.stopPropagation();copyEl(\''+expId+'\')">复制</button></div><pre class="j resp-j" id="'+expId+'">'+fj(c.expect)+'</pre></div>';
|
||||||
|
h+='</div>';}
|
||||||
|
else{if(c.response)h+='<pre class="j" id="'+respId+'">'+fj(c.response)+'</pre>';else h+='<span style="color:#555" id="'+respId+'">无响应</span>';}
|
||||||
h+='</div></div></div>';
|
h+='</div></div></div>';
|
||||||
}
|
}
|
||||||
return h;
|
return h;
|
||||||
@@ -678,11 +700,17 @@ function updateCasePanel(e,i){
|
|||||||
const c=e.cases[i];
|
const c=e.cases[i];
|
||||||
let h='<div style="padding:8px 10px">';
|
let h='<div style="padding:8px 10px">';
|
||||||
if(c.note)h+='<div style="color:#aaa;font-size:12px;margin-bottom:8px;line-height:1.5">备注: '+esc(c.note)+'</div>';
|
if(c.note)h+='<div style="color:#aaa;font-size:12px;margin-bottom:8px;line-height:1.5">备注: '+esc(c.note)+'</div>';
|
||||||
|
if(c.verifyError)h+='<div class="verify-err"><span class="verify-label">Verify 校验失败</span>'+esc(c.verifyError)+'</div>';
|
||||||
h+='<div class="row-hd" style="margin-bottom:4px"><span class="sec-title">cURL</span><span class="'+(c.passed?'tg tg-ok':'tg tg-err')+'" style="margin-left:6px">'+(c.passed?'通过':'失败')+'</span><button class="cp-sm" onclick="copyEl(\'c-curl\')" style="margin-left:auto">复制</button></div>';
|
h+='<div class="row-hd" style="margin-bottom:4px"><span class="sec-title">cURL</span><span class="'+(c.passed?'tg tg-ok':'tg tg-err')+'" style="margin-left:6px">'+(c.passed?'通过':'失败')+'</span><button class="cp-sm" onclick="copyEl(\'c-curl\')" style="margin-left:auto">复制</button></div>';
|
||||||
h+='<div class="curl" id="c-curl">'+esc(caseCurl(e,c))+'</div>';
|
h+='<div class="curl" id="c-curl">'+esc(caseCurl(e,c))+'</div>';
|
||||||
h+='<div class="row-hd" style="margin:8px 0 4px"><span class="sec-title">响应</span><button class="cp-sm" onclick="copyEl(\'c-resp\')" style="margin-left:auto">复制</button></div>';
|
const hasDetail=c.expect&&c.expect.result!==undefined;
|
||||||
if(c.response)h+='<pre class="j" id="c-resp">'+fj(c.response)+'</pre>';
|
h+='<div class="row-hd" style="margin:8px 0 4px"><span class="sec-title">响应</span></div>';
|
||||||
else h+='<span style="color:#555" id="c-resp">无响应</span>';
|
if(hasDetail){const vis=getExpVis();h+='<div class="resp-wrap'+(vis?' exp-on':'')+'">';
|
||||||
|
h+='<div class="resp-main"><div class="rsp-hd"><span>实际响应</span><button class="cp-sm" onclick="copyEl(\'c-resp\')">复制</button></div>';if(c.response)h+='<pre class="j resp-j" id="c-resp">'+fj(c.response)+'</pre>';else h+='<span style="color:#555" id="c-resp">无响应</span>';h+='</div>';
|
||||||
|
h+='<div class="exp-col-btn"><button class="exp-btn" onclick="toggleExpect()">'+(vis?'收起 预期响应':'展开 预期响应')+'</button></div>';
|
||||||
|
h+='<div class="exp-panel"><div class="ep-hd"><span>预期响应</span><button class="cp-sm" onclick="copyEl(\'c-exp\')">复制</button></div><pre class="j resp-j" id="c-exp">'+fj(c.expect)+'</pre></div>';
|
||||||
|
h+='</div>';}
|
||||||
|
else{if(c.response)h+='<pre class="j" id="c-resp">'+fj(c.response)+'</pre>';else h+='<span style="color:#555" id="c-resp">无响应</span>';}
|
||||||
h+='</div>';
|
h+='</div>';
|
||||||
panel.innerHTML=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 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 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 fj(o){return esc(JSON.stringify(o,null,2));}
|
||||||
function fjp(o,params){
|
function fjp(o,params){
|
||||||
if(!params||!params.length||typeof o!=='object'||o===null||Array.isArray(o))return fj(o);
|
if(!params||!params.length||typeof o!=='object'||o===null||Array.isArray(o))return fj(o);
|
||||||
|
|||||||
Reference in New Issue
Block a user