feat(testing): Listener 链式入口并精简测试文档运行指引
将 connect listener 并入 Test 链、删除 NewTestApp,正文只示范 -run 单测,全量命令仅保留在文末 CI 节。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+206
-182
@@ -2,17 +2,11 @@
|
||||
|
||||
不启动 HTTP 服务、自动事务回滚、链式 API、覆盖率报告、API 调试控制台生成。
|
||||
|
||||
> **测试执行铁律(日常开发 / AI 辅助必读)**
|
||||
>
|
||||
> - **只允许**用 `-run` 精确跑当前接口或当前控制器
|
||||
> - **禁止**把 `go test ./app/... -v`(不带 `-run`)当作默认命令
|
||||
> - 全量回归与覆盖率报告见文末「全量回归与覆盖率」,仅 CI / 发版前使用
|
||||
|
||||
## 目录
|
||||
|
||||
- [概述](#概述)
|
||||
- [快速开始](#快速开始)
|
||||
- [运行测试(推荐:-run 单测)](#运行测试推荐-run-单测)
|
||||
- [运行测试](#运行测试)
|
||||
- [用例编写范式](#用例编写范式)
|
||||
- [核心类型](#核心类型)
|
||||
- [业务流程联调(Flows)](#业务流程联调flows)
|
||||
@@ -22,7 +16,7 @@
|
||||
- [API 调试控制台](#api-调试控制台)
|
||||
- [并发保护与缓存隔离](#并发保护与缓存隔离)
|
||||
- [二进制与非 JSON 响应校验](#二进制与非-json-响应校验)
|
||||
- [全量回归与覆盖率(仅 CI / 发版前)](#全量回归与覆盖率仅-ci--发版前)
|
||||
- [全量回归与覆盖率](#全量回归与覆盖率)
|
||||
- [已知局限与后续](#已知局限与后续)
|
||||
|
||||
---
|
||||
@@ -31,18 +25,20 @@
|
||||
|
||||
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`:停收新测、跑完当前、回滚事务、保留已落盘文档 |
|
||||
|
||||
| 能力 | 说明 |
|
||||
| ------------- | --------------------------------------------------------------------- |
|
||||
| **零启动** | 基于 `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`:停收新测、跑完当前、回滚事务、保留已落盘文档 |
|
||||
|
||||
|
||||
---
|
||||
|
||||
@@ -186,11 +182,16 @@ 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},
|
||||
})
|
||||
// 可选:.Flows(FlowTest{ "checkout": {Desc: "下单支付跑通", Func: checkoutFlow} })
|
||||
|
||||
os.Exit(testApp.Run(m))
|
||||
}
|
||||
@@ -201,7 +202,8 @@ func TestApi(t *testing.T) {
|
||||
```
|
||||
|
||||
`Run(m)` 内部会:注册优雅停测信号 → `m.Run()` → 覆盖率 → Swagger 收尾(接口级增量写入)。
|
||||
`WorkDir` / `Swagger` / `Flows` 均可省略;不写 `Swagger` 则不生成调试控制台。
|
||||
`WorkDir` / `Listener` / `Swagger` / `Flows` 均可省略;不写 `Swagger` 则不生成调试控制台。example 推荐把整条链写全便于对照。
|
||||
|
||||
### 推荐目录结构
|
||||
|
||||
```
|
||||
@@ -214,30 +216,28 @@ app/
|
||||
└── init_test.go ← 测试注册 (ProjectTest) + TestMain + TestApi
|
||||
```
|
||||
|
||||
### 运行测试(推荐:`-run` 单测)
|
||||
### 运行测试
|
||||
|
||||
`RunTests` 使用 Go 标准 `t.Run()` 子测试,**日常开发请始终加 `-run`**:
|
||||
`RunTests` 使用 Go 标准 `t.Run()` 子测试,用 `-run` 精确到单接口 / 单控制器 / 单用例:
|
||||
|
||||
```bash
|
||||
# 只跑 order/create 接口(推荐,开发默认)
|
||||
# 单接口
|
||||
go test ./app/ -v -run TestApi/app/order/create
|
||||
|
||||
# 只跑 order 控制器
|
||||
# 单控制器
|
||||
go test ./app/ -v -run TestApi/app/order
|
||||
|
||||
# 只跑某一条用例
|
||||
# 单用例
|
||||
go test ./app/ -v -run "TestApi/app/order/create/正常创建订单"
|
||||
|
||||
# 同时跑 user 和 order
|
||||
# 多控制器(正则)
|
||||
go test ./app/ -v -run "TestApi/app/(user|order)"
|
||||
|
||||
# 跑所有控制器中包含「未登录」的用例
|
||||
# 按用例名收窄(如所有「未登录」)
|
||||
go test ./app/ -v -run "TestApi/.*/.*未登录"
|
||||
```
|
||||
|
||||
> **注意**:`WorkDir("..")` 在 Init 前切换工作目录,确保 `go test ./app/` 时配置与模板输出路径正确。
|
||||
>
|
||||
> 全量 `go test ./app/... -v`(不带 `-run`)会跑完所有用例并触发覆盖率扫描,项目变大后很慢且容易牵连无关失败。**仅在 CI / 发版前使用**,说明见文末。
|
||||
> **注意**:`WorkDir("..")` 在 Init 前切换工作目录,确保上述 `-run` 命令下配置与模板输出路径正确。
|
||||
|
||||
---
|
||||
|
||||
@@ -255,14 +255,16 @@ go test ./app/ -v -run "TestApi/.*/.*未登录"
|
||||
Verify 统一校验(响应值断言 + 数据库状态校验,集中在一个回调中完成)
|
||||
```
|
||||
|
||||
| 环节 | 要求 | 说明 |
|
||||
|------|------|------|
|
||||
| 错误用例 | **必须** | 覆盖权限、参数、业务逻辑等异常路径 |
|
||||
| 测试数据准备 | 按需 | 接口依赖的前置数据用 `a.DB()` 插入 |
|
||||
| Note 备注 | 按需 | 用 `.Note(...)` 补充参数含义、枚举值说明、算法描述等上下文 |
|
||||
| 响应结构校验 | **必须** | 正确请求必须用第三参数校验 result 的类型和结构 |
|
||||
|
||||
| 环节 | 要求 | 说明 |
|
||||
| --------- | ------ | ------------------------------------------------- |
|
||||
| 错误用例 | **必须** | 覆盖权限、参数、业务逻辑等异常路径 |
|
||||
| 测试数据准备 | 按需 | 接口依赖的前置数据用 `a.DB()` 插入 |
|
||||
| Note 备注 | 按需 | 用 `.Note(...)` 补充参数含义、枚举值说明、算法描述等上下文 |
|
||||
| 响应结构校验 | **必须** | 正确请求必须用第三参数校验 result 的类型和结构 |
|
||||
| Verify 校验 | **必须** | 在 Verify 内用 `a.Resp()` 断言响应值 + 用 `a.DB()` 校验数据库状态 |
|
||||
|
||||
|
||||
### 1. 错误用例编写规范
|
||||
|
||||
**错误用例必须写在最前面**,在正确请求之前,按接口错误码顺序依次覆盖:权限校验(status 1/2)→ 参数校验(status 3)→ 业务校验(status 4)。
|
||||
@@ -300,14 +302,16 @@ resp := a.WithSession(Map{"user_id": int64(1)}).
|
||||
|
||||
第三参数作为**类型样本**,框架自动校验 result 的类型是否匹配,**不比较具体值**。第三参数支持所有 JSON 兼容类型:
|
||||
|
||||
| 样本写法 | 校验效果 |
|
||||
|----------|----------|
|
||||
| `int64(1)` | result 是整数类型 |
|
||||
| `float64(1.0)` | result 是浮点类型 |
|
||||
| `"sample"` | result 是字符串类型 |
|
||||
| `true` | result 是布尔类型 |
|
||||
| `Map{"id": int64(1), ...}` | result 是对象,递归校验字段名+类型 |
|
||||
| `Slice{Map{"id": int64(1)}}` | result 是数组,校验首元素结构 |
|
||||
|
||||
| 样本写法 | 校验效果 |
|
||||
| ---------------------------- | --------------------- |
|
||||
| `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 时:值只是类型样本,不比较具体值
|
||||
@@ -395,30 +399,36 @@ a.WithSession(Map{"user_id": int64(1)}).
|
||||
|
||||
**Verify 内可用的方法:**
|
||||
|
||||
| 方法 | 说明 |
|
||||
|------|------|
|
||||
| `a.Resp()` | 获取当前请求的响应,用于值断言 |
|
||||
| `a.Resp().GetBody()` | 获取完整响应体(Map) |
|
||||
| `a.Resp().GetBody().GetMap("result")` | 获取 result 对象 |
|
||||
| `a.DB()` | 获取数据库实例(在测试事务内),用于查库校验 |
|
||||
|
||||
| 方法 | 说明 |
|
||||
| ------------------------------------- | ---------------------- |
|
||||
| `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` |
|
||||
| 主表记录 | 核心数据是否写入、字段值是否正确 | `a.DB().Get("order", ...)` |
|
||||
| 关联表 | 关联数据是否同步生成 | `a.DB().Count("order_goods", ...)` |
|
||||
| 副作用 | 库存扣减、余额变化、状态流转 | `goods.GetCeilInt64("stock") != 97` |
|
||||
|
||||
|
||||
**适用场景对照:**
|
||||
|
||||
| 接口类型 | Verify 内容 |
|
||||
|----------|-------------|
|
||||
| 创建类(Insert) | 响应值断言 + 校验记录写入 + 关联表 |
|
||||
|
||||
| 接口类型 | Verify 内容 |
|
||||
| ----------- | --------------------- |
|
||||
| 创建类(Insert) | 响应值断言 + 校验记录写入 + 关联表 |
|
||||
| 更新类(Update) | 响应值断言 + 校验字段变更 + 状态流转 |
|
||||
| 删除类(Delete) | 响应值断言 + 校验记录已删除/软删除 |
|
||||
| 纯查询(Select) | 响应值断言(无需 DB 校验) |
|
||||
| 删除类(Delete) | 响应值断言 + 校验记录已删除/软删除 |
|
||||
| 纯查询(Select) | 响应值断言(无需 DB 校验) |
|
||||
|
||||
|
||||
### 5. Note 备注
|
||||
|
||||
@@ -459,13 +469,15 @@ a.WithSession(Map{"admin_id": 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("幂等接口,重复调用返回相同结果")` |
|
||||
|
||||
| 场景 | 示例 |
|
||||
| --------------------------- | ----------------------------------------------- |
|
||||
| 参数含枚举值(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 手册。
|
||||
|
||||
@@ -514,13 +526,14 @@ type ApiTestDef struct {
|
||||
|
||||
```go
|
||||
// Test 链式入口(延迟 Init,便于先 WorkDir)
|
||||
func Test(configPath string, listeners ...func(*Context) bool) *TestApp
|
||||
func Test(configPath string) *TestApp
|
||||
|
||||
func (app *TestApp) WorkDir(dir string) *TestApp
|
||||
func (app *TestApp) Swagger(args ...string) *TestApp // title / version / outputDir 均可选
|
||||
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) 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
|
||||
@@ -534,21 +547,25 @@ func (app *TestApp) DB() *HoTimeDB
|
||||
|
||||
**FlowDef 字段**
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `Group` | 控制台侧栏分组(如 `order`) |
|
||||
| `Desc` | 流程短标题 |
|
||||
| `Prep` | 业务说明 / 前置与数据准备(控制台「业务说明」) |
|
||||
| `Func` | `func(f *Flow)` |
|
||||
|
||||
| 字段 | 说明 |
|
||||
| ------- | ------------------------- |
|
||||
| `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()` | 流程事务库 / 最近一步响应 |
|
||||
|
||||
| 方法 | 说明 |
|
||||
| --------------------- | ------------------------- |
|
||||
| `f.Step(name, path)` | 新步骤,返回 `*Api`(记录归入 flows) |
|
||||
| `f.AtPath(path)` | 同流程内换 path,不增步骤序号 |
|
||||
| `f.WithSession(s)` | 后续步骤携带 session |
|
||||
| `f.DB()` / `f.Resp()` | 流程事务库 / 最近一步响应 |
|
||||
|
||||
|
||||
```go
|
||||
Flows(FlowTest{
|
||||
@@ -576,7 +593,7 @@ Flows(FlowTest{
|
||||
|
||||
- 按 path + 用例名加载请求模板:优先本轮已跑的单接口记录,否则读已有 `api-spec.json`
|
||||
- 步骤跑完后回写该单接口 case 的 response / passed
|
||||
- 无模板时当前为**静默不填**(见 [已知局限](#已知局限与后续));建议全量或先跑绑定接口再跑 flow
|
||||
- 无模板时当前为**静默不填**(见 [已知局限](#已知局限与后续));建议先用 `-run` 跑绑定的单接口再跑 flow
|
||||
- 示例:`example/app/flow_test.go`
|
||||
|
||||
控制台:侧栏「业务流程 → group → flow 名」;步骤左栏含关联验收用例 / 备注 / 请求,右栏响应;点 path 弹窗看接口只读详情。
|
||||
@@ -597,50 +614,56 @@ Flows(FlowTest{
|
||||
|
||||
### 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` |
|
||||
|
||||
| 方法 | 说明 | 返回 |
|
||||
| ----------------------------------- | ------------------------------------------------- | -------------- |
|
||||
| `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` |
|
||||
|
||||
| 方法 | 说明 | 返回 |
|
||||
| ----------------------------------- | ----------------------------------------------------------------------------------- | -------------- |
|
||||
| `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(见下方说明) |
|
||||
|
||||
| 参数 | 类型 | 说明 |
|
||||
| -------- | -------------- | -------------------------------- |
|
||||
| `desc` | string | 用例描述,作为 `t.Run` 的子测试名称 |
|
||||
| `status` | int | 期望的 JSON 响应体中 `status` 字段值(0=成功) |
|
||||
| `expect` | ...interface{} | 可选,行为取决于 status(见下方说明) |
|
||||
|
||||
|
||||
**expect 参数解析规则:**
|
||||
|
||||
@@ -658,16 +681,19 @@ Flows(FlowTest{
|
||||
|
||||
expect 参数作为**类型样本**,只比较类型大类,不比较具体值。result 不一定是 Map,可以是任意类型:
|
||||
|
||||
| 样本值类型 | 匹配的实际类型 | 说明 |
|
||||
|-----------|--------------|------|
|
||||
| `int64` / `int` 等整数 | 整数类型 | number(整数) |
|
||||
| `float64` / `float32` | 浮点类型 | float(小数) |
|
||||
| `string` | `string` | 字符串 |
|
||||
| `bool` | `bool` | 布尔值 |
|
||||
| `Map{...}` | `Map` / `map[string]interface{}` | 对象,递归校验子字段 |
|
||||
| `Slice{...}` | `Slice` / `[]interface{}` | 数组,非空时校验首元素结构 |
|
||||
|
||||
| 样本值类型 | 匹配的实际类型 | 说明 |
|
||||
| --------------------- | -------------------------------- | ------------- |
|
||||
| `int64` / `int` 等整数 | 整数类型 | number(整数) |
|
||||
| `float64` / `float32` | 浮点类型 | float(小数) |
|
||||
| `string` | `string` | 字符串 |
|
||||
| `bool` | `bool` | 布尔值 |
|
||||
| `Map{...}` | `Map` / `map[string]interface{}` | 对象,递归校验子字段 |
|
||||
| `Slice{...}` | `Slice` / `[]interface{}` | 数组,非空时校验首元素结构 |
|
||||
|
||||
|
||||
校验规则:
|
||||
|
||||
- result 是原始类型(string、int、float 等)→ 直接比较类型大类
|
||||
- result 是 Map → **多了**字段不管,**少了**预设字段报错「字段缺失」
|
||||
- 字段存在但**类型不一致** → 报错「类型不匹配」
|
||||
@@ -701,16 +727,18 @@ 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)` | — | 手动标记测试失败 |
|
||||
|
||||
| 方法 | 返回类型 | 说明 |
|
||||
| --------------------------- | -------------- | ------------------------------------------------------- |
|
||||
| `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)` | — | 手动标记测试失败 |
|
||||
|
||||
|
||||
### 常用写法速查
|
||||
|
||||
@@ -772,11 +800,13 @@ that.Db.Action(func(db HoTimeDB) bool {
|
||||
// 测试结束 → ROLLBACK testTx(回滚一切)
|
||||
```
|
||||
|
||||
| 机制 | 生产模式 | 测试模式 |
|
||||
|------|----------|----------|
|
||||
|
||||
| 机制 | 生产模式 | 测试模式 |
|
||||
| ---------- | ------------------------------- | --------------------------------------- |
|
||||
| `Action()` | `BEGIN` / `COMMIT` / `ROLLBACK` | `SAVEPOINT` / `RELEASE` / `ROLLBACK TO` |
|
||||
| 数据持久化 | 持久化到数据库 | 测试结束全部回滚 |
|
||||
| 对现有代码影响 | 无 | 无(`testTx` 默认 nil) |
|
||||
| 数据持久化 | 持久化到数据库 | 测试结束全部回滚 |
|
||||
| 对现有代码影响 | 无 | 无(`testTx` 默认 nil) |
|
||||
|
||||
|
||||
---
|
||||
|
||||
@@ -803,12 +833,14 @@ that.Db.Action(func(db HoTimeDB) bool {
|
||||
|
||||
### 控制台能力
|
||||
|
||||
| 区域 | 行为 |
|
||||
|------|------|
|
||||
| 侧栏 | 业务流程 → group → flow 名;接口仍按 project/ctr 三级 |
|
||||
|
||||
| 区域 | 行为 |
|
||||
| --- | -------------------------------------------------------------- |
|
||||
| 侧栏 | 业务流程 → group → flow 名;接口仍按 project/ctr 三级 |
|
||||
| 流程页 | Prep 业务说明;步骤默认展开;左备注/关联验收用例/请求(cURL),右响应;点 path 弹窗只读详情(Esc 关闭) |
|
||||
| 单接口 | 用例预填、全局认证;**发送结果 cURL 随左侧 Header/Query/JSON/Form 实时同步** |
|
||||
| 发送 | 浏览器 `fetch` 打当前源站,**需已启动服务**(与 httptest 零启动测试不同) |
|
||||
| 单接口 | 用例预填、全局认证;**发送结果 cURL 随左侧 Header/Query/JSON/Form 实时同步** |
|
||||
| 发送 | 浏览器 `fetch` 打当前源站,**需已启动服务**(与 httptest 零启动测试不同) |
|
||||
|
||||
|
||||
---
|
||||
|
||||
@@ -900,38 +932,27 @@ a.Verify(func(a *Api) error {
|
||||
|
||||
### RawBody vs Body 的关系
|
||||
|
||||
| 场景 | `Body (Map)` | `RawBody ([]byte)` |
|
||||
|------|-------------|-------------------|
|
||||
| JSON 响应 | 已解析的结构化 Map | 原始 JSON 字节 |
|
||||
| 二进制文件(XLSX/PNG/PDF) | 空 Map(解析失败) | 完整二进制内容 |
|
||||
| 纯文本(CSV/TXT/HTML) | 空 Map(非 JSON) | 完整文本字节,可 `string()` 转换 |
|
||||
| 空响应 | 空 Map | 空 `[]byte` |
|
||||
|
||||
| 场景 | `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 / 发版前)
|
||||
## 全量回归与覆盖率
|
||||
|
||||
> **本节仅适用于 CI 流水线或发版前人工回归。** 日常开发与 AI 辅助请使用文首的 `-run` 单测命令,不要执行本节命令。
|
||||
|
||||
### 全量命令
|
||||
也支持对整个测试包做全量回归,**仅 CI / 发版前使用**:
|
||||
|
||||
```bash
|
||||
# 跑完当前包(或 ./app/...)下全部 API 测试——慢,且任一无关失败都会打断你
|
||||
go test ./app/... -v
|
||||
```
|
||||
|
||||
`TestMain` 推荐写法(`Run` 已含覆盖率与 Swagger 收尾):
|
||||
|
||||
```go
|
||||
func TestMain(m *testing.M) {
|
||||
testApp = Test("config/config.json").
|
||||
WorkDir("..").
|
||||
Swagger("My API", "1.0.0").
|
||||
Proj(TestProj{"app": {Proj: Project, Tests: ProjectTest}})
|
||||
os.Exit(testApp.Run(m))
|
||||
}
|
||||
```
|
||||
全量跑完后 `Run` 会调用 `PrintCoverage()` 输出覆盖率报告。
|
||||
|
||||
### 覆盖率报告
|
||||
|
||||
@@ -969,6 +990,7 @@ func TestMain(m *testing.M) {
|
||||
```
|
||||
|
||||
报告分为四个区域:
|
||||
|
||||
- **汇总区**:接口覆盖率 + 用例通过率
|
||||
- **未通过的接口**:失败接口单独置顶,附带失败原因
|
||||
- **通过的接口**:正常通过的接口列表
|
||||
@@ -998,15 +1020,17 @@ func TestMain(m *testing.M) {
|
||||
|
||||
## 已知局限与后续
|
||||
|
||||
| 优先级 | 缺口 | 说明 |
|
||||
|--------|------|------|
|
||||
| 高 | 值级断言助手 | 结构校验有,值断言靠 Verify 手写 |
|
||||
| 高 | DB 断言助手 | 无 AssertCount / AssertRow 一类 |
|
||||
| 高 | FromCase 无模板显式失败 | 现静默不填,易空 body 误测 |
|
||||
| 中 | 自定义 Header / Cookie API | 控制台有,测试链无 `WithHeader` |
|
||||
| 中 | HTTP 状态码 / 响应头断言 | StatusCode 有写入,无公开断言 |
|
||||
| 中 | 覆盖率含 Flow | PrintCoverage 跳过 Flow 记录 |
|
||||
| 低 | PATCH、多文件上传 | 当前边界 |
|
||||
| 低 | 框架级 Seed/Fixture | 依赖业务自写 `a.DB()` 准备 |
|
||||
|
||||
规划表见 [ROADMAP_改进规划.md](ROADMAP_改进规划.md)。
|
||||
| 优先级 | 缺口 | 说明 |
|
||||
| --- | ----------------------- | ---------------------------- |
|
||||
| 高 | 值级断言助手 | 结构校验有,值断言靠 Verify 手写 |
|
||||
| 高 | DB 断言助手 | 无 AssertCount / AssertRow 一类 |
|
||||
| 高 | FromCase 无模板显式失败 | 现静默不填,易空 body 误测 |
|
||||
| 中 | 自定义 Header / Cookie API | 控制台有,测试链无 `WithHeader` |
|
||||
| 中 | HTTP 状态码 / 响应头断言 | StatusCode 有写入,无公开断言 |
|
||||
| 中 | 覆盖率含 Flow | PrintCoverage 跳过 Flow 记录 |
|
||||
| 低 | PATCH、多文件上传 | 当前边界 |
|
||||
| 低 | 框架级 Seed/Fixture | 依赖业务自写 `a.DB()` 准备 |
|
||||
|
||||
|
||||
规划表见 [ROADMAP_改进规划.md](ROADMAP_改进规划.md)。
|
||||
@@ -23,6 +23,9 @@ 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": {
|
||||
@@ -42,6 +45,37 @@ func TestApi(t *testing.T) {
|
||||
testApp.RunTests(t)
|
||||
}
|
||||
|
||||
// TestListenerChain 验证链式 Listener 在请求路径上被调用。
|
||||
// 注意:TestMain 已 Chdir 到 example/,此处不再 WorkDir。
|
||||
func TestListenerChain(t *testing.T) {
|
||||
hit := false
|
||||
app := Test("config/config.json").
|
||||
Listener(func(ctx *Context) bool {
|
||||
hit = true
|
||||
return false
|
||||
}).
|
||||
Proj(TestProj{
|
||||
"app": {
|
||||
Proj: Project,
|
||||
Tests: ProjTest{
|
||||
"expect_demo": CtrTest{
|
||||
"string_result": {Desc: "触发 listener", Func: func(a *Api) {
|
||||
ExpectDemoTest["string_result"].Func(a)
|
||||
}},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
SetupDatabase(&app.Db)
|
||||
app.RunTests(t)
|
||||
|
||||
if !hit {
|
||||
t.Fatal("Listener 未被调用")
|
||||
}
|
||||
_ = app.Db.RollbackTestTx()
|
||||
}
|
||||
|
||||
// TestInterruptStop 模拟提前中断:string_result 跑完后 RequestStop,zzz_should_skip 应 Skip;
|
||||
// swagger 中 string_result 的 cases 应保留。注意:TestMain 已 Chdir 到 example/,此处不再 WorkDir。
|
||||
func TestInterruptStop(t *testing.T) {
|
||||
|
||||
+14
-7
@@ -41,10 +41,9 @@ type TestApp struct {
|
||||
}
|
||||
|
||||
// Test 创建测试应用(链式入口,延迟 Init 以便先 WorkDir)
|
||||
func Test(configPath string, listeners ...func(*Context) bool) *TestApp {
|
||||
func Test(configPath string) *TestApp {
|
||||
return &TestApp{
|
||||
configPath: configPath,
|
||||
listeners: listeners,
|
||||
collector: &TestCollector{},
|
||||
}
|
||||
}
|
||||
@@ -55,6 +54,19 @@ func (that *TestApp) WorkDir(dir string) *TestApp {
|
||||
return that
|
||||
}
|
||||
|
||||
// Listener 追加连接监听(可选;可多次调用或一次传多个)。
|
||||
// Init 前仅暂存,ensureInit 时批量 SetConnectListener;Init 后立刻挂上。
|
||||
// 返回 true 表示已处理并停止进入控制器,false 继续。
|
||||
func (that *TestApp) Listener(fns ...func(*Context) bool) *TestApp {
|
||||
that.listeners = append(that.listeners, fns...)
|
||||
if that.inited && that.Application != nil {
|
||||
for _, fn := range fns {
|
||||
that.SetConnectListener(fn)
|
||||
}
|
||||
}
|
||||
return that
|
||||
}
|
||||
|
||||
// Swagger 启用接口级增量 Swagger 写入。
|
||||
// 参数可选:Swagger() / Swagger(title) / Swagger(title, version) / Swagger(title, version, outputDir)
|
||||
func (that *TestApp) Swagger(args ...string) *TestApp {
|
||||
@@ -285,11 +297,6 @@ func (c *TestCollector) MarkFlowVisited(name string) {
|
||||
c.VisitedFlows[name] = true
|
||||
}
|
||||
|
||||
// NewTestApp 立即 Init 的便捷构造(内部委托 Test().Proj())
|
||||
func NewTestApp(configPath string, projects TestProj, listeners ...func(*Context) bool) *TestApp {
|
||||
return Test(configPath, listeners...).Proj(projects)
|
||||
}
|
||||
|
||||
// SetupForTest 初始化路由(复用 Run 的路由注册逻辑,但不启动 HTTP 服务)
|
||||
func (that *Application) SetupForTest(router Router) {
|
||||
if that.Router == nil {
|
||||
|
||||
Reference in New Issue
Block a user