Files
hotime/docs/Testing_API测试框架.md
T
hoteas 3430bdec19 enhance(tests): 改进测试用例记录与覆盖率报告
- 在 ApiCase 中添加失败原因记录,提升测试结果的可读性
- 更新 CoverageReport 结构,增加总用例、通过用例和失败用例计数
- 优化 PrintCoverage 方法,支持详细输出未通过接口及其失败原因
- 更新 Swagger 生成逻辑,包含失败原因字段,增强调试信息
- 改进 SQL 错误计数逻辑,确保在测试模式下准确记录 SQL 错误
2026-04-13 06:45:16 +08:00

39 KiB
Raw 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 调试控制台,支持在线测试、认证管理、参数编辑

快速开始

本节通过一个完整的"创建订单"接口,展示从业务代码到测试用例的全流程。

第一步:业务代码

假设有一个"创建订单"接口 POST /api/order/create,业务逻辑如下:

// 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 文件,测试定义与业务代码分离。

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

// app/init.go — 只注册路由
var Project = Proj{
    "order": OrderCtr,
    // ...
}
// 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) {
    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/
├── order.go         ← 业务代码 (OrderCtr)
├── order_test.go    ← 测试定义 (OrderTest)
├── user.go          ← 业务代码 (UserCtr)
├── user_test.go     ← 测试定义 (UserTest)
├── init.go          ← 路由注册 (Project)
└── init_test.go     ← 测试注册 (ProjectTest) + TestMain + TestApi

运行测试

go test ./app/... -v

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


用例编写范式

每个接口的测试用例应按照以下流程编写:

错误用例(参数校验、权限校验、业务校验)
  ↓
准备测试数据(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)。

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() 直接操作数据库插入测试数据:

// 插入前置数据
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 是数组,校验首元素结构
// 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 响应校验)。

4. Verify 统一校验

Verify 是正确请求的核心校验环节,在一个回调中统一完成响应值断言数据库状态校验

  • 通过 a.Resp() 获取当前请求的响应,对关键业务字段断言具体值
  • 通过 a.DB() 查库校验数据库状态变更
  • 返回 nil 表示通过,返回 error 则用例失败
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 不影响测试逻辑,只提升可读性。

典型使用场景:

// 场景一:枚举值/状态码说明 — 当参数或校验涉及枚举值时,备注各值含义
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 完全一致的场景。

省略 result 结构校验(expecta.Post("创建成功", 0) — 仅断言 status=0,不校验 result 的内容和结构。适合无需关注返回结构的场景,也适用于非 JSON 响应(二进制文件、纯文本等),此时在 Verify 中通过 GetRawBody() 做内容校验。

省略 Verify:不设置 Verify 回调时,不会执行响应值断言和数据库状态校验。适合纯查询、无副作用的接口。

对于涉及数据写入、状态变更、副作用的接口,测试不充分等于充分不测试。


核心类型

注册类型

// 顶层:多项目集合(项目名 → 定义)
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.Verify(fn) 设置请求后的校验函数(等同于先创建 ApiCase 再 Verify,适合纯 GET 无数据场景) *ApiCase
a.WithSession(s) 返回携带 session 的新 Api *Api
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.Verify(fn) 设置请求后的统一校验函数,可用 a.Resp() 断言响应值 + a.DB() 校验数据库(详见 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 校验集中在一处):

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 校验的简单场景):

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) 手动标记测试失败

API 速查表

以下为各种参数组合的快速参考。a*Api 参数。

请求方式

// POST JSON(最常见)
a.JSON(Map{"name": "test", "password": "123"}).Post("描述", 0, Map{...})

// POST Form 表单
a.Form(Map{"shop_id": "1", "id": "5"}).Post("表单提交", 0, Map{...})

// GET 无参数
a.Get("描述", 0, Map{...})

// GET + URL 参数
a.Query(Map{"shop_id": "1", "page": "1"}).Get("查询列表", 0, Map{...})

// PUT
a.JSON(Map{"name": "新名字"}).Put("更新", 0, Map{...})

// DELETE + URL 参数
a.Query(Map{"id": "5"}).Delete("删除", 0, "操作成功")

登录态

// 需登录 + GET
a.WithSession(Map{"user_id": int64(1)}).Get("已登录访问", 0, Map{...})

// 需登录 + GET + URL 参数
a.WithSession(Map{"user_id": int64(1)}).Query(Map{"shop_id": "1"}).Get("带参查询", 0, Map{...})

// 需登录 + POST JSON
a.WithSession(Map{"user_id": int64(1)}).JSON(Map{"name": "新名字"}).Post("更新信息", 0, Map{...})

混合参数

// URL 参数 + JSON body
a.Query(Map{"token": "abc"}).JSON(Map{"phone": "138"}).Post("混合传参", 0, Map{...})

// 文件上传
a.File("file", "photo.png", imgBytes).Post("上传文件", 0, Map{...})

// 文件 + 表单字段
a.File("file", "photo.png", imgBytes).Form(Map{"type": "avatar"}).Post("上传头像", 0, Map{...})

Verify 校验

// 纯 GET + Verify(无需 Form/JSON/Query
a.Verify(func(a *Api) error {
    result := a.Resp().GetBody().GetMap("result")
    if result.GetString("version") == "" {
        return fmt.Errorf("缺少 version 字段")
    }
    return nil
}).Get("获取系统信息", 0, Map{"version": "sample"})

// Form + Verify + 响应值断言 + DB 校验
a.Form(Map{"name": "测试"}).
    Verify(func(a *Api) error {
        result := a.Resp().GetBody().GetMap("result")
        if result.GetCeilInt64("id") <= 0 {
            return fmt.Errorf("id 无效")
        }
        row := a.DB().Get("order", "id", Map{"id": result.GetCeilInt64("id")})
        if row == nil {
            return fmt.Errorf("数据库未写入")
        }
        return nil
    }).
    Post("创建订单", 0, Map{"id": int64(1), "name": "sample"})

备注

// 备注显示在调试控制台用例详情中(没有备注时不显示)
a.Note("sign = MD5(smsProxyKey+timestamp)").
    Form(Map{"phone": "138", "code": "1234"}).Post("正常发送", 0, Map{...})

事务隔离机制

工作原理

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

TestApi
└── app(项目名)
    └── order(控制器名)
        └── create(方法名)← 在这一级 BEGIN → 运行测试用例 → ROLLBACK
            └── 未登录创建订单(用例)
            └── 缺少商品ID(用例)
            └── 正常创建订单(用例)

同一方法内的所有测试用例共享同一个 testTx,因此前面用例插入的数据(如 a.DB().Insert("goods", ...))对后续用例可见,模拟了真实的连续操作场景。

嵌套事务 (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%
总用例: 16 | 通过: 16 | 未通过: 0 | 通过率: 100.0%

---------- 通过的接口 ----------
  ✓ /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

未覆盖的接口: (36 个)
  - api/goods/list
  - api/goods/create
  - api/order/list
  - ...
========================================

输出示例(有失败):

========== API 测试覆盖率报告 ==========
总接口: 42 | 已覆盖: 6 | 覆盖率: 14.3%
总用例: 16 | 通过: 14 | 未通过: 2 | 通过率: 87.5%

---------- 未通过的接口 (1个) ----------
  ✗ /api/order/create    5 用例 (3 通过, 2 失败) 25ms
      [失败] 正常创建订单 — Verify: order 表未写入订单记录
      [失败] 库存扣减 — 状态码不匹配: 期望 0, 实际 4

---------- 通过的接口 ----------
  ✓ /api/user/login      4 用例 (4 通过, 0 失败) 12ms
  ✓ /api/user/info       2 用例 (2 通过, 0 失败) 5ms
  ...

未覆盖的接口: (36 个)
  - api/goods/list
  - ...
========================================

报告分为四个区域:

  • 汇总区:接口覆盖率 + 用例通过率,一目了然
  • 未通过的接口:失败接口单独置顶,每个失败用例附带失败原因(状态码不匹配/消息不匹配/响应结构校验失败/Verify 校验失败)
  • 通过的接口:正常通过的接口列表
  • 未覆盖的接口:尚未编写测试的接口

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": "用户名或密码不能为空" }
    }
  ]
}

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 模块)

独立分发:每个模块目录可以单独拷贝和部署,不依赖其他模块。根导航页自动扫描子目录生成链接。

控制台支持三级导航、关键字搜索、用例预填、全局认证(Header/URL/Cookie)、cURL 生成、覆盖率可视化等功能。


指定运行范围

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

# 运行全部测试
go test ./app/... -v

# 只跑 order 控制器的所有接口
go test ./app/... -v -run TestApi/app/order

# 只跑 order/create 接口
go test ./app/... -v -run TestApi/app/order/create

# 只跑 create 接口中"正常创建订单"这一个用例
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/.*/.*未登录"

并发保护与缓存隔离

框架自动处理以下问题,编写测试时无需关心:

  • 并发保护testTx 是单连接,框架通过 testMu 互斥锁自动序列化所有数据库操作,业务代码中的 go func() 协程不会导致 busy buffer 错误
  • 缓存隔离NewTestApp 启动时自动禁用 DB/Redis 缓存,避免与测试事务产生锁冲突;Session 通过 WithSession() 直接注入 Memory 缓存

一次性数据的处理

对于需要随机数据的测试(如注册),直接用 RandX 生成随机值即可,无需手动清理

"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 中校验:

文件下载校验

"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、日志)时,BodyMap)为空但 RawBody 包含完整内容:

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 为空 Mapstatus 默认 0), 所以 Get("描述", 0) 只是形式上通过。真正的校验逻辑必须写在 Verify 中,对 RawBody 做内容断言。