Files
hotime/docs/Testing_API测试框架.md
T
hoteas 8a1a27b457 feat(testing): Listener 链式入口并精简测试文档运行指引
将 connect listener 并入 Test 链、删除 NewTestApp,正文只示范 -run 单测,全量命令仅保留在文末 CI 节。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-13 19:56:36 +08:00

1036 lines
45 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# HoTime API 测试框架使用说明
不启动 HTTP 服务、自动事务回滚、链式 API、覆盖率报告、API 调试控制台生成。
## 目录
- [概述](#概述)
- [快速开始](#快速开始)
- [运行测试](#运行测试)
- [用例编写范式](#用例编写范式)
- [核心类型](#核心类型)
- [业务流程联调(Flows](#业务流程联调flows)
- [优雅停测](#优雅停测)
- [链式 API 参考](#链式-api-参考)
- [事务隔离机制](#事务隔离机制)
- [API 调试控制台](#api-调试控制台)
- [并发保护与缓存隔离](#并发保护与缓存隔离)
- [二进制与非 JSON 响应校验](#二进制与非-json-响应校验)
- [全量回归与覆盖率](#全量回归与覆盖率)
- [已知局限与后续](#已知局限与后续)
---
## 概述
HoTime 内置 API 测试框架,核心能力:
| 能力 | 说明 |
| ------------- | --------------------------------------------------------------------- |
| **零启动** | 基于 `net/http/httptest`,不需要启动 HTTP 服务器 |
| **事务回滚** | 每个接口方法独立开启数据库事务,测试结束自动回滚,数据库不留任何痕迹 |
| **SAVEPOINT** | 业务代码中的 `that.Db.Action()` 在测试模式下自动用 SAVEPOINT 替代真事务,嵌套事务完整可用 |
| **并发保护** | `testMu` 互斥锁自动保护 `testTx` 单连接,业务代码中的 `go func()` 协程不会导致 `busy buffer` |
| **缓存隔离** | 测试启动时自动禁用 DB/Redis 缓存,避免缓存操作与测试事务锁冲突 |
| **链式 API** | `a.JSON(...).Post("描述", 期望status)` 一行完成:数据组装 + 请求 + 断言 |
| **Flows** | 多接口业务流程联调;`Group`/`Desc`/`Prep``FromCase` 绑定单接口用例并回写 |
| **覆盖率** | 自动对比路由注册表与测试定义(**不含 Flow 步骤**,只统计单接口 path) |
| **调试控制台** | 增量写入 swagger;流程分组 UI;发送结果 cURL 实时同步左侧参数 |
| **优雅停测** | SIGINT/SIGTERM / `RequestStop`:停收新测、跑完当前、回滚事务、保留已落盘文档 |
---
## 快速开始
本节通过一个完整的"创建订单"接口,展示从业务代码到测试用例的全流程。
### 第一步:业务代码
假设有一个"创建订单"接口 `POST /api/order/create`,业务逻辑如下:
```go
// app/order.go — 业务代码(简化示意)
var OrderCtr = Ctr{
"create": func(that *Context) {
// 1. 权限校验:未登录 → Display(2, "请先登录")
// 2. 参数校验:goods_id/quantity/address 缺失 → Display(3, "请选择商品"/"请填写购买数量"/"请填写收货地址")
// 3. 业务校验:商品不存在 → Display(4, "商品不存在")
// 4. 正常逻辑:计算总价、生成订单号、插入 order 表、扣减 goods 库存
// 5. 返回:Display(0, Map{"id": orderId, "sn": sn, "total_price": totalPrice})
},
}
```
### 第二步:编写测试用例
**推荐方式**:为每个 handler 创建对应的 `_test.go` 文件,测试定义与业务代码分离。
```go
// app/order_test.go — 测试定义
package app
import (
"fmt"
. "code.hoteas.com/golang/hotime"
. "code.hoteas.com/golang/hotime/common"
)
var OrderTest = CtrTest{
"create": {Desc: "创建订单", Func: func(a *Api) {
// ======== 第一步:错误用例(先行) ========
// 错误码是复用的,三个参数必须填满:desc, status, msg
a.JSON(Map{"goods_id": int64(1), "quantity": 3, "address": "XX路1号"}).
Post("未登录创建订单", 2, "请先登录")
a.WithSession(Map{"user_id": int64(1)}).
JSON(Map{"quantity": 3, "address": "XX路1号"}).
Post("缺少商品ID", 3, "请选择商品")
a.WithSession(Map{"user_id": int64(1)}).
JSON(Map{"goods_id": int64(1), "address": "XX路1号"}).
Post("缺少数量", 3, "请填写购买数量")
a.WithSession(Map{"user_id": int64(1)}).
JSON(Map{"goods_id": int64(1), "quantity": 3}).
Post("缺少收货地址", 3, "请填写收货地址")
a.WithSession(Map{"user_id": int64(1)}).
JSON(Map{"goods_id": int64(999), "quantity": 3, "address": "XX路1号"}).
Post("商品不存在", 4, "商品不存在")
// ======== 第二步:准备测试数据 ========
// 插入商品数据,既作为正确请求的依赖,也作为后续 DB 校验的对照源
goodsId := a.DB().Insert("goods", Map{
"name": "测试商品", "price": 29.9, "stock": 100, "state": 1,
})
// ======== 第三步:正确请求 + 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 {
// 响应值断言
result := a.Resp().GetBody().GetMap("result")
if result.GetCeilInt64("id") <= 0 {
return fmt.Errorf("返回的订单 ID 必须大于 0")
}
if result.GetString("sn") == "" {
return fmt.Errorf("返回的订单编号 sn 不能为空")
}
// 数据库状态校验
row := a.DB().Get("order", "*", Map{"AND": Map{
"user_id": int64(1), "goods_id": goodsId,
}})
if row == nil {
return fmt.Errorf("order 表未写入订单记录")
}
if row.GetCeilInt64("state") != 0 {
return fmt.Errorf("新订单 state 期望 0, 实际 %d", row.GetCeilInt64("state"))
}
if row.GetCeilFloat64("total_price") != 29.9*3 {
return fmt.Errorf("total_price 期望 %.2f, 实际 %.2f",
29.9*3, row.GetCeilFloat64("total_price"))
}
// 校验库存扣减
goods := a.DB().Get("goods", "stock", Map{"id": goodsId})
if goods.GetCeilInt64("stock") != 97 {
return fmt.Errorf("库存期望 97, 实际 %d", goods.GetCeilInt64("stock"))
}
return nil
}).
Post("正常创建订单", 0, Map{
"id": int64(1), "sn": "sample", "total_price": float64(1.0),
})
}},
}
```
> **为什么要分离?** `_test.go` 文件仅在 `go test` 时编译,不会进入生产二进制,保持业务代码干净。同时符合 Go 语言惯例:测试相关代码放在 `_test.go` 文件中。
### 第三步:注册路由和测试
路由在 `init.go` 中注册,测试注册放在 `init_test.go` 中(因为 `_test.go` 中的变量只在测试编译时可见):
```go
// app/init.go — 只注册路由
var Project = Proj{
"order": OrderCtr,
// ...
}
```
```go
// app/init_test.go — 注册测试 + 运行入口
package app
import (
"os"
"testing"
. "code.hoteas.com/golang/hotime"
)
var ProjectTest = ProjTest{
"order": OrderTest,
}
var testApp *TestApp
func TestMain(m *testing.M) {
testApp = Test("config/config.json").
WorkDir("..").
Listener(func(ctx *Context) bool {
return false // 空实现:继续进控制器;需要鉴权/拦截时在此写
}).
Swagger("My API", "1.0.0").
Proj(TestProj{
"app": {Proj: Project, Tests: ProjectTest},
}).
Flows(FlowTest{
// "checkout": {Group: "order", Desc: "下单支付跑通", Func: checkoutFlow},
})
os.Exit(testApp.Run(m))
}
func TestApi(t *testing.T) {
testApp.RunTests(t)
}
```
`Run(m)` 内部会:注册优雅停测信号 → `m.Run()` → 覆盖率 → Swagger 收尾(接口级增量写入)。
`WorkDir` / `Listener` / `Swagger` / `Flows` 均可省略;不写 `Swagger` 则不生成调试控制台。example 推荐把整条链写全便于对照。
### 推荐目录结构
```
app/
├── order.go ← 业务代码 (OrderCtr)
├── order_test.go ← 测试定义 (OrderTest)
├── user.go ← 业务代码 (UserCtr)
├── user_test.go ← 测试定义 (UserTest)
├── init.go ← 路由注册 (Project)
└── init_test.go ← 测试注册 (ProjectTest) + TestMain + TestApi
```
### 运行测试
`RunTests` 使用 Go 标准 `t.Run()` 子测试,用 `-run` 精确到单接口 / 单控制器 / 单用例:
```bash
# 单接口
go test ./app/ -v -run TestApi/app/order/create
# 单控制器
go test ./app/ -v -run TestApi/app/order
# 单用例
go test ./app/ -v -run "TestApi/app/order/create/正常创建订单"
# 多控制器(正则)
go test ./app/ -v -run "TestApi/app/(user|order)"
# 按用例名收窄(如所有「未登录」)
go test ./app/ -v -run "TestApi/.*/.*未登录"
```
> **注意**`WorkDir("..")` 在 Init 前切换工作目录,确保上述 `-run` 命令下配置与模板输出路径正确。
---
## 用例编写范式
每个接口的测试用例应按照以下流程编写:
```
错误用例(参数校验、权限校验、业务校验)
准备测试数据(a.DB().Insert / Update 模拟前置数据)
正确请求 + 响应结构校验(第三参数自动校验 result 的类型和结构)
Verify 统一校验(响应值断言 + 数据库状态校验,集中在一个回调中完成)
```
| 环节 | 要求 | 说明 |
| --------- | ------ | ------------------------------------------------- |
| 错误用例 | **必须** | 覆盖权限、参数、业务逻辑等异常路径 |
| 测试数据准备 | 按需 | 接口依赖的前置数据用 `a.DB()` 插入 |
| Note 备注 | 按需 | 用 `.Note(...)` 补充参数含义、枚举值说明、算法描述等上下文 |
| 响应结构校验 | **必须** | 正确请求必须用第三参数校验 result 的类型和结构 |
| Verify 校验 | **必须** | 在 Verify 内用 `a.Resp()` 断言响应值 + 用 `a.DB()` 校验数据库状态 |
### 1. 错误用例编写规范
**错误用例必须写在最前面**,在正确请求之前,按接口错误码顺序依次覆盖:权限校验(status 1/2)→ 参数校验(status 3)→ 业务校验(status 4)。
```go
a.Post("未登录访问", 2, "请先登录")
a.JSON(Map{"quantity": 3}).Post("缺少商品ID", 3, "请选择商品")
a.JSON(Map{"goods_id": int64(1)}).Post("缺少数量", 3, "请填写购买数量")
a.JSON(Map{"goods_id": int64(999)}).Post("商品不存在", 4, "商品不存在")
```
### 2. 测试数据准备
正确请求通常依赖前置数据(如创建订单需要先有商品)。用 `a.DB()` 直接操作数据库插入测试数据:
```go
// 插入前置数据
goodsId := a.DB().Insert("goods", Map{
"name": "测试商品", "price": 29.9, "stock": 100, "state": 1,
})
// 后续正确请求使用 goodsId
resp := a.WithSession(Map{"user_id": int64(1)}).
JSON(Map{"goods_id": goodsId, "quantity": 3, "address": "XX路1号"}).
Post("正常创建订单", 0, Map{"id": int64(1), "sn": "sample"})
```
**要点**
- `a.DB()` 返回的是测试事务内的数据库实例,插入的数据对同一方法内的所有后续用例可见
- 测试结束后自动回滚,无需手动清理
- 插入的数据同时可作为 Verify 校验的**对照源**(如校验库存从 100 扣减到 97)
### 3. 正确请求与响应结构校验
第三参数作为**类型样本**,框架自动校验 result 的类型是否匹配,**不比较具体值**。第三参数支持所有 JSON 兼容类型:
| 样本写法 | 校验效果 |
| ---------------------------- | --------------------- |
| `int64(1)` | result 是整数类型 |
| `float64(1.0)` | result 是浮点类型 |
| `"sample"` | result 是字符串类型 |
| `true` | result 是布尔类型 |
| `Map{"id": int64(1), ...}` | result 是对象,递归校验字段名+类型 |
| `Slice{Map{"id": int64(1)}}` | result 是数组,校验首元素结构 |
```go
// result 是 Map 时:值只是类型样本,不比较具体值
a.Post("正常创建", 0, Map{
"id": int64(1), "sn": "sample", "total_price": float64(1.0),
})
// result 是原始类型时:直接传对应类型的样本值
a.Get("获取数量", 0, int64(1)) // 校验 result 是整数
a.Get("操作结果", 0, "sample") // 校验 result 是字符串
a.Get("获取金额", 0, float64(1.0)) // 校验 result 是浮点数
a.Get("是否可用", 0, true) // 校验 result 是布尔
// result 直接是数组时
a.Get("获取标签", 0, Slice{Map{"id": int64(1), "name": "sample"}})
// 嵌套对象:递归校验子字段
a.Get("获取详情", 0, Map{
"id": int64(1),
"customer": Map{"id": int64(1), "name": "sample", "phone": "sample"},
})
// Map 中包含数组字段(校验首元素结构)
a.Post("获取列表", 0, Map{
"total": int64(1),
"data": Slice{Map{"id": int64(1), "name": "sample", "price": float64(1.0)}},
})
```
> 结构校验只保证返回格式不变(字段名存在、类型匹配),具体值的校验交给 Verify。非 JSON 响应(二进制文件、纯文本等)不适用结构校验,省略 expect 参数,在 Verify 中通过 `GetRawBody()` 校验(详见 [二进制与非 JSON 响应校验](#二进制与非-json-响应校验))。
### 4. Verify 统一校验
Verify 是正确请求的**核心校验环节**,在一个回调中统一完成**响应值断言**和**数据库状态校验**:
- 通过 `a.Resp()` 获取当前请求的响应,对关键业务字段断言具体值
- 通过 `a.DB()` 查库校验数据库状态变更
- 返回 `nil` 表示通过,返回 `error` 则用例失败
```go
a.WithSession(Map{"user_id": int64(1)}).
JSON(Map{"goods_id": goodsId, "quantity": 3, "address": "XX路1号"}).
Verify(func(a *Api) error {
// ---- 响应值断言 ----
result := a.Resp().GetBody().GetMap("result")
if result.GetCeilInt64("id") <= 0 {
return fmt.Errorf("返回的订单 ID 必须大于 0")
}
if result.GetString("sn") == "" {
return fmt.Errorf("返回的订单编号 sn 不能为空")
}
// ---- 数据库状态校验 ----
// 校验主表:订单是否正确写入
row := a.DB().Get("order", "*", Map{"AND": Map{
"user_id": int64(1), "goods_id": goodsId,
}})
if row == nil {
return fmt.Errorf("order 表未写入订单记录")
}
if row.GetCeilInt64("state") != 0 {
return fmt.Errorf("新订单 state 期望 0, 实际 %d", row.GetCeilInt64("state"))
}
if row.GetCeilFloat64("total_price") != 29.9*3 {
return fmt.Errorf("total_price 期望 %.2f, 实际 %.2f",
29.9*3, row.GetCeilFloat64("total_price"))
}
// 校验关联影响:库存是否扣减
goods := a.DB().Get("goods", "stock", Map{"id": goodsId})
if goods.GetCeilInt64("stock") != 97 {
return fmt.Errorf("库存期望 97, 实际 %d", goods.GetCeilInt64("stock"))
}
// 校验关联表:订单明细是否生成
count := a.DB().Count("order_goods", Map{"order_id": row.GetCeilInt64("id")})
if count < 1 {
return fmt.Errorf("order_goods 期望至少 1 条, 实际 %d 条", count)
}
return nil
}).
Post("正常创建订单", 0, Map{"id": int64(1), "sn": "sample", "total_price": float64(1.0)})
```
**Verify 内可用的方法:**
| 方法 | 说明 |
| ------------------------------------- | ---------------------- |
| `a.Resp()` | 获取当前请求的响应,用于值断言 |
| `a.Resp().GetBody()` | 获取完整响应体(Map) |
| `a.Resp().GetBody().GetMap("result")` | 获取 result 对象 |
| `a.DB()` | 获取数据库实例(在测试事务内),用于查库校验 |
**Verify 校验要点:**
| 校验对象 | 说明 | 示例 |
| ----- | ---------------------- | -------------------------------------------------------- |
| 响应关键值 | 业务字段的具体值(ID > 0、编号非空等) | `a.Resp().GetBody().GetMap("result").GetCeilInt64("id")` |
| 主表记录 | 核心数据是否写入、字段值是否正确 | `a.DB().Get("order", ...)` |
| 关联表 | 关联数据是否同步生成 | `a.DB().Count("order_goods", ...)` |
| 副作用 | 库存扣减、余额变化、状态流转 | `goods.GetCeilInt64("stock") != 97` |
**适用场景对照:**
| 接口类型 | Verify 内容 |
| ----------- | --------------------- |
| 创建类(Insert) | 响应值断言 + 校验记录写入 + 关联表 |
| 更新类(Update) | 响应值断言 + 校验字段变更 + 状态流转 |
| 删除类(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 手册。
### 简写支持说明
框架也支持以下简写用法:
**省略错误消息(第三参数)**`a.Post("请先登录", 2)` — 当 `status != 0` 且未传第三参数时,框架自动用 `desc`(第一参数)作为期望的错误消息。适合 desc 与实际 msg 完全一致的场景。
**省略 result 结构校验(expect**`a.Post("创建成功", 0)` — 仅断言 `status=0`,不校验 result 的内容和结构。适合无需关注返回结构的场景,也适用于非 JSON 响应(二进制文件、纯文本等),此时在 Verify 中通过 `GetRawBody()` 做内容校验。
**省略 Verify**:不设置 Verify 回调时,不会执行响应值断言和数据库状态校验。适合纯查询、无副作用的接口。
> 对于涉及数据写入、状态变更、副作用的接口,测试不充分等于充分不测试。
---
## 核心类型
### 注册类型
```go
// 顶层:多项目集合(项目名 → 定义)
type TestProj map[string]TestProjDef
// 单项目:路由 + 测试绑定在一起
type TestProjDef struct {
Proj Proj // 路由定义(与 Run 传入的 Router 保持一致)
Tests ProjTest // 测试定义
}
// 控制器级测试(控制器名 → 控制器测试集合)
type ProjTest map[string]CtrTest
// 方法级测试(方法名 → 单条用例定义)
type CtrTest map[string]ApiTestDef
// 单个接口的用例定义
type ApiTestDef struct {
Desc string // 接口描述,用于 Swagger 说明和 t.Run 层级名称
Func func(a *Api) // 测试函数体
}
```
### TestApp
```go
// Test 链式入口(延迟 Init,便于先 WorkDir
func Test(configPath string) *TestApp
func (app *TestApp) WorkDir(dir string) *TestApp
func (app *TestApp) Listener(fns ...func(*Context) bool) *TestApp // 可选:连接监听,可多次
func (app *TestApp) Swagger(args ...string) *TestApp // title / version / outputDir 均可选
func (app *TestApp) Proj(projects TestProj) *TestApp
func (app *TestApp) Flows(flows FlowTest) *TestApp // 可选:多接口业务流程
func (app *TestApp) Run(m *testing.M) int // 信号停测 + m.Run + 覆盖率 + Swagger 收尾
func (app *TestApp) RunTests(t *testing.T)
func (app *TestApp) PrintCoverage() CoverageReport
func (app *TestApp) GenerateSwagger(title, version, outputDir string) error
func (app *TestApp) DB() *HoTimeDB
```
### 业务流程联调(Flows
与单接口 `CtrTest` 并列,用于多接口串起来的业务跑通。整条 Flow 共用一次测试事务,结束回滚。
**FlowDef 字段**
| 字段 | 说明 |
| ------- | ------------------------- |
| `Group` | 控制台侧栏分组(如 `order` |
| `Desc` | 流程短标题 |
| `Prep` | 业务说明 / 前置与数据准备(控制台「业务说明」) |
| `Func` | `func(f *Flow)` |
**Flow API**
| 方法 | 说明 |
| --------------------- | ------------------------- |
| `f.Step(name, path)` | 新步骤,返回 `*Api`(记录归入 flows |
| `f.AtPath(path)` | 同流程内换 path,不增步骤序号 |
| `f.WithSession(s)` | 后续步骤携带 session |
| `f.DB()` / `f.Resp()` | 流程事务库 / 最近一步响应 |
```go
Flows(FlowTest{
"checkout": {
Group: "order",
Desc: "下单支付跑通",
Prep: "先创建订单再支付;步骤可用 FromCase 绑定单接口验收用例。",
Func: func(f *Flow) {
f.Step("创建订单", "/app/order/create").
FromCase("正常创建").
Note("创建后取 order id 供支付步骤使用").
WithSession(Map{"admin_id": 1}).
Post("创建", 0, Map{"id": int64(1)})
orderID := f.Resp().GetBody().GetMap("result").GetCeilInt64("id")
f.Step("支付", "/app/order/pay").
WithSession(Map{"admin_id": 1}).
JSON(Map{"order_id": orderID}).
Post("支付成功", 0)
},
},
})
```
**FromCase**
- 按 path + 用例名加载请求模板:优先本轮已跑的单接口记录,否则读已有 `api-spec.json`
- 步骤跑完后回写该单接口 case 的 response / passed
- 无模板时当前为**静默不填**(见 [已知局限](#已知局限与后续));建议先用 `-run` 跑绑定的单接口再跑 flow
- 示例:`example/app/flow_test.go`
控制台:侧栏「业务流程 → group → flow 名」;步骤左栏含关联验收用例 / 备注 / 请求,右栏响应;点 path 弹窗看接口只读详情。
`-run``go test ./app/ -run "TestApi/flows/checkout" -v`
### 优雅停测
`Run(m)` 内监听 SIGINT/SIGTERM;测试代码可调 `testApp.RequestStop()` 模拟。
行为:停收后续接口/流程 → 当前子测跑完 → 回滚测试事务 → 已 flush 的 swagger **保留**(不全量 prune)。
示例:`example/app/app_test.go``TestInterruptStop`
---
## 链式 API 参考
测试函数接收 `*Api` 作为入口,调用数据设置方法返回 `*ApiCase`,再调用终端方法发出请求并断言。
### Api — 测试入口
| 方法 | 说明 | 返回 |
| ----------------------------------- | ------------------------------------------------- | -------------- |
| `a.JSON(body)` | 设置 JSON body | `*ApiCase` |
| `a.Query(params)` | 设置 URL 查询参数 | `*ApiCase` |
| `a.Form(body)` | 设置 form 表单 body | `*ApiCase` |
| `a.File(field, name, content)` | 设置上传文件 | `*ApiCase` |
| `a.Note(note)` | 为下一条用例设置备注,备注显示在调试控制台 | `*ApiCase` |
| `a.Verify(fn)` | 设置请求后的校验函数(等同于先创建 ApiCase 再 Verify,适合纯 GET 无数据场景) | `*ApiCase` |
| `a.FromCase(name)` | 绑定单接口验收用例:加载其请求模板;跑通后回写 response/passed | `*ApiCase` |
| `a.WithSession(s)` | 返回携带 session 的新 Api | `*Api` |
| `a.RunSub(desc, fn)` | 接口 Func 内再分子场景,便于 `-run` 收窄 | — |
| `a.Get(desc, status, expect...)` | 直接 GET 请求(无数据场景) | `*ApiResponse` |
| `a.Post(desc, status, expect...)` | 直接 POST 请求(无数据场景) | `*ApiResponse` |
| `a.Put(desc, status, expect...)` | 直接 PUT 请求 | `*ApiResponse` |
| `a.Delete(desc, status, expect...)` | 直接 DELETE 请求 | `*ApiResponse` |
| `a.DB()` | 获取数据库实例(在事务内,可用于准备/校验数据) | `*HoTimeDB` |
| `a.Resp()` | 获取最近一次请求的响应(在 Verify 回调中用于值断言) | `*ApiResponse` |
### ApiCase — 链式构建器
`Api` 的数据设置方法返回 `*ApiCase`,支持继续叠加数据:
| 方法 | 说明 | 返回 |
| ----------------------------------- | ----------------------------------------------------------------------------------- | -------------- |
| `c.JSON(body)` | 追加 JSON body | `*ApiCase` |
| `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.FromCase(name)` | 绑定单接口验收用例名并加载请求模板(若尚未加载) | `*ApiCase` |
| `c.Verify(fn)` | 设置请求后的统一校验函数,可用 `a.Resp()` 断言响应值 + `a.DB()` 校验数据库(详见 [Verify 统一校验](#4-verify-统一校验) | `*ApiCase` |
| `c.Get(desc, status, expect...)` | 终端:发送 GET 请求并断言 | `*ApiResponse` |
| `c.Post(desc, status, expect...)` | 终端:发送 POST 请求并断言 | `*ApiResponse` |
| `c.Put(desc, status, expect...)` | 终端:发送 PUT 请求并断言 | `*ApiResponse` |
| `c.Delete(desc, status, expect...)` | 终端:发送 DELETE 请求并断言 | `*ApiResponse` |
### 终端方法参数说明
| 参数 | 类型 | 说明 |
| -------- | -------------- | -------------------------------- |
| `desc` | string | 用例描述,作为 `t.Run` 的子测试名称 |
| `status` | int | 期望的 JSON 响应体中 `status` 字段值(0=成功) |
| `expect` | ...interface{} | 可选,行为取决于 status(见下方说明) |
**expect 参数解析规则:**
- `status == 0`(成功):
- **不传 expect** → 仅校验 status=0,不校验 result 内容
- 传任意类型 → result 类型/结构校验(因为成功响应没有 msg)
- `status != 0`(错误):
-`string` → 用该字符串校验错误消息
- **不传 expect** → 自动用 `desc`(第一参数)作为期望的错误消息(简写模式)
- 传非 string 类型 → 按 status=0 的规则做 result 结构校验(罕见但支持)
> **编写建议**:错误用例建议始终填满三个参数 `Post("场景描述", status, "具体错误消息")`,因为错误码是复用的,省略第三参数会降低可读性。
**result 类型/结构校验:**
expect 参数作为**类型样本**,只比较类型大类,不比较具体值。result 不一定是 Map,可以是任意类型:
| 样本值类型 | 匹配的实际类型 | 说明 |
| --------------------- | -------------------------------- | ------------- |
| `int64` / `int` 等整数 | 整数类型 | number(整数) |
| `float64` / `float32` | 浮点类型 | float(小数) |
| `string` | `string` | 字符串 |
| `bool` | `bool` | 布尔值 |
| `Map{...}` | `Map` / `map[string]interface{}` | 对象,递归校验子字段 |
| `Slice{...}` | `Slice` / `[]interface{}` | 数组,非空时校验首元素结构 |
校验规则:
- result 是原始类型(string、int、float 等)→ 直接比较类型大类
- result 是 Map → **多了**字段不管,**少了**预设字段报错「字段缺失」
- 字段存在但**类型不一致** → 报错「类型不匹配」
- Map 类型的值 → 递归验证子字段
- Slice 类型的值且样本非空 → 取实际首元素与样本首元素递归验证
### ApiResponse — 响应对象
终端方法返回 `*ApiResponse`,同时也可以在 Verify 回调中通过 `a.Resp()` 获取。
在 Verify 内通过 `a.Resp()` 做值断言(与 DB 校验集中在一处):
```go
a.JSON(Map{"name": phone, "password": "123456"}).
Verify(func(a *Api) error {
result := a.Resp().GetBody().GetMap("result")
if result.GetString("token") == "" {
return fmt.Errorf("登录成功但缺少 token")
}
return nil
}).
Post("正常登录", 0, Map{"token": "sample", "user_id": int64(1)})
```
也可以通过返回值在外部断言(适合不需要 DB 校验的简单场景):
```go
resp := a.Get("获取配置", 0)
resp.ExpectResult(Map{"version": "sample", "features": Slice{}})
```
**ApiResponse 可用方法:**
| 方法 | 返回类型 | 说明 |
| --------------------------- | -------------- | ------------------------------------------------------- |
| `resp.GetStatus()` | `int` | 业务 status |
| `resp.GetMsg()` | `string` | 业务 msg |
| `resp.GetResult()` | `interface{}` | result 字段原始值 |
| `resp.GetBody()` | `Map` | 完整 JSON 响应体,可链式 `GetString("key")``GetMap("result")` 等 |
| `resp.GetRawBody()` | `[]byte` | 原始响应字节(非 JSON 响应如文件下载、二进制流) |
| `resp.RawBody` | `[]byte` | 同上,公开字段直接访问 |
| `resp.ExpectResult(sample)` | `*ApiResponse` | 后置结构校验(类型样本) |
| `resp.Fail(msg)` | — | 手动标记测试失败 |
### 常用写法速查
```go
// POST JSON / Form / GET / PUT / DELETE
a.JSON(Map{"name": "test"}).Post("描述", 0, Map{...})
a.Form(Map{"shop_id": "1"}).Post("表单提交", 0, Map{...})
a.Query(Map{"page": "1"}).Get("查询列表", 0, Map{...})
a.Get("无参 GET", 0, Map{...})
a.JSON(Map{"name": "新名字"}).Put("更新", 0, Map{...})
a.Query(Map{"id": "5"}).Delete("删除", 0, "操作成功")
// 登录态
a.WithSession(Map{"user_id": int64(1)}).JSON(Map{...}).Post("更新", 0, Map{...})
// 混合参数 / 上传
a.Query(Map{"token": "abc"}).JSON(Map{"phone": "138"}).Post("混合传参", 0, Map{...})
a.File("file", "photo.png", imgBytes).Form(Map{"type": "avatar"}).Post("上传头像", 0, Map{...})
// Verify + 备注
a.Note("sign = MD5(key+timestamp)").
Form(Map{"phone": "138"}).
Verify(func(a *Api) error { /* a.Resp() / a.DB() */ return nil }).
Post("正常发送", 0, Map{...})
```
---
## 事务隔离机制
### 工作原理
`RunTests` 在每个**方法级**测试前开启一个数据库事务(`testTx`),测试结束后无论成功与否一律回滚:
```
TestApi
└── app(项目名)
└── order(控制器名)
└── create(方法名)← 在这一级 BEGIN → 运行测试用例 → ROLLBACK
└── 未登录创建订单(用例)
└── 缺少商品ID(用例)
└── 正常创建订单(用例)
```
同一方法内的所有测试用例共享同一个 `testTx`,因此前面用例插入的数据(如 `a.DB().Insert("goods", ...)`)对后续用例可见,模拟了真实的连续操作场景。
### 嵌套事务 (SAVEPOINT)
业务代码中常见的 `that.Db.Action()` 在测试模式下自动切换为 SAVEPOINT 机制,不会脱离外层测试事务:
```go
// 业务代码(无需修改)
that.Db.Action(func(db HoTimeDB) bool {
db.Insert("order", orderData) // 在 SAVEPOINT sp_N 内执行
db.Update("customer", balanceData, where)
return true // → RELEASE SAVEPOINT sp_N(提交到外层 testTx
// 返回 false → ROLLBACK TO SAVEPOINT sp_N(只回滚这一层)
})
// 测试结束 → ROLLBACK testTx(回滚一切)
```
| 机制 | 生产模式 | 测试模式 |
| ---------- | ------------------------------- | --------------------------------------- |
| `Action()` | `BEGIN` / `COMMIT` / `ROLLBACK` | `SAVEPOINT` / `RELEASE` / `ROLLBACK TO` |
| 数据持久化 | 持久化到数据库 | 测试结束全部回滚 |
| 对现有代码影响 | 无 | 无(`testTx` 默认 nil) |
---
## API 调试控制台
通过链式 `.Swagger(title, version)` 启用。
**增量写入**:每个接口 / 流程跑完即 flush;本轮无记录则不覆盖成「未测」;未全量跑完不 prune 已删接口;仅全量完成才清理孤儿模块。Ctrl+C / SIGTERM 走优雅停测后再收尾。
输出目录默认 `config` 中的 `tpt`
### 生成目录结构
```
{outputDir}/swagger/
├── index.html ← 根导航页
├── app/
│ ├── api-spec.json ← endpoints + flows
│ └── index.html
└── ...
```
访问:`http://localhost:8081/swagger/`(根导航),`http://localhost:8081/swagger/app/`(模块)。
### 控制台能力
| 区域 | 行为 |
| --- | -------------------------------------------------------------- |
| 侧栏 | 业务流程 → group → flow 名;接口仍按 project/ctr 三级 |
| 流程页 | Prep 业务说明;步骤默认展开;左备注/关联验收用例/请求(cURL),右响应;点 path 弹窗只读详情(Esc 关闭) |
| 单接口 | 用例预填、全局认证;**发送结果 cURL 随左侧 Header/Query/JSON/Form 实时同步** |
| 发送 | 浏览器 `fetch` 打当前源站,**需已启动服务**(与 httptest 零启动测试不同) |
---
## 并发保护与缓存隔离
框架自动处理以下问题,编写测试时无需关心:
- **并发保护**`testTx` 是单连接,框架通过 `testMu` 互斥锁自动序列化所有数据库操作,业务代码中的 `go func()` 协程不会导致 `busy buffer` 错误
- **缓存隔离**:启动时自动禁用 DB/Redis 缓存,避免与测试事务产生锁冲突;Session 通过 `WithSession()` 直接注入 Memory 缓存
### 一次性数据的处理
对于需要随机数据的测试(如注册),直接用 `RandX` 生成随机值即可,**无需手动清理**:
```go
"create": {Desc: "用户注册", Func: func(a *Api) {
// 错误用例
a.JSON(Map{"phone": "", "password": "123456"}).Post("手机号为空", 3, "请填写手机号")
a.JSON(Map{"phone": "1380013", "password": "123456"}).Post("手机号格式错误", 3, "手机号码格式错误")
// 准备数据 + 正确请求
phone := "138" + ObjToStr(RandX(10000000, 99999999))
a.JSON(Map{"phone": phone, "password": "123456"}).
Verify(func(a *Api) error {
result := a.Resp().GetBody().GetMap("result")
if result.GetCeilInt64("user_id") <= 0 {
return fmt.Errorf("注册成功但 user_id 无效")
}
row := a.DB().Get("user", "id,phone", Map{"phone": phone})
if row == nil {
return fmt.Errorf("user 表未写入注册记录")
}
return nil
}).
Post("正常注册", 0, Map{"user_id": int64(1), "token": "sample"})
// 重复注册
a.JSON(Map{"phone": phone, "password": "123456"}).Post("重复注册", 3, "该手机号已被注册")
}},
```
---
## 二进制与非 JSON 响应校验
并非所有接口都返回 JSON。文件下载、图片导出、PDF 生成等场景会直接输出二进制流。框架通过 `RawBody` 完整捕获响应体原始字节,可在 Verify 中校验:
### 文件下载校验
```go
"export": {Desc: "导出 Excel", Func: func(a *Api) {
a.Query(Map{"type": "xlsx"}).
Verify(func(a *Api) error {
raw := a.Resp().GetRawBody()
if len(raw) == 0 {
return fmt.Errorf("响应体为空")
}
// XLSX 文件的 Magic Bytes: PK (50 4B 03 04)
if len(raw) < 4 || raw[0] != 0x50 || raw[1] != 0x4B {
return fmt.Errorf("不是有效的 XLSX 文件,前4字节: %x", raw[:4])
}
// 校验文件大小在合理范围
if len(raw) < 1024 {
return fmt.Errorf("文件过小: %d bytes,可能为空文件", len(raw))
}
return nil
}).
Get("导出报表", 0)
}},
```
### 非 JSON 纯文本响应
当接口返回纯文本(如 CSV、日志)时,`Body`Map)为空但 `RawBody` 包含完整内容:
```go
a.Verify(func(a *Api) error {
text := string(a.Resp().GetRawBody())
if !strings.Contains(text, "id,name,phone") {
return fmt.Errorf("CSV 缺少表头")
}
lines := strings.Split(text, "\n")
if len(lines) < 2 {
return fmt.Errorf("CSV 无数据行,仅 %d 行", len(lines))
}
return nil
}).Get("导出 CSV", 0)
```
### RawBody vs Body 的关系
| 场景 | `Body (Map)` | `RawBody ([]byte)` |
| ------------------- | ------------- | ---------------------- |
| JSON 响应 | 已解析的结构化 Map | 原始 JSON 字节 |
| 二进制文件(XLSX/PNG/PDF | 空 Map(解析失败) | 完整二进制内容 |
| 纯文本(CSV/TXT/HTML | 空 Map(非 JSON) | 完整文本字节,可 `string()` 转换 |
| 空响应 | 空 Map | 空 `[]byte` |
> **注意**:二进制/纯文本响应不走 status 校验(JSON 解析失败后 `Body` 为空 Map`status` 默认 0),
> 所以 `Get("描述", 0)` 只是形式上通过。**真正的校验逻辑必须写在 Verify 中**,对 `RawBody` 做内容断言。
## 全量回归与覆盖率
也支持对整个测试包做全量回归,**仅 CI / 发版前使用**:
```bash
go test ./app/... -v
```
全量跑完后 `Run` 会调用 `PrintCoverage()` 输出覆盖率报告。
### 覆盖率报告
`PrintCoverage()` 自动对比 `Proj`(所有路由)和 `ProjTest`(有测试的路由)。**Flow 步骤不计入覆盖率**(`FlowName != ""` 的记录会被跳过)。
**输出示例(全部通过):**
```
========== API 测试覆盖率报告 ==========
总接口: 42 | 已覆盖: 6 | 覆盖率: 14.3%
总用例: 16 | 通过: 16 | 未通过: 0 | 通过率: 100.0%
---------- 通过的接口 ----------
✓ /api/user/login 4 用例 (4 通过, 0 失败) 12ms
...
未覆盖的接口: (36 个)
- api/goods/list
- ...
========================================
```
**输出示例(有失败):**
```
========== API 测试覆盖率报告 ==========
总接口: 42 | 已覆盖: 6 | 覆盖率: 14.3%
总用例: 16 | 通过: 14 | 未通过: 2 | 通过率: 87.5%
---------- 未通过的接口 (1个) ----------
✗ /api/order/create 5 用例 (3 通过, 2 失败) 25ms
[失败] 正常创建订单 — Verify: order 表未写入订单记录
...
========================================
```
报告分为四个区域:
- **汇总区**:接口覆盖率 + 用例通过率
- **未通过的接口**:失败接口单独置顶,附带失败原因
- **通过的接口**:正常通过的接口列表
- **未覆盖的接口**:尚未编写测试的接口
### api-spec.json 覆盖率字段说明
`GenerateSwagger` 生成的每个 `api-spec.json` 中,每条 `endpoint` 可含:
```json
{
"path": "/api/user/login",
"tested": true,
"cases": [
{
"name": "正常登录",
"passed": true,
"note": "备注(可选)",
"json": { "name": "13800138000", "password": "123456" },
"response": { "status": 0, "msg": "ok", "data": {} }
}
]
}
```
---
## 已知局限与后续
| 优先级 | 缺口 | 说明 |
| --- | ----------------------- | ---------------------------- |
| 高 | 值级断言助手 | 结构校验有,值断言靠 Verify 手写 |
| 高 | DB 断言助手 | 无 AssertCount / AssertRow 一类 |
| 高 | FromCase 无模板显式失败 | 现静默不填,易空 body 误测 |
| 中 | 自定义 Header / Cookie API | 控制台有,测试链无 `WithHeader` |
| 中 | HTTP 状态码 / 响应头断言 | StatusCode 有写入,无公开断言 |
| 中 | 覆盖率含 Flow | PrintCoverage 跳过 Flow 记录 |
| 低 | PATCH、多文件上传 | 当前边界 |
| 低 | 框架级 Seed/Fixture | 依赖业务自写 `a.DB()` 准备 |
规划表见 [ROADMAP_改进规划.md](ROADMAP_改进规划.md)。