feat(tests): 增强测试框架的 SQL 日志记录与覆盖率报告
- 在 ApiCase 中添加 SQL 日志记录功能,失败时输出日志以便于调试 - 更新 TestApp 以支持测试模式下的 SQL 日志缓冲,成功与失败的测试均可记录日志 - 改进 PrintCoverage 方法,优化覆盖率报告的输出逻辑,支持部分运行的接口显示 - 增加 Note 备注功能,提升测试用例的可读性与文档化效果 - 更新相关文档,详细说明新功能的使用场景与示例
This commit is contained in:
@@ -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 目录中。
|
||||
@@ -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(...)`)
|
||||
|
||||
@@ -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()
|
||||
|
||||
+17
-2
@@ -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()
|
||||
}
|
||||
}()
|
||||
|
||||
|
||||
@@ -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 手册。
|
||||
|
||||
### 简写支持说明
|
||||
|
||||
框架也支持以下简写用法:
|
||||
|
||||
@@ -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,
|
||||
|
||||
+50
-8
@@ -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)
|
||||
}
|
||||
|
||||
+92
-16
@@ -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}
|
||||
</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':'')+'">▶</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()">×</button></div></div></div>';
|
||||
l+='<div class="bd-hd'+(defBody==='json'?' on':'')+'" data-t="json" onclick="togBody(\'json\')"><span class="ar'+(defBody==='json'?' o':'')+'">▶</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':'')+'">▶</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):'{"key":"value"}')+'"></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);
|
||||
|
||||
Reference in New Issue
Block a user