From 47e13b532bafe721824064d31186886d0cbcc6e1 Mon Sep 17 00:00:00 2001 From: hoteas <925970985@qq.com> Date: Sun, 15 Mar 2026 11:45:21 +0800 Subject: [PATCH] =?UTF-8?q?feat(api):=20=E6=B7=BB=E5=8A=A0=E5=A4=87?= =?UTF-8?q?=E6=B3=A8=E5=8A=9F=E8=83=BD=E4=BB=A5=E5=A2=9E=E5=BC=BA=E7=94=A8?= =?UTF-8?q?=E4=BE=8B=E6=8F=8F=E8=BF=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在 Api 和 ApiCase 中新增 Note 方法,允许为用例设置可选备注 - 更新 TestRecord 结构体,包含备注字段以便记录用例信息 - 修改 Swagger 生成逻辑,支持备注字段的输出 - 更新文档,详细说明 Note 方法的使用及其在调试控制台的显示效果 --- docs/Testing_API测试框架.md | 12 ++++++++++-- testing_api.go | 15 +++++++++++++++ testing_helper.go | 23 ++++++++++++----------- testing_swagger.go | 22 +++++++++++++++------- 4 files changed, 52 insertions(+), 20 deletions(-) diff --git a/docs/Testing_API测试框架.md b/docs/Testing_API测试框架.md index 31e93ff..1ef7a37 100644 --- a/docs/Testing_API测试框架.md +++ b/docs/Testing_API测试框架.md @@ -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 且值为空的用例),调试面板和用例展示均标记 `*` | | **预填用例** | 默认选中第一个用例自动填充左侧所有参数;"手动填写"置于列表末尾;必填字段有 `*` 标记 | diff --git a/testing_api.go b/testing_api.go index 886cd14..9ef69d9 100644 --- a/testing_api.go +++ b/testing_api.go @@ -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 diff --git a/testing_helper.go b/testing_helper.go index cd4703a..b8827c7 100644 --- a/testing_helper.go +++ b/testing_helper.go @@ -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 线程安全的测试记录收集器 diff --git a/testing_swagger.go b/testing_swagger.go index 0410920..d4319b8 100644 --- a/testing_swagger.go +++ b/testing_swagger.go @@ -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+='
'; h+='
'+buildLeft(e)+'
'; h+='
'; - h+='
测试用例'+(cnt?'('+cnt+')':'')+'
发送结果
'; + h+='
用例结果
发送结果
'; h+='
'; h+='
'+respPanel()+'
'; h+='
'; @@ -533,6 +534,7 @@ function cases(e){ for(let i=0;i'+esc(c.name)+''+c.method+''; h+='
'; + if(c.note)h+='
备注: '+esc(c.note)+'
'; const curlId='cc'+i;const respId='cr'+i; h+='
cURL
'; h+='
'+esc(caseCurl(e,c))+'
'; @@ -553,6 +555,7 @@ function accordion(el){ function respPanel(){ let r=''; + r+=''; r+='
cURL
'; r+='
发送请求后生成
'; r+='
响应
'; @@ -567,7 +570,7 @@ function buildLeft(e){ const at=document.getElementById('at').value; let l=''; l+='
'; - l+='预填'; + l+='测试用例选择'; l+='
    '; l+=''; l+='
    '; @@ -674,6 +677,7 @@ function updateCasePanel(e,i){ if(i===''||!e?.cases?.[i]){panel.innerHTML='
    选择一个测试用例查看响应
    ';return;} const c=e.cases[i]; let h='
    '; + if(c.note)h+='
    备注: '+esc(c.note)+'
    '; h+='
    cURL'+(c.passed?'通过':'失败')+'
    '; h+='
    '+esc(caseCurl(e,c))+'
    '; h+='
    响应
    '; @@ -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;