# HoTime API 测试框架使用说明 不启动 HTTP 服务、自动事务回滚、链式 API、覆盖率报告、API 调试控制台生成。 ## 目录 - [概述](#概述) - [快速开始](#快速开始) - [核心类型](#核心类型) - [链式 API 参考](#链式-api-参考) - [完整场景对照](#完整场景对照) - [事务隔离机制](#事务隔离机制) - [覆盖率报告](#覆盖率报告) - [api-spec.json 覆盖率字段说明](#api-specjson-覆盖率字段说明) - [params 参数推断说明](#api-specjson-覆盖率字段说明) - [API 调试控制台](#api-调试控制台) - [指定运行范围](#指定运行范围) - [并发保护与缓存隔离](#并发保护与缓存隔离) - [框架改动说明](#框架改动说明) --- ## 概述 HoTime 内置 API 测试框架,核心能力: | 能力 | 说明 | |------|------| | **零启动** | 基于 `net/http/httptest`,不需要启动 HTTP 服务器 | | **事务回滚** | 每个接口方法独立开启数据库事务,测试结束自动回滚,数据库不留任何痕迹 | | **SAVEPOINT** | 业务代码中的 `that.Db.Action()` 在测试模式下自动用 SAVEPOINT 替代真事务,嵌套事务完整可用 | | **并发保护** | `testMu` 互斥锁自动保护 `testTx` 单连接,业务代码中的 `go func()` 协程不会导致 `busy buffer` | | **缓存隔离** | 测试启动时自动禁用 DB/Redis 缓存,避免缓存操作与测试事务锁冲突 | | **链式 API** | `a.JSON(...).Post("描述", 期望status)` 一行完成:数据组装 + 请求 + 断言 | | **覆盖率** | 自动对比路由注册表与测试定义,输出哪些接口已覆盖、哪些未覆盖 | | **调试控制台** | 按模块生成独立的交互式 API 调试控制台,支持在线测试、认证管理、参数编辑 | --- ## 快速开始 ### 第一步:创建测试定义文件(推荐) **推荐方式**:为每个 handler 创建对应的 `_test.go` 文件,测试定义与业务代码分离。`_test.go` 文件仅在 `go test` 时编译,不影响生产构建。 ```go // app/user.go — 只有业务代码 package app import . "code.hoteas.com/golang/hotime" import . "code.hoteas.com/golang/hotime/common" var UserCtr = Ctr{ "login": func(that *Context) { /* ... */ }, "info": func(that *Context) { /* ... */ }, "create": func(that *Context) { /* ... */ }, } ``` ```go // app/user_test.go — 测试定义 package app import . "code.hoteas.com/golang/hotime" import . "code.hoteas.com/golang/hotime/common" var UserTest = CtrTest{ "login": {Desc: "用户登录", Func: func(a *Api) { a.JSON(Map{"name": "13800138000", "password": "123456"}).Post("正常登录", 0) a.JSON(Map{"name": "13800138000", "password": ""}).Post("密码为空", 4, "用户名或密码不能为空") a.JSON(Map{"name": "1380013", "password": "123456"}).Post("手机号格式错误", 4, "手机号码格式错误") }}, "create": {Desc: "用户注册", Func: func(a *Api) { phone := "138" + ObjToStr(RandX(10000000, 99999999)) a.JSON(Map{"phone": phone, "password": "123456"}).Post("正常注册", 0) a.JSON(Map{"phone": phone, "password": "123456"}).Post("重复注册", 3, "该手机号已被注册") }}, } ``` > **为什么要分离?** `_test.go` 文件仅在 `go test` 时编译,不会进入生产二进制,保持业务代码干净。同时符合 Go 语言惯例:测试相关代码放在 `_test.go` 文件中。 ### 第二步:注册路由和测试 路由在 `init.go` 中注册,测试注册放在 `app_test.go` 中(因为 `_test.go` 中的变量只在测试编译时可见): ```go // app/init.go — 只注册路由 var Project = Proj{ "user": UserCtr, // ... } ``` ```go // app/app_test.go — 注册测试 + 运行入口 package app import ( "os" "testing" . "code.hoteas.com/golang/hotime" ) var ProjectTest = ProjTest{ "user": UserTest, } var testApp *TestApp func TestMain(m *testing.M) { os.Chdir("..") // 确保工作目录为项目根目录 testApp = NewTestApp("config/config.json", TestProj{ "app": {Proj: Project, Tests: ProjectTest}, }) code := m.Run() testApp.PrintCoverage() testApp.GenerateSwagger("My API", "1.0.0", testApp.Config.GetString("tpt")) os.Exit(code) } func TestApi(t *testing.T) { testApp.RunTests(t) } ``` ### 推荐目录结构 ``` app/ ├── user.go ← 业务代码 (UserCtr) ├── user_test.go ← 测试定义 (UserTest) ├── tags.go ← 业务代码 (TagsCtr) ├── tags_test.go ← 测试定义 (TagsTest) ├── init.go ← 路由注册 (Project) └── app_test.go ← 测试注册 (ProjectTest) + TestMain + TestApi ``` ### 运行测试 ```bash go test ./app/... -v ``` > **注意**:`TestMain` 中的 `os.Chdir("..")` 确保 `go test ./app/` 的工作目录为项目根目录,使配置文件路径和模板输出路径正确。 --- ## 核心类型 ### 注册类型 ```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 // NewTestApp 创建测试应用 // configPath: 配置文件路径 // projects: TestProj 注册所有项目的路由和测试 // listeners: 可选,与 SetConnectListener 相同的请求拦截器 func NewTestApp(configPath string, projects TestProj, listeners ...func(*Context) bool) *TestApp // 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 ``` --- ## 链式 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.WithSession(s)` | 返回携带 session 的新 Api | `*Api` | | `a.Get(desc, status, msg...)` | 直接 GET 请求(无数据场景) | `*ApiResponse` | | `a.Post(desc, status, msg...)` | 直接 POST 请求(无数据场景) | `*ApiResponse` | | `a.Put(desc, status, msg...)` | 直接 PUT 请求 | `*ApiResponse` | | `a.Delete(desc, status, msg...)` | 直接 DELETE 请求 | `*ApiResponse` | | `a.DB()` | 获取数据库实例(在事务内,可用于准备/清查数据) | `*HoTimeDB` | ### 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.Verify(fn)` | 设置请求后的自定义校验函数(详见 [Verify 数据库校验](#verify-数据库校验)) | `*ApiCase` | | `c.Get(desc, status, msg...)` | 终端:发送 GET 请求并断言 | `*ApiResponse` | | `c.Post(desc, status, msg...)` | 终端:发送 POST 请求并断言 | `*ApiResponse` | | `c.Put(desc, status, msg...)` | 终端:发送 PUT 请求并断言 | `*ApiResponse` | | `c.Delete(desc, status, msg...)` | 终端:发送 DELETE 请求并断言 | `*ApiResponse` | **终端方法参数说明:** | 参数 | 类型 | 说明 | |------|------|------| | `desc` | string | 用例描述,作为 `t.Run` 的子测试名称 | | `status` | int | 期望的 JSON 响应体中 `status` 字段值(0=成功) | | `expect` | ...interface{} | 可选,行为取决于 status(见下方说明) | **expect 参数解析规则:** - `status == 0`(成功): - **不传 expect** → 仅校验 status=0,不校验 result 内容(如 `a.Post("描述", 0)`) - 传任意类型 → result 类型/结构校验(因为成功响应没有 msg) - `status != 0`(错误): - 传 `string` → 用该字符串校验错误消息 - **不传 expect** → 自动用 `desc`(第一参数)作为期望的错误消息(简写模式) - 传非 string 类型 → 按 status=0 的规则做 result 结构校验(罕见但支持) **简写示例**(错误用例 desc 即 msg): ```go // 完整写法 a.Post("密码为空", 4, "用户名或密码不能为空") // desc="密码为空", 校验 msg="用户名或密码不能为空" // 简写:desc 与错误消息相同时可省略第三参数 a.Post("用户名或密码不能为空", 4) // desc 直接作为期望 msg a.Get("请选择店铺", 3) // 等价于 a.Get("请选择店铺", 3, "请选择店铺") ``` **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`,可用于进一步的自定义断言: ```go resp := a.JSON(Map{"name": "user", "password": "123"}).Post("正常登录", 0) // 获取响应字段 resp.GetStatus() // int:业务 status resp.GetMsg() // string:业务 msg resp.GetResult() // interface{}:result 字段 resp.GetBody() // Map:完整响应体 // 后置结构校验(支持 Map、Slice、string、int64 等任意类型样本) resp.ExpectResult(Map{"id": int64(1), "token": "sample"}) resp.ExpectResult("操作成功") // 校验 result 是 string 类型 // 自定义断言 token := resp.GetBody().GetMap("result").GetString("token") if token == "" { resp.Fail("缺少 token 字段") } ``` --- ## 完整场景对照 以下示例均写在 `_test.go` 文件的 `CtrTest` 定义中(如 `user_test.go`),`a` 为 `*Api` 参数: ```go // POST JSON(最常见) a.JSON(Map{"name": "138", "password": "123"}).Post("正常登录", 0) // GET 无参数 a.Get("未登录", 2, "请先登录") // GET + URL 参数 a.Query(Map{"shop_id": "1", "page": "1"}).Get("查询列表", 0) // 需登录 + GET a.WithSession(Map{"user_id": int64(1)}).Get("已登录", 0) // 需登录 + GET + URL 参数 a.WithSession(Map{"user_id": int64(1)}).Query(Map{"shop_id": "1"}).Get("带参查询", 0) // 需登录 + POST JSON a.WithSession(Map{"user_id": int64(1)}).JSON(Map{"name": "新名字"}).Post("更新信息", 0) // 混合:URL 参数 + JSON body a.Query(Map{"token": "abc"}).JSON(Map{"phone": "138"}).Post("混合传参", 3, "异常") // POST Form 表单 a.Form(Map{"shop_id": "1", "id": "5"}).Post("表单提交", 0) // 文件上传 a.File("file", "photo.png", imgBytes).Post("上传文件", 0) // 文件 + 表单字段混合 a.File("file", "photo.png", imgBytes).Form(Map{"type": "avatar"}).Post("上传头像", 0) // PUT a.JSON(Map{"name": "新名字"}).Put("更新", 0) // DELETE + URL 参数 a.Query(Map{"id": "5"}).Delete("删除", 0) // 带备注的用例(备注显示在调试控制台用例详情中,没有备注时不显示) a.Form(Map{"phone": "138", "code": "1234"}).Note("sign = MD5(smsProxyKey+timestamp),smsProxyKey 见 config.json").Post("正常发送", 0) a.Note("需先登录后在 Header 中携带 Authorization token").JSON(Map{"name": "新名字"}).Post("更新信息", 0) // 校验响应中的具体字段 resp := a.JSON(Map{"name": "138", "password": "123"}).Post("正常登录", 0) if resp.GetBody().GetMap("result").GetString("token") == "" { resp.Fail("响应中缺少 token 字段") } // ========== Result 类型/结构校验 ========== // result 是原始类型:校验 result 是 string a.Post("操作成功", 0, "操作成功") // result 是整数 a.Get("获取数量", 0, int64(1)) // result 是小数 a.Get("获取金额", 0, float64(1.5)) // result 是 Map:校验字段名 + 类型 a.Query(Map{"shop_id": "1", "id": "1"}).Get("正常获取订单", 0, Map{ "id": int64(1), "sn": "sample", "customer_id": int64(1), }) // 嵌套对象:递归校验子字段 a.Get("获取详情", 0, Map{ "id": int64(1), "customer": Map{"name": "张三", "id": int64(1)}, }) // result 直接是数组:校验 result 是 Slice 且首元素结构匹配 a.Get("获取标签", 0, Slice{Map{"id": int64(1), "name": "test"}}) // Map 中包含数组字段 a.JSON(Map{"page": 1, "pageSize": 10}).Post("获取列表", 0, Map{ "total": int64(10), "data": Slice{Map{"id": int64(1), "name": "test"}}, }) // 后置校验 resp = a.Get("获取配置", 0) resp.ExpectResult(Map{"version": "1.0", "features": Slice{}}) // ========== Verify 数据库校验 ========== // 请求后自动查库验证,返回 nil 表示通过,返回 error 则用例失败 a.Form(Map{"name": "test"}). Verify(func(a *Api) error { row := a.DB().Get("order", "*", Map{"name": "test"}) if row == nil { return fmt.Errorf("未找到 name=test 的记录") } if row.GetCeilInt64("state") != 0 { return fmt.Errorf("state 期望 0, 实际 %d", row.GetCeilInt64("state")) } // 校验关联表数量 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("创建并验证DB", 0, Map{"id": int64(1)}) ``` --- ## 事务隔离机制 ### 工作原理 `RunTests` 在每个**方法级**测试前开启一个数据库事务(`testTx`),测试结束后无论成功与否一律回滚: ``` TestApi └── app(项目名) └── user(控制器名) └── create(方法名)← 在这一级 BEGIN → 运行测试用例 → ROLLBACK └── 正常注册(用例) └── 重复注册(用例) ``` 所有测试用例共享同一个 `testTx`,因此"正常注册"写入的数据对"重复注册"可见,模拟了真实的并发隔离场景。 ### 嵌套事务 (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) | --- ## 覆盖率报告 `PrintCoverage()` 自动对比 `Proj`(所有路由)和 `ProjTest`(有测试的路由),在 `TestMain` 中调用: ```go func TestMain(m *testing.M) { // ... code := m.Run() testApp.PrintCoverage() // 在测试运行完成后输出报告 os.Exit(code) } ``` **输出示例:** ``` ========== API 测试覆盖率报告 ========== 总接口: 42 | 已覆盖: 6 | 覆盖率: 14.3% 已覆盖的接口: + /api/user/login 4 用例 (4 通过, 0 失败) 12ms + /api/user/info 2 用例 (2 通过, 0 失败) 5ms + /api/user/token 2 用例 (2 通过, 0 失败) 4ms + /api/user/create 4 用例 (4 通过, 0 失败) 18ms + /api/user/forget 2 用例 (2 通过, 0 失败) 6ms + /api/user/file 2 用例 (2 通过, 0 失败) 9ms 未覆盖的接口: - api/goods/list - api/goods/create - api/order/list - ... (共36个未覆盖) ======================================== ``` ### api-spec.json 覆盖率字段说明 `GenerateSwagger` 生成的每个 `api-spec.json` 文件中,每条 `endpoint` 记录包含以下与测试状态相关的字段: ```json { "path": "/api/user/login", "project": "api", "ctr": "user", "method": "POST", "summary": "用户登录", "tested": true, "params": [ { "name": "name", "required": true, "type": "string", "example": "13800138000", "in": "json" }, { "name": "password", "required": false, "type": "string", "example": "123456", "in": "json" } ], "cases": [ { "name": "正常登录", "method": "POST", "passed": true, "note": "备注内容(可选,未调用 .Note() 则不出现该字段)", "query": {}, "json": { "name": "13800138000", "password": "123456" }, "response": { "status": 0, "msg": "ok", "data": {} } }, { "name": "密码为空", "method": "POST", "passed": true, "json": { "name": "13800138000", "password": "" }, "response": { "status": 4, "msg": "用户名或密码不能为空" } } ] } ``` | 字段 | 类型 | 说明 | |------|------|------| | `tested` | bool | 是否在 `CtrTest` 中定义了测试,`false` = 未测试 | | `params` | array | 从测试用例自动推断的参数规格列表 | | `params[].name` | string | 参数名称 | | `params[].required` | bool | 是否必填(判断依据:该参数出现且值为空、同时 response.status==3 的用例存在) | | `params[].type` | string | 推断的参数类型(`string` / `number` / `boolean` / `array` / `object`) | | `params[].example` | any | 取第一个 status==0 成功用例中该参数的值作为示例 | | `params[].in` | string | 参数位置:`query` / `form` / `json` | | `cases` | array | 实际运行的测试用例列表;若 `tested=false` 则为空数组 | | `cases[].passed` | bool | 该用例是否通过(断言的 status/message 均匹配) | | `cases[].note` | string | 用例备注(可选),由 `.Note("...")` 设置,为空时不输出该字段 | | `cases[].query` | object | 该用例的 Query 参数(GET 场景) | | `cases[].json` | object | 该用例的 JSON Body(`a.JSON(...)` 场景) | | `cases[].form` | object | 该用例的 Form 参数(`a.Form(...)` 场景) | | `cases[].response` | object | 该用例的实际响应体 | **侧边栏徽章规则**(调试控制台): | 显示 | 含义 | |------|------| | `未测试`(灰色) | `tested=false`,该接口没有任何测试用例 | | `N/N`(绿色) | 所有用例通过,如 `4/4` | | `N/N`(红色) | 存在失败用例,如 `2/4` | --- ## API 调试控制台 `GenerateSwagger` 按模块生成独立的交互式 API 调试控制台(暗色高对比度主题),每个模块一个子目录,支持独立分发。 ```go testApp.GenerateSwagger( "My API", // 文档标题 "2.1.0", // 版本号 testApp.Config.GetString("tpt"), // 输出目录(如 "tpt") ) ``` ### 生成目录结构 每个模块(`TestProj` 中的项目名)生成独立子目录,同时自动生成根导航页: ``` {outputDir}/swagger/ ├── index.html ← 根导航页(API 文档中心,卡片式链接到各模块) ├── api/ │ ├── api-spec.json ← api 模块的 API 规范(路由、测试用例、请求/响应数据) │ └── index.html ← api 模块的调试控制台 ├── admin/ │ ├── api-spec.json │ └── index.html └── portal/ ├── api-spec.json └── index.html ``` 访问地址:`http://localhost:8081/swagger/`(根导航页),`http://localhost:8081/swagger/api/`(api 模块) > **独立分发**:每个模块目录可以单独拷贝和部署,不依赖其他模块。根导航页自动扫描子目录生成链接。 ### 控制台功能 | 功能 | 说明 | |------|------| | **三级导航** | 左侧侧边栏按 项目 > 控制器 > 方法 三级树形展示(一级默认展开,二级默认收起) | | **搜索和筛选** | 支持关键字搜索,按 全部/已测试/未测试/通过/未通过 五种模式筛选 | | **左右分栏** | 接口信息栏右侧显示用例数量(`用例: N`);左侧为请求构建器,右侧为结果面板(两 tab 切换) | | **测试用例 tab** | 右侧默认展示"测试用例"tab,手风琴列表(第一个默认展开);必填参数前显示红色 `*` 标记;用例设置了 `.Note(...)` 时在展开体和选中面板顶部显示灰色备注文字 | | **发送结果 tab** | 点击"发送"后自动切换到"发送结果"tab 展示 cURL 命令和响应内容;可随时手动切回 | | **必填参数推断** | 从测试用例自动推断必填参数(有 status==3 且值为空的用例),调试面板和用例展示均标记 `*` | | **预填用例** | 默认选中第一个用例自动填充左侧所有参数;"手动填写"置于列表末尾;必填字段有 `*` 标记 | | **全局认证** | 顶部认证栏支持四种模式:无认证、Header (Authorization)、URL (?token=)、Cookie,修改后实时同步到各参数区域 | | **Cookie 隔离** | Header/URL 认证模式下自动阻止浏览器发送 Cookie(`credentials: 'omit'`),避免意外自动授权 | | **cURL 生成** | 每次请求自动生成对应的 cURL 命令,支持一键复制 | | **响应格式化** | JSON 响应自动格式化显示,支持复制 | | **覆盖率可视化** | 已测试接口显示通过/失败计数徽章,未测试接口标记"未测试" | ### 侧边栏结构 ``` ├── user(控制器,默认展开) ← 一级目录 │ ├── POST login 用户登录 ✅ 4/4 │ ├── POST info 未测试 │ └── POST create 用户注册 ✅ 4/4 ├── goods(控制器,点击展开) ← 二级目录 │ └── ... └── order └── ... ``` --- ## 指定运行范围 `RunTests` 使用 Go 标准 `t.Run()` 子测试机制,天然支持 `-run` 参数过滤: ```bash # 运行全部测试 go test ./app/... -v # 只跑 user 控制器的所有接口 go test ./app/... -v -run TestApi/app/user # 只跑 user/login 接口 go test ./app/... -v -run TestApi/app/user/login # 只跑 login 接口中"密码为空"这一个用例 go test ./app/... -v -run "TestApi/app/user/login/密码为空" # 同时跑 user 和 goods 两个控制器 go test ./app/... -v -run "TestApi/app/(user|goods)" # 跑所有控制器中包含"未登录"的用例 go test ./app/... -v -run "TestApi/.*/.*未登录" ``` 子测试层级结构: ``` TestApi └── {项目名} ← -run TestApi/app └── {控制器名} ← -run TestApi/app/user └── {方法名} ← -run TestApi/app/user/login └── {ApiTestDef.Desc}(接口描述) └── {用例描述} ← -run TestApi/app/user/login/密码为空 ``` --- ## 并发保护与缓存隔离 ### testMu 互斥锁 `testTx` 是单个 `*sql.Tx` 连接,MySQL 驱动不允许在同一连接上并发执行查询。业务代码中的 `go func()` 协程(如异步同步、统计任务)会尝试并发访问 `testTx`,导致 `busy buffer` 错误。 框架通过 `testMu *sync.Mutex` 自动序列化所有 `testTx` 上的操作: | 操作 | 保护范围 | |------|----------| | `testTx.Query()` | 加锁 → 执行查询 → 消费结果集 → 解锁 | | `testTx.Exec()` | 加锁 → 执行 → 解锁 | | SAVEPOINT 操作 | 加锁 → 执行 → 解锁 | 生产环境不受影响(`testMu` 默认 nil,所有锁检查都有 nil 保护)。 ### 缓存隔离 `NewTestApp` 启动时自动调用 `HoTimeCache.DisableDbCache()`,禁用 DB 和 Redis 缓存层,仅保留 Memory 缓存: - 避免缓存的 `DELETE FROM cached` 操作与测试事务产生锁冲突(`Lock wait timeout`) - Session 通过 `WithSession()` 直接注入 Memory 缓存,不依赖 DB/Redis - 生产环境不受影响(仅在 `NewTestApp` 中调用) ### 一次性数据的处理 对于需要随机数据的测试(如注册),直接用 `RandX` 生成随机值即可,**无需手动清理**: ```go "create": {Desc: "用户注册", Func: func(a *Api) { phone := "138" + ObjToStr(RandX(10000000, 99999999)) a.JSON(Map{"phone": phone, "password": "123456"}).Post("正常注册", 0) a.JSON(Map{"phone": phone, "password": "123456"}).Post("重复注册", 3, "该手机号已被注册") }}, ``` --- ## 框架改动说明 测试框架对 `hotimev1.5` 共改动 9 个文件,其中 6 个为修改、3 个为新增: ### 修改的文件 | 文件 | 改动 | 生产影响 | |------|------|----------| | `db/db.go` | 新增 `testTx *sql.Tx` + `testMu *sync.Mutex` 字段,`BeginTestTx()`(初始化互斥锁)/ `RollbackTestTx()` 方法 | 无(`testTx` 默认 nil) | | `db/query.go` | `Query` 和 `Exec` 各增加 `if testTx != nil` 分支 + `testMu` 互斥锁保护 + 跳过重试逻辑 | 无(分支默认跳过) | | `db/transaction.go` | `Action()` 增加 SAVEPOINT 分支 + `testTx`/`testMu` 传递 + SAVEPOINT 操作加锁 | 无(`testTx` 为 nil 时走原有逻辑) | | `db/crud.go` | `Select` 中 `testTx` 激活时跳过缓存逻辑 | 无(分支默认跳过) | | `cache/cache.go` | 新增 `DisableDbCache()` 方法 + 新增 `SessionsGet`/`SessionsSet`/`SessionsDelete` 批量操作 | 无(方法不主动调用) | | `session.go` | 对接批量 Session 缓存操作 | 无 | ### 新增的文件 | 文件 | 内容 | |------|------| | `testing_helper.go` | `TestApp`、`NewTestApp`(含 `DisableDbCache` 调用)、`SetupForTest`、`RunTests`(事务隔离)、`PrintCoverage`(覆盖率) | | `testing_api.go` | 注册类型 + `Api`/`ApiCase`/`ApiResponse` 链式 API + `TestCollector` | | `testing_swagger.go` | `GenerateSwagger`:按模块生成子目录 + 根导航页 + 交互式调试控制台 | > 所有新增文件仅在 `go test` 环境下被使用,生产构建不引入 `testing` 包依赖,完全不影响线上服务。所有修改的文件都通过 nil 检查保护,`testTx`/`testMu` 默认为 nil,生产代码路径不受任何影响。