feat(tests): 增强测试框架的 SQL 日志记录与覆盖率报告

- 在 ApiCase 中添加 SQL 日志记录功能,失败时输出日志以便于调试
- 更新 TestApp 以支持测试模式下的 SQL 日志缓冲,成功与失败的测试均可记录日志
- 改进 PrintCoverage 方法,优化覆盖率报告的输出逻辑,支持部分运行的接口显示
- 增加 Note 备注功能,提升测试用例的可读性与文档化效果
- 更新相关文档,详细说明新功能的使用场景与示例
This commit is contained in:
2026-04-04 01:34:09 +08:00
parent b3f263c965
commit 17e1090077
8 changed files with 531 additions and 28 deletions
+92 -16
View File
@@ -51,8 +51,13 @@ func inferParamsFromCases(cases []swaggerTestCase) []paramSpec {
return -1
}
if s, ok := resp["status"]; ok {
if f, ok := s.(float64); ok {
return int(f)
switch v := s.(type) {
case float64:
return int(v)
case int64:
return int(v)
case int:
return v
}
}
return -1
@@ -60,13 +65,15 @@ func inferParamsFromCases(cases []swaggerTestCase) []paramSpec {
inferType := func(v interface{}) string {
switch v.(type) {
case float64:
case int, int8, int16, int32, int64,
uint, uint8, uint16, uint32, uint64,
float32, float64:
return "number"
case bool:
return "boolean"
case []interface{}:
case []interface{}, Slice:
return "array"
case map[string]interface{}:
case map[string]interface{}, Map:
return "object"
default:
return "string"
@@ -95,6 +102,7 @@ func inferParamsFromCases(cases []swaggerTestCase) []paramSpec {
}
// 第一轮:收集所有参数 key、类型和示例值
// 成功用例(status=0)优先级最高,覆盖 in/typ/example,代表真实请求方式和数据类型
for _, c := range cases {
st := getStatus(c.Response)
for k, v := range c.Query {
@@ -102,10 +110,14 @@ func inferParamsFromCases(cases []swaggerTestCase) []paramSpec {
params[k] = &paramInfo{in: "query"}
}
p := params[k]
if st == 0 {
p.in = "query"
}
if p.typ == "" && sv(v) != "" {
p.typ = inferType(v)
}
if p.example == nil && st == 0 && sv(v) != "" {
if st == 0 && sv(v) != "" {
p.typ = inferType(v)
p.example = v
}
}
@@ -114,10 +126,14 @@ func inferParamsFromCases(cases []swaggerTestCase) []paramSpec {
params[k] = &paramInfo{in: "form"}
}
p := params[k]
if st == 0 {
p.in = "form"
}
if p.typ == "" && sv(v) != "" {
p.typ = inferType(v)
}
if p.example == nil && st == 0 && sv(v) != "" {
if st == 0 && sv(v) != "" {
p.typ = inferType(v)
p.example = v
}
}
@@ -127,40 +143,76 @@ func inferParamsFromCases(cases []swaggerTestCase) []paramSpec {
params[k] = &paramInfo{in: "json"}
}
p := params[k]
if st == 0 {
p.in = "json"
}
if p.typ == "" && sv(v) != "" {
p.typ = inferType(v)
}
if p.example == nil && st == 0 && sv(v) != "" {
if st == 0 && sv(v) != "" {
p.typ = inferType(v)
p.example = v
}
}
}
}
// 第二轮:标记必填 — 参数存在但值为空 且 status==3
// 第二轮:标记必填 — 参数存在但值为空 且 status==3(不限制 in 类型,因为错误用例可能用不同传参方式)
for _, c := range cases {
if getStatus(c.Response) != 3 {
continue
}
for k, v := range c.Query {
if p, ok := params[k]; ok && p.in == "query" && sv(v) == "" {
if p, ok := params[k]; ok && sv(v) == "" {
p.required = true
}
}
for k, v := range c.Form {
if p, ok := params[k]; ok && p.in == "form" && sv(v) == "" {
if p, ok := params[k]; ok && sv(v) == "" {
p.required = true
}
}
if jmap := toStrMap(c.Json); jmap != nil {
for k, v := range jmap {
if p, ok := params[k]; ok && p.in == "json" && sv(v) == "" {
if p, ok := params[k]; ok && sv(v) == "" {
p.required = true
}
}
}
}
// 第三轮:标记必填 — 字段在 status==3 用例中缺失,但在下一个用例中出现
collectKeys := func(c swaggerTestCase) map[string]bool {
keys := map[string]bool{}
for k := range c.Query {
keys[k] = true
}
for k := range c.Form {
keys[k] = true
}
if jm := toStrMap(c.Json); jm != nil {
for k := range jm {
keys[k] = true
}
}
return keys
}
for i := 0; i < len(cases)-1; i++ {
if getStatus(cases[i].Response) != 3 {
continue
}
curKeys := collectKeys(cases[i])
nextKeys := collectKeys(cases[i+1])
for k := range nextKeys {
if !curKeys[k] {
if p, ok := params[k]; ok {
p.required = true
}
}
}
}
result := make([]paramSpec, 0, len(params))
for name, p := range params {
result = append(result, paramSpec{
@@ -509,6 +561,12 @@ pre.j.resp-j{max-height:calc(100vh - 280px);min-height:120px}
.bd-bd textarea{width:100%;min-height:36px;max-height:40vh;padding:5px 8px;border:1px solid var(--border);border-radius:var(--r);background:var(--bg2);color:var(--fg);font-size:12px;font-family:Consolas,Monaco,monospace;resize:vertical;outline:none;overflow-y:auto}
.bd-bd textarea:focus{border-color:var(--accent)}
.req-star{color:var(--red);font-size:10px;font-weight:700;margin-right:2px;flex-shrink:0;line-height:1.2;align-self:center}
.jp-panel{margin-bottom:6px;border:1px solid var(--border);border-radius:var(--r);overflow:hidden}
.jp-hd{padding:3px 8px;background:var(--bg2);cursor:pointer;display:flex;align-items:center;font-size:11px;color:var(--fg2);user-select:none}
.jp-bd{padding:6px 8px;display:flex;flex-wrap:wrap;gap:4px;border-top:1px solid var(--border)}
.jp-tag{font-size:11px;padding:1px 6px;background:var(--bg3);border:1px solid var(--border);border-radius:var(--r);color:var(--fg2);display:inline-flex;align-items:center;gap:2px}
.jp-req{color:var(--accent);border-color:rgba(79,195,247,.3)}
.jp-type{color:#555;font-size:10px}
</style></head><body>
<div id="side">
@@ -680,7 +738,24 @@ function buildLeft(e){
l+='<div class="bd-hd'+(defBody==='form'?' on':'')+'" data-t="form" onclick="togBody(\'form\')"><span class="ar'+(defBody==='form'?' o':'')+'">&#9654;</span>表单 (Form)<button class="addbtn" onclick="event.stopPropagation();setAcc(\'form\');addKV(\'f-rows\')">+ 添加</button></div>';
l+='<div id="bt-form" class="bd-bd'+(defBody==='form'?' on':'')+'"><div id="f-rows"><div class="kv"><input placeholder="key"><input placeholder="value"><button onclick="this.parentElement.remove()">&#215;</button></div></div></div>';
l+='<div class="bd-hd'+(defBody==='json'?' on':'')+'" data-t="json" onclick="togBody(\'json\')"><span class="ar'+(defBody==='json'?' o':'')+'">&#9654;</span>JSON</div>';
l+='<div id="bt-json" class="bd-bd'+(defBody==='json'?' on':'')+'"><textarea id="xb" oninput="autoH(this)" placeholder="'+(jsonPH?esc(jsonPH):'{"key":"value"}')+'"></textarea></div>';
l+='<div id="bt-json" class="bd-bd'+(defBody==='json'?' on':'')+'">';
if(e.params){
const jp=e.params.filter(p=>p.in==='json');
if(jp.length){
const jpVis=localStorage.getItem('json_params_vis')!=='0';
l+='<div class="jp-panel"><div class="jp-hd" onclick="togJP()"><span class="ar jp-ar'+(jpVis?' o':'')+'">&#9654;</span><span>JSON 参数</span><span style="margin-left:auto;font-size:10px" class="jp-toggle">'+(jpVis?'收起':'展开')+'</span></div>';
l+='<div class="jp-bd"'+(jpVis?'':' style="display:none"')+'>';
for(const p of jp){
const star=p.required?'<span class="req-star">*</span>':'';
const cls=p.required?'jp-tag jp-req':'jp-tag';
l+=star+'<span class="'+cls+'">'+esc(p.name);
if(p.type)l+=' <span class="jp-type">'+p.type+'</span>';
l+='</span>';
}
l+='</div></div>';
}
}
l+='<textarea id="xb" oninput="autoH(this)" placeholder="'+(jsonPH?esc(jsonPH):'{&quot;key&quot;:&quot;value&quot;}')+'"></textarea></div>';
l+='</div></div>';
return l;
}
@@ -806,7 +881,7 @@ function pfFill(v){
if(nb){if(c.note){nb.textContent='备注: '+c.note;nb.style.display='block';}else{nb.textContent='';nb.style.display='none';}}
if(c.query){
for(const[k,val]of Object.entries(c.query)){
const req=C?.params?.find(p=>p.name===k&&p.in==='query')?.required||false;
const req=C?.params?.find(p=>p.name===k)?.required||false;
addKVReq('q-rows',k,String(val),req);
}
}
@@ -814,7 +889,7 @@ function pfFill(v){
if(c.json){
setAcc('json');const el=document.getElementById('xb');
if(el){
const rq=new Set((C?.params||[]).filter(p=>p.required&&p.in==='json').map(p=>p.name));
const rq=new Set((C?.params||[]).filter(p=>p.required).map(p=>p.name));
let obj=c.json;
if(rq.size&&typeof obj==='object'&&obj!==null&&!Array.isArray(obj)){
const sorted={};
@@ -828,7 +903,7 @@ function pfFill(v){
if(fr){
fr.innerHTML='';
for(const[k,val]of Object.entries(c.form)){
const req=C?.params?.find(p=>p.name===k&&p.in==='form')?.required||false;
const req=C?.params?.find(p=>p.name===k)?.required||false;
addKVReq('f-rows',k,String(val),req);
}
addKV('f-rows');
@@ -904,6 +979,7 @@ function copyEl(id){navigator.clipboard.writeText(document.getElementById(id).te
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 togJP(){const bd=document.querySelector('.jp-bd');const ar=document.querySelector('.jp-ar');const tg=document.querySelector('.jp-toggle');if(!bd)return;const vis=bd.style.display!=='none';bd.style.display=vis?'none':'flex';if(ar)ar.classList.toggle('o',!vis);if(tg)tg.textContent=vis?'展开':'收起';localStorage.setItem('json_params_vis',vis?'0':'1');}
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);