feat(testing): Flows/FromCase、增量 swagger 与 Doc-Driven 门禁

补齐多接口流程联调与控制台体验,并落地仓内 Doc-Driven+TDD 总规则与文档。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-13 18:11:08 +08:00
parent 9fe4ab2758
commit 16496a8dc0
14 changed files with 4178 additions and 418 deletions
+4 -2
View File
@@ -1,5 +1,7 @@
# HoTime 文档索引
本仓采用 **Doc-Driven + TDD**:行为一变必须回写 docs;改接口必须用 `-run` 精确测试验证(见 [Testing_API测试框架.md](Testing_API测试框架.md) 文首铁律与仓规则 `hotime-doc-tdd`)。
推荐按路径阅读,避免同一概念在多份文档里来回找。
## 阅读路径
@@ -8,7 +10,7 @@
2. **类型与工具** → [Common_工具类.md](Common_工具类.md)
3. **数据库 ORM** → [HoTimeDB_使用说明.md](HoTimeDB_使用说明.md)(教程)→ [HoTimeDB_API参考.md](HoTimeDB_API参考.md)(速查)
4. **开表 / 代码生成** → [DatabaseDesign_数据库设计规范.md](DatabaseDesign_数据库设计规范.md)(前置)→ [CodeGen_代码生成.md](CodeGen_代码生成.md)
5. **接口测试** → [Testing_API测试框架.md](Testing_API测试框架.md)(日常用 `-run` 单测;全量见文末)
5. **接口测试** → [Testing_API测试框架.md](Testing_API测试框架.md)(日常用 `-run` 单测;局限见文末;全量见文末)
6. **运维** → [GracefulShutdown_优雅停机.md](GracefulShutdown_优雅停机.md)、[Seq_日志集成.md](Seq_日志集成.md)
7. **规划** → [ROADMAP_改进规划.md](ROADMAP_改进规划.md)
@@ -22,7 +24,7 @@
| HoTimeDB_API参考 | ORM 方法与条件语法速查 |
| DatabaseDesign_数据库设计规范 | 表/字段/COMMENT 规范(CodeGen 输入契约) |
| CodeGen_代码生成 | 生成器用法 + codeConfig / rule 配置 |
| Testing_API测试框架 | API 测试;优先 `-run`,全量沉底 |
| Testing_API测试框架 | API 测试、Flows、控制台;优先 `-run`;已知局限 |
| GracefulShutdown_优雅停机 | 优雅停机与滚动重启 |
| Seq_日志集成 | Seq 结构化日志 |
| ROADMAP_改进规划 | 待改进项 |
+2
View File
@@ -266,6 +266,7 @@ a.JSON(Map{"phone": "138..."}).Post("正常注册", 0, Map{"id": notEmpty}) //
|--------|--------|------|
| 高 | 备注语法优化(`\|` 分隔符) | 待设计 |
| 高 | 测试框架:响应字段部分匹配断言 | 待实现 |
| 高 | 测试框架其它缺口(DB 断言、FromCase 无模板失败、Header API 等) | 见 [Testing 已知局限](Testing_API测试框架.md#已知局限与后续) |
| 中 | 软删除/日志表支持 | 待设计 |
| 低 | 多对多关联表增强 | 待评估 |
| 低 | 自动填充字段扩展 | 待评估 |
@@ -276,6 +277,7 @@ a.JSON(Map{"phone": "138..."}).Post("正常注册", 0, Map{"id": notEmpty}) //
| 日期 | 内容 |
|------|------|
| 2026-07-13 | 测试框架文档补齐 Flows/控制台;缺口清单链到 Testing |
| 2026-01-24 | 初始版本,记录分析结果和待改进项 |
| 2026-03-15 | 新增测试框架响应字段断言改进规划(七) |
| 2026-03-20 | 完成达梦(DM8)数据库完整支持:Dialect 适配(MERGE INTO Upsert、双引号标识符)、LastInsertId、保留字自动引号、schema 正确处理、代码生成器 DM 兼容、缓存表自动建表、测试框架 DM 适配 |
+132 -59
View File
@@ -14,18 +14,16 @@
- [快速开始](#快速开始)
- [运行测试(推荐:-run 单测)](#运行测试推荐-run-单测)
- [用例编写范式](#用例编写范式)
- [错误用例编写规范](#1-错误用例编写规范)
- [测试数据准备](#2-测试数据准备)
- [正确请求与响应结构校验](#3-正确请求与响应结构校验)
- [Verify 统一校验](#4-verify-统一校验)
- [简写支持说明](#简写支持说明)
- [核心类型](#核心类型)
- [业务流程联调(Flows](#业务流程联调flows)
- [优雅停测](#优雅停测)
- [链式 API 参考](#链式-api-参考)
- [事务隔离机制](#事务隔离机制)
- [API 调试控制台](#api-调试控制台)
- [并发保护与缓存隔离](#并发保护与缓存隔离)
- [二进制与非 JSON 响应校验](#二进制与非-json-响应校验)
- [全量回归与覆盖率(仅 CI / 发版前)](#全量回归与覆盖率仅-ci--发版前)
- [已知局限与后续](#已知局限与后续)
---
@@ -41,8 +39,10 @@ HoTime 内置 API 测试框架,核心能力:
| **并发保护** | `testMu` 互斥锁自动保护 `testTx` 单连接,业务代码中的 `go func()` 协程不会导致 `busy buffer` |
| **缓存隔离** | 测试启动时自动禁用 DB/Redis 缓存,避免缓存操作与测试事务锁冲突 |
| **链式 API** | `a.JSON(...).Post("描述", 期望status)` 一行完成:数据组装 + 请求 + 断言 |
| **覆盖率** | 自动对比路由注册表与测试定义,输出哪些接口已覆盖、哪些未覆盖 |
| **调试控制台** | 按模块生成独立的交互式 API 调试控制台,支持在线测试、认证管理、参数编辑 |
| **Flows** | 多接口业务流程联调;`Group`/`Desc`/`Prep``FromCase` 绑定单接口用例并回写 |
| **覆盖率** | 自动对比路由注册表与测试定义(**不含 Flow 步骤**,只统计单接口 path) |
| **调试控制台** | 增量写入 swagger;流程分组 UI;发送结果 cURL 实时同步左侧参数 |
| **优雅停测** | SIGINT/SIGTERM / `RequestStop`:停收新测、跑完当前、回滚事务、保留已落盘文档 |
---
@@ -184,15 +184,15 @@ var ProjectTest = ProjTest{
var testApp *TestApp
func TestMain(m *testing.M) {
os.Chdir("..") // 确保工作目录为项目根目录
testApp = NewTestApp("config/config.json", TestProj{
"app": {Proj: Project, Tests: ProjectTest},
})
code := m.Run()
// 以下两行适合 CI / 全量回归;日常用 -run 单测时也会执行,但覆盖率主要在全量时有意义
testApp.PrintCoverage()
testApp.GenerateSwagger("My API", "1.0.0", testApp.Config.GetString("tpt"))
os.Exit(code)
testApp = Test("config/config.json").
WorkDir("..").
Swagger("My API", "1.0.0").
Proj(TestProj{
"app": {Proj: Project, Tests: ProjectTest},
})
// 可选:.Flows(FlowTest{ "checkout": {Desc: "下单支付跑通", Func: checkoutFlow} })
os.Exit(testApp.Run(m))
}
func TestApi(t *testing.T) {
@@ -200,6 +200,8 @@ func TestApi(t *testing.T) {
}
```
`Run(m)` 内部会:注册优雅停测信号 → `m.Run()` → 覆盖率 → Swagger 收尾(接口级增量写入)。
`WorkDir` / `Swagger` / `Flows` 均可省略;不写 `Swagger` 则不生成调试控制台。
### 推荐目录结构
```
@@ -233,7 +235,7 @@ go test ./app/ -v -run "TestApi/app/(user|order)"
go test ./app/ -v -run "TestApi/.*/.*未登录"
```
> **注意**`TestMain` 中的 `os.Chdir("..")` 确保 `go test ./app/` 的工作目录为项目根目录,使配置文件路径和模板输出路径正确。
> **注意**`WorkDir("..")` 在 Init 前切换工作目录,确保 `go test ./app/` 时配置与模板输出路径正确。
>
> 全量 `go test ./app/... -v`(不带 `-run`)会跑完所有用例并触发覆盖率扫描,项目变大后很慢且容易牵连无关失败。**仅在 CI / 发版前使用**,说明见文末。
@@ -511,25 +513,82 @@ type ApiTestDef struct {
### TestApp
```go
// NewTestApp 创建测试应用
// configPath: 配置文件路径
// projects: TestProj 注册所有项目的路由和测试
// listeners: 可选,与 SetConnectListener 相同的请求拦截器
func NewTestApp(configPath string, projects TestProj, listeners ...func(*Context) bool) *TestApp
// Test 链式入口(延迟 Init,便于先 WorkDir
func Test(configPath string, listeners ...func(*Context) bool) *TestApp
func (app *TestApp) WorkDir(dir string) *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 收尾
// RunTests 运行所有注册的测试(每个方法级别自动事务隔离)
func (app *TestApp) RunTests(t *testing.T)
// PrintCoverage 输出覆盖率报告,返回报告结构体
func (app *TestApp) PrintCoverage() CoverageReport
// GenerateSwagger 生成 API 调试控制台(api-spec.json + index.html
func (app *TestApp) GenerateSwagger(title, version, outputDir string) error
// DB 获取测试应用的数据库实例(在事务内)
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
- 无模板时当前为**静默不填**(见 [已知局限](#已知局限与后续));建议全量或先跑绑定接口再跑 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 参考
@@ -546,7 +605,9 @@ func (app *TestApp) DB() *HoTimeDB
| `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` |
@@ -566,6 +627,7 @@ func (app *TestApp) DB() *HoTimeDB
| `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` |
@@ -720,39 +782,33 @@ that.Db.Action(func(db HoTimeDB) bool {
## API 调试控制台
`GenerateSwagger` 按模块生成独立的交互式 API 调试控制台(暗色高对比度主题),每个模块一个子目录,支持独立分发
通过链式 `.Swagger(title, version)` 启用
```go
testApp.GenerateSwagger(
"My API", // 文档标题
"2.1.0", // 版本号
testApp.Config.GetString("tpt"), // 输出目录(如 "tpt"
)
```
**增量写入**:每个接口 / 流程跑完即 flush;本轮无记录则不覆盖成「未测」;未全量跑完不 prune 已删接口;仅全量完成才清理孤儿模块。Ctrl+C / SIGTERM 走优雅停测后再收尾。
输出目录默认 `config` 中的 `tpt`
### 生成目录结构
每个模块(`TestProj` 中的项目名)生成独立子目录,同时自动生成根导航页:
```
{outputDir}/swagger/
├── index.html ← 根导航页(API 文档中心,卡片式链接到各模块)
├── api/
│ ├── api-spec.json ← api 模块的 API 规范(路由、测试用例、请求/响应数据)
│ └── index.html ← api 模块的调试控制台
├── admin/
│ ├── api-spec.json
├── index.html ← 根导航页
├── app/
│ ├── api-spec.json ← endpoints + flows
│ └── index.html
└── portal/
├── api-spec.json
└── index.html
└── ...
```
访问地址`http://localhost:8081/swagger/`(根导航),`http://localhost:8081/swagger/api/`api 模块)
访问:`http://localhost:8081/swagger/`(根导航),`http://localhost:8081/swagger/app/`(模块)
> **独立分发**:每个模块目录可以单独拷贝和部署,不依赖其他模块。根导航页自动扫描子目录生成链接。
### 控制台能力
控制台支持三级导航、关键字搜索、用例预填、全局认证(Header/URL/Cookie)、cURL 生成、覆盖率可视化等功能。
| 区域 | 行为 |
|------|------|
| 侧栏 | 业务流程 → group → flow 名;接口仍按 project/ctr 三级 |
| 流程页 | Prep 业务说明;步骤默认展开;左备注/关联验收用例/请求(cURL),右响应;点 path 弹窗只读详情(Esc 关闭) |
| 单接口 | 用例预填、全局认证;**发送结果 cURL 随左侧 Header/Query/JSON/Form 实时同步** |
| 发送 | 浏览器 `fetch` 打当前源站,**需已启动服务**(与 httptest 零启动测试不同) |
---
@@ -761,7 +817,7 @@ testApp.GenerateSwagger(
框架自动处理以下问题,编写测试时无需关心:
- **并发保护**`testTx` 是单连接,框架通过 `testMu` 互斥锁自动序列化所有数据库操作,业务代码中的 `go func()` 协程不会导致 `busy buffer` 错误
- **缓存隔离**`NewTestApp` 启动时自动禁用 DB/Redis 缓存,避免与测试事务产生锁冲突;Session 通过 `WithSession()` 直接注入 Memory 缓存
- **缓存隔离**:启动时自动禁用 DB/Redis 缓存,避免与测试事务产生锁冲突;Session 通过 `WithSession()` 直接注入 Memory 缓存
### 一次性数据的处理
@@ -865,21 +921,21 @@ a.Verify(func(a *Api) error {
go test ./app/... -v
```
`TestMain` 里常见的收尾逻辑(全量跑完后才有意义):
`TestMain` 推荐写法(`Run` 已含覆盖率与 Swagger 收尾):
```go
func TestMain(m *testing.M) {
// ...
code := m.Run()
testApp.PrintCoverage() // 全量跑完后输出覆盖率
testApp.GenerateSwagger("My API", "1.0.0", testApp.Config.GetString("tpt"))
os.Exit(code)
testApp = Test("config/config.json").
WorkDir("..").
Swagger("My API", "1.0.0").
Proj(TestProj{"app": {Proj: Project, Tests: ProjectTest}})
os.Exit(testApp.Run(m))
}
```
### 覆盖率报告
`PrintCoverage()` 自动对比 `Proj`(所有路由)和 `ProjTest`(有测试的路由)
`PrintCoverage()` 自动对比 `Proj`(所有路由)和 `ProjTest`(有测试的路由)。**Flow 步骤不计入覆盖率**`FlowName != ""` 的记录会被跳过)。
**输出示例(全部通过):**
@@ -937,3 +993,20 @@ 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)。