hotime/docs/Testing_API测试框架.md
hoteas 309f113a6f feat(api): 添加备注功能以增强用例描述
- 在 Api 和 ApiCase 中新增 Note 方法,允许为用例设置可选备注
- 更新 TestRecord 结构体,包含备注字段以便记录用例信息
- 修改 Swagger 生成逻辑,支持备注字段的输出
- 更新文档,详细说明 Note 方法的使用及其在调试控制台的显示效果
2026-03-15 11:45:21 +08:00

23 KiB
Raw Permalink Blame History

HoTime API 测试框架使用说明

不启动 HTTP 服务、自动事务回滚、链式 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 时编译,不影响生产构建。

// 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) { /* ... */ },
}
// 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 中的变量只在测试编译时可见):

// app/init.go — 只注册路由
var Project = Proj{
    "user": UserCtr,
    // ...
}
// 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

运行测试

go test ./app/... -v

注意TestMain 中的 os.Chdir("..") 确保 go test ./app/ 的工作目录为项目根目录,使配置文件路径和模板输出路径正确。


核心类型

注册类型

// 顶层:多项目集合(项目名 → 定义)
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

// 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.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=成功)
msg ...string 可选,期望的 msg 字段值(传空则不校验)

ApiResponse — 响应对象

终端方法返回 *ApiResponse,可用于进一步的自定义断言:

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完整响应体

// 自定义断言
token := resp.GetBody().GetMap("result").GetString("token")
if token == "" {
    resp.Fail("缺少 token 字段")
}

完整场景对照

以下示例均写在 _test.go 文件的 CtrTest 定义中(如 user_test.goa*Api 参数:

// 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 字段")
}

事务隔离机制

工作原理

RunTests 在每个方法级测试前开启一个数据库事务(testTx),测试结束后无论成功与否一律回滚:

TestApi
└── app项目名
    └── user控制器名
        └── create方法名← 在这一级 BEGIN → 运行测试用例 → ROLLBACK
            └── 正常注册(用例)
            └── 重复注册(用例)

所有测试用例共享同一个 testTx,因此"正常注册"写入的数据对"重复注册"可见,模拟了真实的并发隔离场景。

嵌套事务 (SAVEPOINT)

业务代码中常见的 that.Db.Action() 在测试模式下自动切换为 SAVEPOINT 机制,不会脱离外层测试事务:

// 业务代码(无需修改)
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 中调用:

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 记录包含以下与测试状态相关的字段:

{
  "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 Bodya.JSON(...) 场景)
cases[].form object 该用例的 Form 参数(a.Form(...) 场景)
cases[].response object 该用例的实际响应体

侧边栏徽章规则(调试控制台):

显示 含义
未测试(灰色) tested=false,该接口没有任何测试用例
N/N(绿色) 所有用例通过,如 4/4
N/N(红色) 存在失败用例,如 2/4

API 调试控制台

GenerateSwagger 按模块生成独立的交互式 API 调试控制台(暗色高对比度主题),每个模块一个子目录,支持独立分发。

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 认证模式下自动阻止浏览器发送 Cookiecredentials: 'omit'),避免意外自动授权
cURL 生成 每次请求自动生成对应的 cURL 命令,支持一键复制
响应格式化 JSON 响应自动格式化显示,支持复制
覆盖率可视化 已测试接口显示通过/失败计数徽章,未测试接口标记"未测试"

侧边栏结构

├── user控制器默认展开      ← 一级目录
│   ├── POST login 用户登录          ✅ 4/4
│   ├── POST info                    未测试
│   └── POST create 用户注册         ✅ 4/4
├── goods控制器点击展开     ← 二级目录
│   └── ...
└── order
    └── ...

指定运行范围

RunTests 使用 Go 标准 t.Run() 子测试机制,天然支持 -run 参数过滤:

# 运行全部测试
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 生成随机值即可,无需手动清理

"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 QueryExec 各增加 if testTx != nil 分支 + testMu 互斥锁保护 + 跳过重试逻辑 无(分支默认跳过)
db/transaction.go Action() 增加 SAVEPOINT 分支 + testTx/testMu 传递 + SAVEPOINT 操作加锁 无(testTx 为 nil 时走原有逻辑)
db/crud.go SelecttestTx 激活时跳过缓存逻辑 无(分支默认跳过)
cache/cache.go 新增 DisableDbCache() 方法 + 新增 SessionsGet/SessionsSet/SessionsDelete 批量操作 无(方法不主动调用)
session.go 对接批量 Session 缓存操作

新增的文件

文件 内容
testing_helper.go TestAppNewTestApp(含 DisableDbCache 调用)、SetupForTestRunTests(事务隔离)、PrintCoverage(覆盖率)
testing_api.go 注册类型 + Api/ApiCase/ApiResponse 链式 API + TestCollector
testing_swagger.go GenerateSwagger:按模块生成子目录 + 根导航页 + 交互式调试控制台

所有新增文件仅在 go test 环境下被使用,生产构建不引入 testing 包依赖,完全不影响线上服务。所有修改的文件都通过 nil 检查保护,testTx/testMu 默认为 nil生产代码路径不受任何影响。