feat(api): 添加备注功能以增强用例描述

- 在 Api 和 ApiCase 中新增 Note 方法,允许为用例设置可选备注
- 更新 TestRecord 结构体,包含备注字段以便记录用例信息
- 修改 Swagger 生成逻辑,支持备注字段的输出
- 更新文档,详细说明 Note 方法的使用及其在调试控制台的显示效果
This commit is contained in:
2026-03-15 11:45:21 +08:00
parent 97a9f0964a
commit 309f113a6f
4 changed files with 52 additions and 20 deletions
+10 -2
View File
@@ -1,4 +1,4 @@
# HoTime API 测试框架使用说明
# HoTime API 测试框架使用说明
不启动 HTTP 服务、自动事务回滚、链式 API、覆盖率报告、API 调试控制台生成。
@@ -209,6 +209,7 @@ func (app *TestApp) DB() *HoTimeDB
| `a.Query(params)` | 设置 URL 查询参数 | `*ApiCase` |
| `a.Form(body)` | 设置 form 表单 body | `*ApiCase` |
| `a.File(field, name, content)` | 设置上传文件 | `*ApiCase` |
| `a.Note(note)` | 为下一条用例设置备注,备注显示在调试控制台 | `*ApiCase` |
| `a.WithSession(s)` | 返回携带 session 的新 Api | `*Api` |
| `a.Get(desc, status, msg...)` | 直接 GET 请求(无数据场景) | `*ApiResponse` |
| `a.Post(desc, status, msg...)` | 直接 POST 请求(无数据场景) | `*ApiResponse` |
@@ -226,6 +227,7 @@ func (app *TestApp) DB() *HoTimeDB
| `c.Query(params)` | 追加 URL 查询参数 | `*ApiCase` |
| `c.Form(body)` | 追加 form 字段 | `*ApiCase` |
| `c.File(field, name, content)` | 设置上传文件 | `*ApiCase` |
| `c.Note(note)` | 为本条用例添加可选备注,备注显示在调试控制台用例详情中 | `*ApiCase` |
| `c.WithSession(s)` | 覆盖本次请求的 session | `*ApiCase` |
| `c.Get(desc, status, msg...)` | 终端:发送 GET 请求并断言 | `*ApiResponse` |
| `c.Post(desc, status, msg...)` | 终端:发送 POST 请求并断言 | `*ApiResponse` |
@@ -303,6 +305,10 @@ a.JSON(Map{"name": "新名字"}).Put("更新", 0)
// DELETE + URL 参数
a.Query(Map{"id": "5"}).Delete("删除", 0)
// 带备注的用例(备注显示在调试控制台用例详情中,没有备注时不显示)
a.Form(Map{"phone": "138", "code": "1234"}).Note("sign = MD5(smsProxyKey+timestamp)smsProxyKey 见 config.json").Post("正常发送", 0)
a.Note("需先登录后在 Header 中携带 Authorization token").JSON(Map{"name": "新名字"}).Post("更新信息", 0)
// 校验响应中的具体字段
resp := a.JSON(Map{"name": "138", "password": "123"}).Post("正常登录", 0)
if resp.GetBody().GetMap("result").GetString("token") == "" {
@@ -408,6 +414,7 @@ func TestMain(m *testing.M) {
"name": "正常登录",
"method": "POST",
"passed": true,
"note": "备注内容(可选,未调用 .Note() 则不出现该字段)",
"query": {},
"json": { "name": "13800138000", "password": "123456" },
"response": { "status": 0, "msg": "ok", "data": {} }
@@ -434,6 +441,7 @@ func TestMain(m *testing.M) {
| `params[].in` | string | 参数位置:`query` / `form` / `json` |
| `cases` | array | 实际运行的测试用例列表;若 `tested=false` 则为空数组 |
| `cases[].passed` | bool | 该用例是否通过(断言的 status/message 均匹配) |
| `cases[].note` | string | 用例备注(可选),由 `.Note("...")` 设置,为空时不输出该字段 |
| `cases[].query` | object | 该用例的 Query 参数(GET 场景) |
| `cases[].json` | object | 该用例的 JSON Body`a.JSON(...)` 场景) |
| `cases[].form` | object | 该用例的 Form 参数(`a.Form(...)` 场景) |
@@ -490,7 +498,7 @@ testApp.GenerateSwagger(
| **三级导航** | 左侧侧边栏按 项目 > 控制器 > 方法 三级树形展示(一级默认展开,二级默认收起) |
| **搜索和筛选** | 支持关键字搜索,按 全部/已测试/未测试/通过/未通过 五种模式筛选 |
| **左右分栏** | 接口信息栏右侧显示用例数量(`用例: N`);左侧为请求构建器,右侧为结果面板(两 tab 切换) |
| **测试用例 tab** | 右侧默认展示"测试用例"tab,手风琴列表(第一个默认展开);必填参数前显示红色 `*` 标记 |
| **测试用例 tab** | 右侧默认展示"测试用例"tab,手风琴列表(第一个默认展开);必填参数前显示红色 `*` 标记;用例设置了 `.Note(...)` 时在展开体和选中面板顶部显示灰色备注文字 |
| **发送结果 tab** | 点击"发送"后自动切换到"发送结果"tab 展示 cURL 命令和响应内容;可随时手动切回 |
| **必填参数推断** | 从测试用例自动推断必填参数(有 status==3 且值为空的用例),调试面板和用例展示均标记 `*` |
| **预填用例** | 默认选中第一个用例自动填充左侧所有参数;"手动填写"置于列表末尾;必填字段有 `*` 标记 |
+15
View File
@@ -110,6 +110,13 @@ func (a *Api) Delete(desc string, status int, msg ...string) *ApiResponse {
return a.newCase().Delete(desc, status, msg...)
}
// Note 为下一条用例设置备注,返回可继续链式的 ApiCase
func (a *Api) Note(note string) *ApiCase {
c := a.newCase()
c.note = note
return c
}
// DB 获取数据库实例(在测试事务内)
func (a *Api) DB() *HoTimeDB {
return &a.app.Db
@@ -134,6 +141,7 @@ type ApiCase struct {
fileField string
fileName string
fileContent []byte
note string
}
// JSON 叠加 JSON body
@@ -180,6 +188,12 @@ func (c *ApiCase) WithSession(s Map) *ApiCase {
return c
}
// Note 为本条用例添加可选备注,备注将显示在 swagger 调试控制台
func (c *ApiCase) Note(note string) *ApiCase {
c.note = note
return c
}
// Get 终端操作:GET 请求 + 断言
func (c *ApiCase) Get(desc string, status int, msg ...string) *ApiResponse {
return c.execute("GET", desc, status, msg...)
@@ -247,6 +261,7 @@ func (c *ApiCase) execute(method, desc string, expectStatus int, expectMsg ...st
Passed: passed,
Duration: duration,
Method: method,
Note: c.note,
}
if c.query != nil {
record.Query = c.query
+12 -11
View File
@@ -46,18 +46,19 @@ type MethodCoverage struct {
// TestRecord 单条测试记录
type TestRecord struct {
Path string
Desc string
CaseName string
Passed bool
Duration time.Duration
Method string
Query map[string]interface{}
JsonBody interface{}
FormBody map[string]interface{}
HasFile bool
FileField string
Path string
Desc string
CaseName string
Passed bool
Duration time.Duration
Method string
Query map[string]interface{}
JsonBody interface{}
FormBody map[string]interface{}
HasFile bool
FileField string
ResponseBody map[string]interface{}
Note string
}
// TestCollector 线程安全的测试记录收集器
+15 -7
View File
@@ -13,6 +13,7 @@ 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"`
@@ -227,10 +228,10 @@ func (that *TestApp) GenerateSwagger(title, version, outputDir string) error {
var cases []swaggerTestCase
for _, r := range recs {
tc := swaggerTestCase{
Name: r.CaseName, 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,
}
if r.Query != nil {
tc.Query = r.Query
}
@@ -502,7 +503,7 @@ function show(){
h+='<div class="split">';
h+='<div class="split-l">'+buildLeft(e)+'</div>';
h+='<div class="split-r">';
h+='<div class="rtabs"><div class="rtab on" id="rt0" onclick="rswitch(0)">测试用例'+(cnt?'('+cnt+')':'')+'</div><div class="rtab" id="rt1" onclick="rswitch(1)">发送结果</div></div>';
h+='<div class="rtabs"><div class="rtab on" id="rt0" onclick="rswitch(0)">用例结果</div><div class="rtab" id="rt1" onclick="rswitch(1)">发送结果</div></div>';
h+='<div class="rpn on" id="rp0"><div id="case-panel" style="padding:4px 0"></div></div>';
h+='<div class="rpn" id="rp1">'+respPanel()+'</div>';
h+='</div></div>';
@@ -533,6 +534,7 @@ function cases(e){
for(let i=0;i<e.cases.length;i++){const c=e.cases[i];
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>';
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>';
@@ -553,6 +555,7 @@ function accordion(el){
function respPanel(){
let r='';
r+='<div id="note-box" style="display:none;color:#aaa;font-size:12px;margin-bottom:8px;line-height:1.5"></div>';
r+='<div class="row-hd" style="margin-bottom:4px"><span class="sec-title">cURL</span><button class="cp-sm" onclick="copyEl(\'curl-box\')">复制</button></div>';
r+='<div class="curl" id="curl-box">发送请求后生成</div>';
r+='<div class="row-hd" style="margin:10px 0 4px"><span class="sec-title">响应 <span id="resp-st"></span></span><button class="cp-sm" onclick="copyEl(\'resp-body\')">复制</button></div>';
@@ -567,7 +570,7 @@ function buildLeft(e){
const at=document.getElementById('at').value;
let l='';
l+='<div style="display:flex;align-items:center;gap:6px;margin-bottom:10px">';
l+='<span style="color:var(--fg2);font-size:11px;white-space:nowrap">预填</span>';
l+='<span style="color:var(--fg2);font-size:11px;white-space:nowrap">测试用例选择</span>';
l+='<div class="pf-wrap"><button class="pf-btn" id="pf-btn" onclick="togglePfDd(event)"><span class="dot" id="pf-dot" style="display:none;flex-shrink:0"></span><span id="pf-label" style="flex:1;text-align:left;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--fg2)">选择用例...</span><span style="color:var(--fg2);font-size:10px;flex-shrink:0">▾</span></button><ul class="pf-dd" id="pf-dd"></ul></div>';
l+='<button class="sbtn" onclick="send()" id="sbtn">发送</button>';
l+='</div>';
@@ -674,6 +677,7 @@ function updateCasePanel(e,i){
if(i===''||!e?.cases?.[i]){panel.innerHTML='<div style="color:#555;padding:16px 10px">选择一个测试用例查看响应</div>';return;}
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>';
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>';
@@ -697,11 +701,15 @@ function pfFill(v){
if(C?.params){for(const p of C.params.filter(p=>p.in==='form'))addKVReq('f-rows',p.name,p.example!==undefined?String(p.example):'',p.required);}
addKV('f-rows');
}
document.getElementById('file-rows').innerHTML='';addFileRow();syncAuth();return;
document.getElementById('file-rows').innerHTML='';addFileRow();
const nb0=document.getElementById('note-box');if(nb0){nb0.style.display='none';nb0.textContent='';}
syncAuth();return;
}
const i=parseInt(v);
if(isNaN(i)||i<0||!C?.cases?.[i])return;
const c=C.cases[i];
const nb=document.getElementById('note-box');
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;