feat(api): 增强 API 测试框架的断言功能
- 修改 ApiCase 和 ApiResponse 结构,新增 Verify 方法以支持自定义校验函数 - 更新 Get、Post、Put、Delete 方法,允许使用 expect 参数进行更灵活的响应断言 - 添加 ExpectResult 方法,支持对响应结果的结构和类型进行后置校验 - 更新文档,详细说明新功能的使用方法和示例,提升测试框架的可用性 - 增强 Swagger 生成逻辑,支持输出新的 expect 和 verifyError 字段
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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{
|
||||
"test": TestCtr,
|
||||
"mysql": MysqlCtr,
|
||||
"dmdb": DmdbCtr,
|
||||
"cache": CacheCtr,
|
||||
"test": TestCtr,
|
||||
"mysql": MysqlCtr,
|
||||
"dmdb": DmdbCtr,
|
||||
"cache": CacheCtr,
|
||||
"expect_demo": ExpectDemoCtr,
|
||||
}
|
||||
|
||||
@@ -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
@@ -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':'')+'">▶</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);
|
||||
|
||||
Reference in New Issue
Block a user