feat(api): 增强 API 测试框架的断言功能

- 修改 ApiCase 和 ApiResponse 结构,新增 Verify 方法以支持自定义校验函数
- 更新 Get、Post、Put、Delete 方法,允许使用 expect 参数进行更灵活的响应断言
- 添加 ExpectResult 方法,支持对响应结果的结构和类型进行后置校验
- 更新文档,详细说明新功能的使用方法和示例,提升测试框架的可用性
- 增强 Swagger 生成逻辑,支持输出新的 expect 和 verifyError 字段
This commit is contained in:
2026-03-21 13:38:22 +08:00
parent 813e26755f
commit 70012032f0
10 changed files with 1263 additions and 182 deletions
+104 -2
View File
@@ -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)})
```
---
+5 -4
View File
@@ -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
+81
View File
@@ -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
View File
@@ -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,
}
+61
View File
@@ -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) {
File diff suppressed because it is too large Load Diff
+34 -6
View File
@@ -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+='<div class="cs"><div class="csh" onclick="accordion(this)"><span class="ar'+(i===0?' o':'')+'">&#9654;</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">';
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;
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="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>';
if(c.response)h+='<pre class="j" id="'+respId+'">'+fj(c.response)+'</pre>';
else h+='<span style="color:#555" id="'+respId+'">无响应</span>';
const hasDetail=c.expect&&c.expect.result!==undefined;const expId='ce'+i;
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>';
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>';
}
return h;
@@ -330,11 +350,17 @@ function updateCasePanel(e,i){
const c=e.cases[i];
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.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="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>';
if(c.response)h+='<pre class="j" id="c-resp">'+fj(c.response)+'</pre>';
else h+='<span style="color:#555" id="c-resp">无响应</span>';
const hasDetail=c.expect&&c.expect.result!==undefined;
h+='<div class="row-hd" style="margin:8px 0 4px"><span class="sec-title">响应</span></div>';
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>';
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);
+207 -32
View File
@@ -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 为期望 msgMap 为期望 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)
}
}
+2
View File
@@ -59,6 +59,8 @@ type TestRecord struct {
FileField string
ResponseBody map[string]interface{}
Note string
ExpectResult interface{}
VerifyError string
}
// TestCollector 线程安全的测试记录收集器
+50 -20
View File
@@ -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+='<div class="cs"><div class="csh" onclick="accordion(this)"><span class="ar'+(i===0?' o':'')+'">&#9654;</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">';
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;
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="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>';
if(c.response)h+='<pre class="j" id="'+respId+'">'+fj(c.response)+'</pre>';
else h+='<span style="color:#555" id="'+respId+'">无响应</span>';
const hasDetail=c.expect&&c.expect.result!==undefined;const expId='ce'+i;
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>';
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>';
}
return h;
@@ -678,11 +700,17 @@ function updateCasePanel(e,i){
const c=e.cases[i];
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.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="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>';
if(c.response)h+='<pre class="j" id="c-resp">'+fj(c.response)+'</pre>';
else h+='<span style="color:#555" id="c-resp">无响应</span>';
const hasDetail=c.expect&&c.expect.result!==undefined;
h+='<div class="row-hd" style="margin:8px 0 4px"><span class="sec-title">响应</span></div>';
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>';
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);