# 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 完全一致的场景。 **跳过错误消息断言(AnyMsg)**:`a.AnyMsg().Post("无效授权码", 1)` — 当 `status != 0` 但错误 msg 含动态内容(网络拨号错误、第三方 API 回传文案等)无法全等断言时,用 `AnyMsg()` 仅断言 status、跳过 msg 校验(同时也不再用 desc 兜底匹配)。`Api` 与 `ApiCase` 均可链式调用,如 `a.JSON(...).AnyMsg().Post(...)`。样例见 `example/app/expect_demo_test.go` 的 `error_demo`。 **省略 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)。