feat(api): 增强 API 测试框架功能与文档
- 在 Api 结构中新增 lastResp 字段以存储最近请求的响应 - 添加 Verify 方法,支持自定义校验函数并返回 ApiCase 构建器 - 新增 Resp 方法,获取最近一次请求的响应以便于断言 - 在 TestCollector 中添加 Visited 字段,记录已调用的路径 - 更新 GenerateSwagger 方法,支持部分运行时保留未运行端点的已有数据 - 完善文档,增加用例编写范式和示例,提升测试框架的可用性与易用性
This commit is contained in:
+517
-295
@@ -6,17 +6,22 @@
|
||||
|
||||
- [概述](#概述)
|
||||
- [快速开始](#快速开始)
|
||||
- [用例编写范式](#用例编写范式)
|
||||
- [错误用例编写规范](#1-错误用例编写规范)
|
||||
- [测试数据准备](#2-测试数据准备)
|
||||
- [正确请求与响应结构校验](#3-正确请求与响应结构校验)
|
||||
- [Verify 统一校验](#4-verify-统一校验)
|
||||
- [简写支持说明](#简写支持说明)
|
||||
- [核心类型](#核心类型)
|
||||
- [链式 API 参考](#链式-api-参考)
|
||||
- [完整场景对照](#完整场景对照)
|
||||
- [API 速查表](#api-速查表)
|
||||
- [事务隔离机制](#事务隔离机制)
|
||||
- [覆盖率报告](#覆盖率报告)
|
||||
- [api-spec.json 覆盖率字段说明](#api-specjson-覆盖率字段说明)
|
||||
- [params 参数推断说明](#api-specjson-覆盖率字段说明)
|
||||
- [API 调试控制台](#api-调试控制台)
|
||||
- [指定运行范围](#指定运行范围)
|
||||
- [并发保护与缓存隔离](#并发保护与缓存隔离)
|
||||
- [框架改动说明](#框架改动说明)
|
||||
- [二进制与非 JSON 响应校验](#二进制与非-json-响应校验)
|
||||
|
||||
---
|
||||
|
||||
@@ -39,55 +44,120 @@ HoTime 内置 API 测试框架,核心能力:
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 第一步:创建测试定义文件(推荐)
|
||||
本节通过一个完整的"创建订单"接口,展示从业务代码到测试用例的全流程。
|
||||
|
||||
**推荐方式**:为每个 handler 创建对应的 `_test.go` 文件,测试定义与业务代码分离。`_test.go` 文件仅在 `go test` 时编译,不影响生产构建。
|
||||
### 第一步:业务代码
|
||||
|
||||
假设有一个"创建订单"接口 `POST /api/order/create`,业务逻辑如下:
|
||||
|
||||
```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) { /* ... */ },
|
||||
// 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/user_test.go — 测试定义
|
||||
// app/order_test.go — 测试定义
|
||||
package app
|
||||
|
||||
import . "code.hoteas.com/golang/hotime"
|
||||
import . "code.hoteas.com/golang/hotime/common"
|
||||
import (
|
||||
"fmt"
|
||||
. "code.hoteas.com/golang/hotime"
|
||||
. "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, "该手机号已被注册")
|
||||
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)}).
|
||||
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` 中注册,测试注册放在 `app_test.go` 中(因为 `_test.go` 中的变量只在测试编译时可见):
|
||||
|
||||
```go
|
||||
// app/init.go — 只注册路由
|
||||
var Project = Proj{
|
||||
"user": UserCtr,
|
||||
"order": OrderCtr,
|
||||
// ...
|
||||
}
|
||||
```
|
||||
@@ -103,7 +173,7 @@ import (
|
||||
)
|
||||
|
||||
var ProjectTest = ProjTest{
|
||||
"user": UserTest,
|
||||
"order": OrderTest,
|
||||
}
|
||||
|
||||
var testApp *TestApp
|
||||
@@ -128,10 +198,10 @@ func TestApi(t *testing.T) {
|
||||
|
||||
```
|
||||
app/
|
||||
├── order.go ← 业务代码 (OrderCtr)
|
||||
├── order_test.go ← 测试定义 (OrderTest)
|
||||
├── user.go ← 业务代码 (UserCtr)
|
||||
├── user_test.go ← 测试定义 (UserTest)
|
||||
├── tags.go ← 业务代码 (TagsCtr)
|
||||
├── tags_test.go ← 测试定义 (TagsTest)
|
||||
├── init.go ← 路由注册 (Project)
|
||||
└── app_test.go ← 测试注册 (ProjectTest) + TestMain + TestApi
|
||||
```
|
||||
@@ -146,6 +216,198 @@ go test ./app/... -v
|
||||
|
||||
---
|
||||
|
||||
## 用例编写范式
|
||||
|
||||
每个接口的测试用例应按照以下流程编写:
|
||||
|
||||
```
|
||||
错误用例(参数校验、权限校验、业务校验)
|
||||
↓
|
||||
准备测试数据(a.DB().Insert / Update 模拟前置数据)
|
||||
↓
|
||||
正确请求 + 响应结构校验(第三参数自动校验 result 的类型和结构)
|
||||
↓
|
||||
Verify 统一校验(响应值断言 + 数据库状态校验,集中在一个回调中完成)
|
||||
```
|
||||
|
||||
| 环节 | 要求 | 说明 |
|
||||
|------|------|------|
|
||||
| 错误用例 | **必须** | 覆盖权限、参数、业务逻辑等异常路径 |
|
||||
| 测试数据准备 | 按需 | 接口依赖的前置数据用 `a.DB()` 插入 |
|
||||
| 响应结构校验 | **必须** | 正确请求必须用第三参数校验 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 校验) |
|
||||
|
||||
### 简写支持说明
|
||||
|
||||
框架也支持以下简写用法:
|
||||
|
||||
**省略错误消息(第三参数)**:`a.Post("请先登录", 2)` — 当 `status != 0` 且未传第三参数时,框架自动用 `desc`(第一参数)作为期望的错误消息。适合 desc 与实际 msg 完全一致的场景。
|
||||
|
||||
**省略 result 结构校验(expect)**:`a.Post("创建成功", 0)` — 仅断言 `status=0`,不校验 result 的内容和结构。适合无需关注返回结构的场景,也适用于非 JSON 响应(二进制文件、纯文本等),此时在 Verify 中通过 `GetRawBody()` 做内容校验。
|
||||
|
||||
**省略 Verify**:不设置 Verify 回调时,不会执行响应值断言和数据库状态校验。适合纯查询、无副作用的接口。
|
||||
|
||||
> 对于涉及数据写入、状态变更、副作用的接口,测试不充分等于充分不测试。
|
||||
|
||||
---
|
||||
|
||||
## 核心类型
|
||||
|
||||
### 注册类型
|
||||
@@ -210,12 +472,14 @@ func (app *TestApp) DB() *HoTimeDB
|
||||
| `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, 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` |
|
||||
| `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 — 链式构建器
|
||||
|
||||
@@ -229,13 +493,13 @@ func (app *TestApp) DB() *HoTimeDB
|
||||
| `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` |
|
||||
| `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` |
|
||||
|
||||
**终端方法参数说明:**
|
||||
### 终端方法参数说明
|
||||
|
||||
| 参数 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
@@ -246,23 +510,14 @@ func (app *TestApp) DB() *HoTimeDB
|
||||
**expect 参数解析规则:**
|
||||
|
||||
- `status == 0`(成功):
|
||||
- **不传 expect** → 仅校验 status=0,不校验 result 内容(如 `a.Post("描述", 0)`)
|
||||
- **不传 expect** → 仅校验 status=0,不校验 result 内容
|
||||
- 传任意类型 → 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, "请选择店铺")
|
||||
```
|
||||
> **编写建议**:错误用例建议始终填满三个参数 `Post("场景描述", status, "具体错误消息")`,因为错误码是复用的,省略第三参数会降低可读性。
|
||||
|
||||
**result 类型/结构校验:**
|
||||
|
||||
@@ -286,136 +541,130 @@ expect 参数作为**类型样本**,只比较类型大类,不比较具体值
|
||||
|
||||
### ApiResponse — 响应对象
|
||||
|
||||
终端方法返回 `*ApiResponse`,可用于进一步的自定义断言:
|
||||
终端方法返回 `*ApiResponse`,同时也可以在 Verify 回调中通过 `a.Resp()` 获取。
|
||||
|
||||
在 Verify 内通过 `a.Resp()` 做值断言(与 DB 校验集中在一处):
|
||||
|
||||
```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"}).
|
||||
a.JSON(Map{"name": phone, "password": "123456"}).
|
||||
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)
|
||||
result := a.Resp().GetBody().GetMap("result")
|
||||
if result.GetString("token") == "" {
|
||||
return fmt.Errorf("登录成功但缺少 token")
|
||||
}
|
||||
return nil
|
||||
}).
|
||||
Post("创建并验证DB", 0, Map{"id": int64(1)})
|
||||
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)` | — | 手动标记测试失败 |
|
||||
|
||||
---
|
||||
|
||||
## API 速查表
|
||||
|
||||
以下为各种参数组合的快速参考。`a` 为 `*Api` 参数。
|
||||
|
||||
### 请求方式
|
||||
|
||||
```go
|
||||
// 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, "操作成功")
|
||||
```
|
||||
|
||||
### 登录态
|
||||
|
||||
```go
|
||||
// 需登录 + 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{...})
|
||||
```
|
||||
|
||||
### 混合参数
|
||||
|
||||
```go
|
||||
// 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 校验
|
||||
|
||||
```go
|
||||
// 纯 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"})
|
||||
```
|
||||
|
||||
### 备注
|
||||
|
||||
```go
|
||||
// 备注显示在调试控制台用例详情中(没有备注时不显示)
|
||||
a.Note("sign = MD5(smsProxyKey+timestamp)").
|
||||
Form(Map{"phone": "138", "code": "1234"}).Post("正常发送", 0, Map{...})
|
||||
```
|
||||
|
||||
---
|
||||
@@ -429,13 +678,14 @@ a.Form(Map{"name": "test"}).
|
||||
```
|
||||
TestApi
|
||||
└── app(项目名)
|
||||
└── user(控制器名)
|
||||
└── order(控制器名)
|
||||
└── create(方法名)← 在这一级 BEGIN → 运行测试用例 → ROLLBACK
|
||||
└── 正常注册(用例)
|
||||
└── 重复注册(用例)
|
||||
└── 未登录创建订单(用例)
|
||||
└── 缺少商品ID(用例)
|
||||
└── 正常创建订单(用例)
|
||||
```
|
||||
|
||||
所有测试用例共享同一个 `testTx`,因此"正常注册"写入的数据对"重复注册"可见,模拟了真实的并发隔离场景。
|
||||
同一方法内的所有测试用例共享同一个 `testTx`,因此前面用例插入的数据(如 `a.DB().Insert("goods", ...)`)对后续用例可见,模拟了真实的连续操作场景。
|
||||
|
||||
### 嵌套事务 (SAVEPOINT)
|
||||
|
||||
@@ -532,31 +782,6 @@ func TestMain(m *testing.M) {
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `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 调试控制台
|
||||
@@ -593,35 +818,7 @@ testApp.GenerateSwagger(
|
||||
|
||||
> **独立分发**:每个模块目录可以单独拷贝和部署,不依赖其他模块。根导航页自动扫描子目录生成链接。
|
||||
|
||||
### 控制台功能
|
||||
|
||||
| 功能 | 说明 |
|
||||
|------|------|
|
||||
| **三级导航** | 左侧侧边栏按 项目 > 控制器 > 方法 三级树形展示(一级默认展开,二级默认收起) |
|
||||
| **搜索和筛选** | 支持关键字搜索,按 全部/已测试/未测试/通过/未通过 五种模式筛选 |
|
||||
| **左右分栏** | 接口信息栏右侧显示用例数量(`用例: 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
|
||||
└── ...
|
||||
```
|
||||
控制台支持三级导航、关键字搜索、用例预填、全局认证(Header/URL/Cookie)、cURL 生成、覆盖率可视化等功能。
|
||||
|
||||
---
|
||||
|
||||
@@ -633,58 +830,30 @@ testApp.GenerateSwagger(
|
||||
# 运行全部测试
|
||||
go test ./app/... -v
|
||||
|
||||
# 只跑 user 控制器的所有接口
|
||||
go test ./app/... -v -run TestApi/app/user
|
||||
# 只跑 order 控制器的所有接口
|
||||
go test ./app/... -v -run TestApi/app/order
|
||||
|
||||
# 只跑 user/login 接口
|
||||
go test ./app/... -v -run TestApi/app/user/login
|
||||
# 只跑 order/create 接口
|
||||
go test ./app/... -v -run TestApi/app/order/create
|
||||
|
||||
# 只跑 login 接口中"密码为空"这一个用例
|
||||
go test ./app/... -v -run "TestApi/app/user/login/密码为空"
|
||||
# 只跑 create 接口中"正常创建订单"这一个用例
|
||||
go test ./app/... -v -run "TestApi/app/order/create/正常创建订单"
|
||||
|
||||
# 同时跑 user 和 goods 两个控制器
|
||||
go test ./app/... -v -run "TestApi/app/(user|goods)"
|
||||
# 同时跑 user 和 order 两个控制器
|
||||
go test ./app/... -v -run "TestApi/app/(user|order)"
|
||||
|
||||
# 跑所有控制器中包含"未登录"的用例
|
||||
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` 中调用)
|
||||
- **并发保护**:`testTx` 是单连接,框架通过 `testMu` 互斥锁自动序列化所有数据库操作,业务代码中的 `go func()` 协程不会导致 `busy buffer` 错误
|
||||
- **缓存隔离**:`NewTestApp` 启动时自动禁用 DB/Redis 缓存,避免与测试事务产生锁冲突;Session 通过 `WithSession()` 直接注入 Memory 缓存
|
||||
|
||||
### 一次性数据的处理
|
||||
|
||||
@@ -692,35 +861,88 @@ TestApi
|
||||
|
||||
```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"}).Post("正常注册", 0)
|
||||
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 响应校验
|
||||
|
||||
测试框架对 `hotimev1.5` 共改动 9 个文件,其中 6 个为修改、3 个为新增:
|
||||
并非所有接口都返回 JSON。文件下载、图片导出、PDF 生成等场景会直接输出二进制流。框架通过 `RawBody` 完整捕获响应体原始字节,可在 Verify 中校验:
|
||||
|
||||
### 修改的文件
|
||||
### 文件下载校验
|
||||
|
||||
| 文件 | 改动 | 生产影响 |
|
||||
|------|------|----------|
|
||||
| `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 缓存操作 | 无 |
|
||||
```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 纯文本响应
|
||||
|
||||
| 文件 | 内容 |
|
||||
|------|------|
|
||||
| `testing_helper.go` | `TestApp`、`NewTestApp`(含 `DisableDbCache` 调用)、`SetupForTest`、`RunTests`(事务隔离)、`PrintCoverage`(覆盖率) |
|
||||
| `testing_api.go` | 注册类型 + `Api`/`ApiCase`/`ApiResponse` 链式 API + `TestCollector` |
|
||||
| `testing_swagger.go` | `GenerateSwagger`:按模块生成子目录 + 根导航页 + 交互式调试控制台 |
|
||||
当接口返回纯文本(如 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` 做内容断言。
|
||||
|
||||
> 所有新增文件仅在 `go test` 环境下被使用,生产构建不引入 `testing` 包依赖,完全不影响线上服务。所有修改的文件都通过 nil 检查保护,`testTx`/`testMu` 默认为 nil,生产代码路径不受任何影响。
|
||||
|
||||
Reference in New Issue
Block a user