From 17e1090077d4cbfd35307c964326109c08e9e6eb Mon Sep 17 00:00:00 2001 From: hoteas <925970985@qq.com> Date: Sat, 4 Apr 2026 01:34:09 +0800 Subject: [PATCH] =?UTF-8?q?feat(tests):=20=E5=A2=9E=E5=BC=BA=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=E6=A1=86=E6=9E=B6=E7=9A=84=20SQL=20=E6=97=A5=E5=BF=97?= =?UTF-8?q?=E8=AE=B0=E5=BD=95=E4=B8=8E=E8=A6=86=E7=9B=96=E7=8E=87=E6=8A=A5?= =?UTF-8?q?=E5=91=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在 ApiCase 中添加 SQL 日志记录功能,失败时输出日志以便于调试 - 更新 TestApp 以支持测试模式下的 SQL 日志缓冲,成功与失败的测试均可记录日志 - 改进 PrintCoverage 方法,优化覆盖率报告的输出逻辑,支持部分运行的接口显示 - 增加 Note 备注功能,提升测试用例的可读性与文档化效果 - 更新相关文档,详细说明新功能的使用场景与示例 --- .../plans/hotime_tdd_skill_a252ff6f.plan.md | 220 ++++++++++++++++++ .../plans/补充_note_方法示例_d1af0e5b.plan.md | 69 ++++++ db/db.go | 26 ++- db/query.go | 19 +- docs/Testing_API测试框架.md | 51 ++++ testing_api.go | 8 + testing_helper.go | 58 ++++- testing_swagger.go | 108 +++++++-- 8 files changed, 531 insertions(+), 28 deletions(-) create mode 100644 .cursor/plans/hotime_tdd_skill_a252ff6f.plan.md create mode 100644 .cursor/plans/补充_note_方法示例_d1af0e5b.plan.md diff --git a/.cursor/plans/hotime_tdd_skill_a252ff6f.plan.md b/.cursor/plans/hotime_tdd_skill_a252ff6f.plan.md new file mode 100644 index 0000000..120caf0 --- /dev/null +++ b/.cursor/plans/hotime_tdd_skill_a252ff6f.plan.md @@ -0,0 +1,220 @@ +--- +name: HoTime TDD Skill +overview: 创建一个 HoTime 框架的 TDD(测试驱动开发)Skill,指导 AI 按照"先写测试、再写实现、运行直到通过"的流程开发接口;同时在框架层面增加测试模式下的 SQL 日志智能控制,解决 AI 因日志过多无法继续执行的问题。 +todos: + - id: sql-log-buffer + content: "db/db.go: 添加 testLogBuf *bytes.Buffer + testLogBufMu sync.Mutex 字段" + status: completed + - id: sql-log-query + content: "db/query.go: 修改 Query/Exec 两处 defer SQL 日志块,有 buffer 写 buffer、无 buffer 照旧" + status: completed + - id: sql-log-execute + content: "testing_api.go execute(): 请求前创建 buffer、请求后根据 pass/fail 决定 t.Log 或丢弃" + status: completed + - id: sql-log-init + content: "testing_helper.go NewTestApp(): Init 后设 Db.Mode=0 静默启动阶段日志,再恢复原 Mode 供运行时 buffer 使用" + status: completed + - id: create-skill + content: 创建 ~/.cursor/skills/hotime-tdd-testing/SKILL.md:TDD 工作流 + 测试编写范式 + API 速查 + 运行命令 + status: completed + - id: verify-test + content: 运行单接口测试验证:通过时无 SQL 日志,失败时输出 SQL 日志 + status: completed +isProject: false +--- + +# HoTime TDD API 测试 Skill 与 SQL 日志控制 + +## 一、SQL 日志智能控制(框架修改)-- 失败才打印 + +### 问题分析 + +SQL 日志由 `Db.Mode` 控制([db/query.go](d:/work/hotimev1.5/db/query.go) 第 40 行 `if that.Mode != 0`),`Log` 是 `*logrus.Logger`([db/db.go](d:/work/hotimev1.5/db/db.go) 第 22 行)。当前 config `"mode": 3`,每条 SQL 都实时打印。日志来源: + +- 启动阶段 `Init()` -> `SetDB()` 中的 DB 初始化/表扫描 +- 测试执行中每个接口的所有 SQL +- 批量运行时日志量指数级增长,导致 AI 输出被截断 + +### 方案:Buffer + Flush on Failure + +**策略**:SQL 日志不直接打印,写入 per-case buffer。测试通过则丢弃,失败则通过 `t.Log()` 输出(Go 的 `t.Log` 只在失败或 `-v` 时显示)。 + +#### Step 1: db/db.go -- 添加 buffer 字段 + +```go +type HoTimeDB struct { + // ... 现有字段不变 ... + testLogBuf *bytes.Buffer // 测试模式:SQL 日志写入此 buffer 而非直接打印 + testLogBufMu sync.Mutex // 保护 testLogBuf 并发写入 +} +``` + +新增 helper 方法: + +```go +func (that *HoTimeDB) SetTestLogBuffer(buf *bytes.Buffer) { + that.testLogBufMu.Lock() + that.testLogBuf = buf + that.testLogBufMu.Unlock() +} + +func (that *HoTimeDB) FlushTestLog() string { + that.testLogBufMu.Lock() + defer that.testLogBufMu.Unlock() + if that.testLogBuf == nil { + return "" + } + s := that.testLogBuf.String() + that.testLogBuf.Reset() + return s +} +``` + +#### Step 2: db/query.go -- 修改两处 defer SQL 日志 + +`queryWithRetry` 第 39-45 行和 `execWithRetry` 第 117-123 行的 defer 块统一改为: + +```go +defer func() { + if that.Mode != 0 { + that.mu.RLock() + msg := fmt.Sprintf("SQL:%s DATA:%v ERROR:%v", that.LastQuery, that.LastData, that.LastErr.GetError()) + that.mu.RUnlock() + that.testLogBufMu.Lock() + if that.testLogBuf != nil { + that.testLogBuf.WriteString(msg + "\n") + } else { + that.Log.Info(msg) + } + that.testLogBufMu.Unlock() + } +}() +``` + +#### Step 3: testing_api.go execute() -- 管理 buffer 生命周期 + +在 `execute()` 函数的 `t.Run(desc, func(t *testing.T) { ... })` 内部: + +```go +t.Run(desc, func(t *testing.T) { + // 启用 SQL 日志缓冲 + buf := &bytes.Buffer{} + c.api.app.Db.SetTestLogBuffer(buf) + + start := time.Now() + req := c.buildRequest(method) + w := httptest.NewRecorder() + c.api.app.Application.ServeHTTP(w, req) + duration := time.Since(start) + + // ... 原有的断言逻辑 ... + + // 停止缓冲 + sqlLog := c.api.app.Db.FlushTestLog() + c.api.app.Db.SetTestLogBuffer(nil) + + // 失败时输出 SQL 日志 + if !passed && sqlLog != "" { + t.Logf("SQL 日志:\n%s", sqlLog) + } + // ... 原有的 record 收集逻辑 ... +}) +``` + +#### Step 4: testing_helper.go NewTestApp() -- 静默启动阶段日志 + +`Init()` 内部调用 `SetDB()` 时 Mode 已从 config 读取(值为 3),会打印大量启动 SQL。在 `NewTestApp` 中 `Init()` 之后立即处理: + +```go +func NewTestApp(configPath string, projects TestProj, ...) *TestApp { + app := Init(configPath) + + // 启动阶段产生的 SQL 日志已经打印完毕,现在保存原始 Mode + // Mode 保持原值(非 0)让运行时 SQL 仍走 defer 日志逻辑,但会被 buffer 拦截 + // 启动阶段的日志无法追溯拦截,但它们是一次性的固定输出,量可控 + + // ... 后续逻辑不变 ... +} +``` + +> 启动阶段的 SQL 日志(表扫描等)是 `Init()` 内部产生的,此时 buffer 尚未设置,会直接打印。这些是一次性的固定日志,量相对可控。如果仍然太多,可在 `Init()` 调用前临时设 `app.Db.Mode = 0`,但 `Init` 返回的 app 还没有 Db 对象——所以更实际的做法是在 `NewTestApp` 中 `Init()` 之后、`SetupForTest()` 之前设置 `Db.Mode = 0` 静默后续初始化 SQL,然后在 `SetupForTest()` 完成后恢复原始 Mode。 + +实际代码: + +```go +func NewTestApp(configPath string, projects TestProj, ...) *TestApp { + app := Init(configPath) + + // 保存原始 Mode,静默后续初始化阶段的 SQL 日志 + origMode := app.Db.Mode + app.Db.Mode = 0 + + // ... listener / router / SetupForTest / DisableDbCache 等初始化 ... + + // 恢复 Mode,运行时 SQL 日志由 testLogBuf 接管 + app.Db.Mode = origMode + + return &TestApp{ ... } +} +``` + +### 效果 + +- `go test ./app/... -v` -- 全量运行:通过的接口零 SQL 输出,失败的接口自动输出完整 SQL 链路 +- `go test -run TestApi/app/order/create -v` -- 单接口:同上,通过无输出、失败有输出 +- 任何粒度都自动适配,无需手动控制 + +### 涉及文件 + +- [db/db.go](d:/work/hotimev1.5/db/db.go) -- 添加 `testLogBuf` / `testLogBufMu` 字段 + helper 方法 +- [db/query.go](d:/work/hotimev1.5/db/query.go) -- 修改 `queryWithRetry` 和 `execWithRetry` 的 defer 日志块 +- [testing_api.go](d:/work/hotimev1.5/testing_api.go) -- `execute()` 中管理 buffer 生命周期 +- [testing_helper.go](d:/work/hotimev1.5/testing_helper.go) -- `NewTestApp()` 中静默初始化阶段 + 恢复 Mode + +--- + +## 二、TDD Skill 创建 + +### 存储位置 + +个人 Skill:`~/.cursor/skills/hotime-tdd-testing/SKILL.md`(跨项目通用) + +### Skill 核心内容 + +指导 AI 按以下 TDD 工作流开发 HoTime 接口: + +**Phase 1: 编写测试用例(先于实现)** + +- 在 `xxx_test.go` 中定义 `CtrTest`,包含: + - 错误用例(权限/参数/业务校验,覆盖所有 status != 0 路径) + - 测试数据准备(`a.DB().Insert`) + - 正确请求 + 响应结构校验(第三参数类型样本) + - Verify 统一校验(响应值断言 + 数据库状态校验) + +**Phase 2: 实现接口** + +- 在对应的 `.go` 文件中编写 handler + +**Phase 3: 运行测试直到通过** + +- 运行命令:`go test ./app/... -v -run TestApi/app/{ctr}/{method}` +- 修复失败用例,循环直到全部通过 + +**关键参考文档** + +- Skill 内直接嵌入精简版的测试 API 速查表(链式 API、Verify 用法、DB 操作等) +- 详细文档指向 [Testing_API测试框架.md](d:/work/xbc/docs/Testing_API测试框架.md) + +**SQL 日志行为(框架自动处理)** + +- 任何粒度运行:通过的用例零 SQL 输出,失败的用例自动输出完整 SQL 链路 +- 无需手动控制,框架通过 buffer 机制自动按 pass/fail 决定输出 + +### Skill 文件结构 + +``` +~/.cursor/skills/hotime-tdd-testing/ + SKILL.md -- 主文件(TDD 流程 + API 速查 + 运行指令) +``` + +直接在 SKILL.md 内嵌入精简的 API 参考(不分离文件),保持在 500 行以内,因为测试框架文档已经在项目 docs 目录中。 \ No newline at end of file diff --git a/.cursor/plans/补充_note_方法示例_d1af0e5b.plan.md b/.cursor/plans/补充_note_方法示例_d1af0e5b.plan.md new file mode 100644 index 0000000..54dee3c --- /dev/null +++ b/.cursor/plans/补充_note_方法示例_d1af0e5b.plan.md @@ -0,0 +1,69 @@ +--- +name: 补充 Note 方法示例 +overview: 在 Testing_API测试框架.md 的"第二步:编写测试用例"部分补充 Note 方法的使用示例和说明,同步更新 SKILL.md。 +todos: + - id: doc-step2-example + content: 文档:在第二步的完整订单示例中加入 Note 调用示范 + status: completed + - id: doc-note-section + content: 文档:在用例编写范式中新增 Note 使用说明(典型场景 + 示例 + 何时必须用) + status: completed + - id: doc-hotimev15-sync + content: 同步修改 hotimev1.5 中的 Testing_API测试框架.md + status: completed + - id: skill-note-guide + content: SKILL.md:补充 Note 使用指引和更多场景示例 + status: completed +isProject: false +--- + +# 补充 Note 方法示例到文档和 Skill + +## 现状分析 + +目前 `Note` 方法在文档中的存在情况: + +- [链式 API 参考](d:\work\xbc\docs\Testing_API测试框架.md) 第 474、494 行:仅出现在 API 表格中,一句话描述 +- [API 速查表](d:\work\xbc\docs\Testing_API测试框架.md) 第 662-668 行:只有一个简短示例(`sign = MD5(...)`) +- [SKILL.md](C:\Users\92597.cursor\skills\hotime-tdd-testing\SKILL.md) 第 195-197 行:一行示例 + +**缺失**:"第二步:编写测试用例"(第 66-149 行)的完整订单示例中完全没有出现 `Note`,而这是新手学习的第一个入口。 + +## 需要修改的内容 + +### 1. 文档:第二步示例中加入 Note 使用 + +在 [Testing_API测试框架.md](d:\work\xbc\docs\Testing_API测试框架.md) 第 80-148 行的订单创建示例中,在**正确请求**部分加入 `Note` 调用,展示以下几种典型场景: + +- 在正确请求处用 `Note` 说明参数值含义(如 `quantity=3` 是故意的用于校验总价 `29.9*3=89.7`) + +### 2. 文档:用例编写范式中新增 Note 独立小节 + +在"用例编写范式"部分(第 219 行之后),在"错误用例"之前或之后,新增一个关于 Note 的说明段落或小节,覆盖以下典型使用场景: + +- **参数值参考**:说明某个参数值的来源或含义,如 `Note("status: 0=待付款, 1=已付款, 2=已发货, 3=已完成")` +- **算法说明**:简要描述接口涉及的签名/加密算法,如 `Note("sign = MD5(smsProxyKey+timestamp)")` +- **接口/用例说明**:补充 desc 无法涵盖的上下文,如 `Note("需先调用 /api/cart/add 加入购物车")` 或 `Note("此接口支持批量操作,最多100条")` +- 在用例编写范式表格中增加 Note 的定位说明 + +### 3. 文档:同步更新 hotimev1.5 中的同名文件 + +[hotimev1.5/docs/Testing_API测试框架.md](d:\work\hotimev1.5\docs\Testing_API测试框架.md) 需要做相同的修改。 + +### 4. Skill 文件:补充 Note 指引 + +在 [SKILL.md](C:\Users\92597.cursor\skills\hotime-tdd-testing\SKILL.md) 中: + +- 在 Phase 1 的 1.3 小节之后(或 1.2 和 1.3 之间),新增 Note 的使用指引 +- 说明何时应该使用 Note(有必要的场景),给出 2-3 个典型示例 +- 在 API 速查的"备注"部分补充更多场景示例 + +## 修改要点 + +- Note 不改变测试逻辑,只是给调试控制台和 api-spec.json 添加可读说明 +- Note 是可选的,但在以下场景**建议必须**使用: + - 参数中有枚举值(如 status、type)需要说明各值含义 + - 涉及签名/加密等算法的接口 + - 接口有前置依赖或特殊调用顺序 +- Note 可以放在链式调用的任意位置(`a.Note(...)` 或 `c.Note(...)`) + diff --git a/db/db.go b/db/db.go index 3a17638..2ec76a9 100644 --- a/db/db.go +++ b/db/db.go @@ -1,6 +1,7 @@ package db import ( + "bytes" "code.hoteas.com/golang/hotime/cache" . "code.hoteas.com/golang/hotime/common" "database/sql" @@ -33,8 +34,10 @@ type HoTimeDB struct { mu sync.RWMutex limitMu sync.Mutex Dialect Dialect // 数据库方言适配器 - testTx *sql.Tx // 测试事务:设置后所有操作都在此事务内,测试结束回滚 - testMu *sync.Mutex // 保护 testTx 单连接不被并发访问 + testTx *sql.Tx // 测试事务:设置后所有操作都在此事务内,测试结束回滚 + testMu *sync.Mutex // 保护 testTx 单连接不被并发访问 + testLogBuf *bytes.Buffer // 测试模式:SQL 日志写入此 buffer 而非直接打印,失败时才输出 + testLogBufMu sync.Mutex // 保护 testLogBuf 并发写入 } // SetConnect 设置数据库配置连接 @@ -151,6 +154,25 @@ func trimQuotes(s string) string { return s } +// SetTestLogBuffer 设置测试 SQL 日志缓冲区,非 nil 时 SQL 日志写入 buffer 而非直接打印 +func (that *HoTimeDB) SetTestLogBuffer(buf *bytes.Buffer) { + that.testLogBufMu.Lock() + that.testLogBuf = buf + that.testLogBufMu.Unlock() +} + +// FlushTestLog 取出并清空缓冲区中的 SQL 日志,返回累积的日志文本 +func (that *HoTimeDB) FlushTestLog() string { + that.testLogBufMu.Lock() + defer that.testLogBufMu.Unlock() + if that.testLogBuf == nil { + return "" + } + s := that.testLogBuf.String() + that.testLogBuf.Reset() + return s +} + // BeginTestTx 开启测试事务,设置后所有 Query/Exec/Action 都在此事务内执行 func (that *HoTimeDB) BeginTestTx() error { tx, err := that.DB.Begin() diff --git a/db/query.go b/db/query.go index fb1c2de..a321cb6 100644 --- a/db/query.go +++ b/db/query.go @@ -4,6 +4,7 @@ import ( "database/sql" "encoding/json" "errors" + "fmt" "math" "reflect" "strconv" @@ -39,8 +40,15 @@ func (that *HoTimeDB) queryWithRetry(query string, retried bool, args ...interfa defer func() { if that.Mode != 0 { that.mu.RLock() - that.Log.Info("SQL:"+that.LastQuery, " DATA:", that.LastData, " ERROR:", that.LastErr.GetError()) + msg := fmt.Sprintf("SQL:%s DATA:%v ERROR:%v", that.LastQuery, that.LastData, that.LastErr.GetError()) that.mu.RUnlock() + that.testLogBufMu.Lock() + if that.testLogBuf != nil { + that.testLogBuf.WriteString(msg + "\n") + } else { + that.Log.Info(msg) + } + that.testLogBufMu.Unlock() } }() @@ -117,8 +125,15 @@ func (that *HoTimeDB) execWithRetry(query string, retried bool, args ...interfac defer func() { if that.Mode != 0 { that.mu.RLock() - that.Log.Info("SQL: "+that.LastQuery, " DATA: ", that.LastData, " ERROR: ", that.LastErr.GetError()) + msg := fmt.Sprintf("SQL:%s DATA:%v ERROR:%v", that.LastQuery, that.LastData, that.LastErr.GetError()) that.mu.RUnlock() + that.testLogBufMu.Lock() + if that.testLogBuf != nil { + that.testLogBuf.WriteString(msg + "\n") + } else { + that.Log.Info(msg) + } + that.testLogBufMu.Unlock() } }() diff --git a/docs/Testing_API测试框架.md b/docs/Testing_API测试框架.md index c510080..a6ebd38 100644 --- a/docs/Testing_API测试框架.md +++ b/docs/Testing_API测试框架.md @@ -109,6 +109,7 @@ var OrderTest = CtrTest{ // ======== 第三步:正确请求 + Verify 统一校验 ======== a.WithSession(Map{"user_id": int64(1)}). + Note("quantity=3 用于校验总价: 29.9*3=89.7; 新订单 state=0(待付款)"). JSON(Map{"goods_id": goodsId, "quantity": 3, "address": "XX路1号"}). Verify(func(a *Api) error { // 响应值断言 @@ -234,6 +235,7 @@ Verify 统一校验(响应值断言 + 数据库状态校验,集中在一个 |------|------|------| | 错误用例 | **必须** | 覆盖权限、参数、业务逻辑等异常路径 | | 测试数据准备 | 按需 | 接口依赖的前置数据用 `a.DB()` 插入 | +| Note 备注 | 按需 | 用 `.Note(...)` 补充参数含义、枚举值说明、算法描述等上下文 | | 响应结构校验 | **必须** | 正确请求必须用第三参数校验 result 的类型和结构 | | Verify 校验 | **必须** | 在 Verify 内用 `a.Resp()` 断言响应值 + 用 `a.DB()` 校验数据库状态 | @@ -394,6 +396,55 @@ a.WithSession(Map{"user_id": int64(1)}). | 删除类(Delete) | 响应值断言 + 校验记录已删除/软删除 | | 纯查询(Select) | 响应值断言(无需 DB 校验) | +### 5. Note 备注 + +`Note` 用于为测试用例附加说明文字,备注会显示在 API 调试控制台的用例详情中,也会写入 `api-spec.json`。Note 不影响测试逻辑,只提升可读性。 + +**典型使用场景:** + +```go +// 场景一:枚举值/状态码说明 — 当参数或校验涉及枚举值时,备注各值含义 +a.WithSession(Map{"user_id": int64(1)}). + Note("status: 0=待付款, 1=已付款, 2=已发货, 3=已完成, 4=已取消"). + Query(Map{"status": "1"}). + Get("按状态查询订单", 0, Map{"total": int64(1), "data": Slice{Map{"id": int64(1)}}}) + +// 场景二:算法/签名说明 — 接口涉及加密或计算逻辑时,简述算法 +a.Note("sign = MD5(smsProxyKey + timestamp), 有效期5分钟"). + Form(Map{"phone": "13800138000", "sign": "abc123", "timestamp": "1700000000"}). + Post("发送验证码", 0, Map{"expire": int64(1)}) + +// 场景三:参数值含义 — 说明测试数据的选取理由或计算依据 +a.WithSession(Map{"user_id": int64(1)}). + Note("quantity=3, 单价29.9, 预期总价: 29.9*3=89.7"). + JSON(Map{"goods_id": goodsId, "quantity": 3}). + Post("创建订单", 0, Map{"id": int64(1), "total_price": float64(1.0)}) + +// 场景四:前置依赖/调用顺序说明 +a.WithSession(Map{"user_id": int64(1)}). + Note("需先调用 /api/cart/add 加入购物车, cart_id 来自上一步返回"). + JSON(Map{"cart_id": cartId}). + Post("购物车结算", 0, Map{"order_id": int64(1)}) + +// 场景五:接口特殊行为说明 +a.WithSession(Map{"admin_id": int64(1)}). + Note("批量操作,单次最多100条; 部分失败不回滚,返回逐条结果"). + JSON(Map{"ids": Slice{int64(1), int64(2), int64(3)}}). + Post("批量审核", 0, Map{"success": int64(1), "fail": int64(1)}) +``` + +**建议使用 Note 的场景:** + +| 场景 | 示例 | +|------|------| +| 参数含枚举值(status/type/state 等) | `Note("type: 1=个人, 2=企业")` | +| 涉及签名、加密、哈希等算法 | `Note("sign = MD5(appKey+timestamp+nonce)")` | +| 测试数据有特定计算依据 | `Note("quantity=3, price=29.9, 预期 total=89.7")` | +| 接口有调用顺序或前置依赖 | `Note("需先调用 /api/order/create 创建订单")` | +| 接口有特殊行为(批量/异步/幂等等) | `Note("幂等接口,重复调用返回相同结果")` | + +> Note 是可选的,但在涉及枚举值、算法、前置依赖等场景时建议使用。好的备注能让其他开发者快速理解用例意图,也让调试控制台成为自文档化的 API 手册。 + ### 简写支持说明 框架也支持以下简写用法: diff --git a/testing_api.go b/testing_api.go index ef5ee4f..4df5981 100644 --- a/testing_api.go +++ b/testing_api.go @@ -316,6 +316,14 @@ func (c *ApiCase) execute(method, desc string, expectStatus int, expectArgs ...i } } + if !passed { + if sqlLog := c.api.app.Db.FlushTestLog(); sqlLog != "" { + t.Logf("SQL 日志:\n%s", sqlLog) + } + } else { + c.api.app.Db.FlushTestLog() + } + record := TestRecord{ Path: c.api.path, Desc: desc, diff --git a/testing_helper.go b/testing_helper.go index 5b5c0f7..4573961 100644 --- a/testing_helper.go +++ b/testing_helper.go @@ -1,9 +1,12 @@ package hotime import ( + "bytes" "fmt" + "io" "net/http" "net/http/httptest" + "os" "strings" "sync" "testing" @@ -91,8 +94,22 @@ func (c *TestCollector) MarkVisited(path string) { // projects: 项目定义(路由 + 测试) // listeners: 可选的请求拦截器(与 SetConnectListener 相同) func NewTestApp(configPath string, projects TestProj, listeners ...func(*Context) bool) *TestApp { + origStdout := os.Stdout + origStderr := os.Stderr + devNull, _ := os.Open(os.DevNull) + os.Stdout = devNull + os.Stderr = devNull + app := Init(configPath) + os.Stdout = origStdout + os.Stderr = origStderr + devNull.Close() + + app.Log.SetOutput(io.Discard) + origMode := app.Db.Mode + app.Db.Mode = 0 + for _, lis := range listeners { app.SetConnectListener(lis) } @@ -107,6 +124,9 @@ func NewTestApp(configPath string, projects TestProj, listeners ...func(*Context app.HoTimeCache.DisableDbCache() } + app.Db.Mode = origMode + app.Log.SetOutput(os.Stderr) + return &TestApp{ Application: app, projs: projects, @@ -193,6 +213,10 @@ func (that *TestApp) RunTests(t *testing.T) { } defer that.Db.RollbackTestTx() + methodBuf := &bytes.Buffer{} + that.Db.SetTestLogBuffer(methodBuf) + defer that.Db.SetTestLogBuffer(nil) + api := &Api{ app: that, path: path, @@ -219,6 +243,7 @@ func (that *TestApp) PrintCoverage() CoverageReport { for _, r := range that.collector.Records { pathRecords[r.Path] = append(pathRecords[r.Path], r) } + visited := that.collector.Visited that.collector.mu.Unlock() for projName, projDef := range that.projs { @@ -258,6 +283,9 @@ func (that *TestApp) PrintCoverage() CoverageReport { } } + ranCount := len(visited) + isPartialRun := ranCount > 0 && ranCount < report.Covered + fmt.Println("\n========== API 测试覆盖率报告 ==========") if report.Total > 0 { fmt.Printf("总接口: %d | 已覆盖: %d | 覆盖率: %.1f%%\n", @@ -267,18 +295,32 @@ func (that *TestApp) PrintCoverage() CoverageReport { fmt.Println("总接口: 0") } - fmt.Println("\n已覆盖的接口:") - for _, d := range report.Details { - if d.HasTest && d.CaseCount > 0 { - fmt.Printf(" + %s\t%d 用例 (%d 通过, %d 失败) %v\n", - d.Path, d.CaseCount, d.Passed, d.Failed, d.Duration) - } else if d.HasTest { - fmt.Printf(" + %s\t(已定义, 未运行)\n", d.Path) + if !isPartialRun { + var ranDetails []MethodCoverage + for _, d := range report.Details { + if d.HasTest && d.CaseCount > 0 { + ranDetails = append(ranDetails, d) + } + } + if len(ranDetails) > 0 { + fmt.Println("\n本次运行:") + for _, d := range ranDetails { + fmt.Printf(" + %s\t%d 用例 (%d 通过, %d 失败) %v\n", + d.Path, d.CaseCount, d.Passed, d.Failed, d.Duration) + } + } + } else { + fmt.Printf("\n本次运行 %d 个接口:\n", ranCount) + for _, d := range report.Details { + if d.HasTest && d.CaseCount > 0 { + fmt.Printf(" + %s\t%d 用例 (%d 通过, %d 失败) %v\n", + d.Path, d.CaseCount, d.Passed, d.Failed, d.Duration) + } } } if len(report.Missing) > 0 { - fmt.Println("\n未覆盖的接口:") + fmt.Printf("\n未覆盖的接口: (%d 个)\n", len(report.Missing)) for _, path := range report.Missing { fmt.Println(" -", path) } diff --git a/testing_swagger.go b/testing_swagger.go index 45e4986..57d4138 100644 --- a/testing_swagger.go +++ b/testing_swagger.go @@ -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] = ¶mInfo{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] = ¶mInfo{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] = ¶mInfo{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}