feat(api): 增强 API 测试框架功能与文档

- 在 Api 结构中新增 lastResp 字段以存储最近请求的响应
- 添加 Verify 方法,支持自定义校验函数并返回 ApiCase 构建器
- 新增 Resp 方法,获取最近一次请求的响应以便于断言
- 在 TestCollector 中添加 Visited 字段,记录已调用的路径
- 更新 GenerateSwagger 方法,支持部分运行时保留未运行端点的已有数据
- 完善文档,增加用例编写范式和示例,提升测试框架的可用性与易用性
This commit is contained in:
2026-03-30 01:55:07 +08:00
parent 3681564ca4
commit 37a67f5810
14 changed files with 2665 additions and 639 deletions
@@ -0,0 +1,106 @@
---
name: Verify统一断言改造
overview: 在 Api 上新增 Resp() 方法让 Verify 回调内可以同时做响应值断言和数据库校验,然后更新文档将统一的 Verify 模式作为推荐范式。
todos:
- id: add-resp-method
content: "testing_api.go: Api 新增 lastResp 字段 + Resp() 方法,execute 中调 verifyFn 前设置 lastResp"
status: completed
- id: update-doc
content: "docs/Testing_API测试框架.md: 将 Verify 统一断言作为推荐范式,resp 外部断言降为兼容用法"
status: completed
isProject: false
---
# Verify 统一断言改造
## 问题
当前 `Verify(func(a *Api) error)` 的回调只能通过 `a.DB()` 做数据库校验,无法访问响应体。导致值断言必须在外部用 `resp.GetBody()` + `resp.Fail()` 完成,逻辑分散在两处。
## 方案:Api 上新增 Resp() 方法
`execute` 调用 `verifyFn` 之前,把 `resp` 存到 `Api` 上,然后通过 `a.Resp()` 暴露给回调。
### 代码改动([testing_api.go](testing_api.go)
**1. Api 结构体新增字段**(第 46-51 行附近):
```go
type Api struct {
app *TestApp
path string
t *testing.T
session Map
lastResp *ApiResponse // 新增:最近一次请求的响应
}
```
**2. 新增 Resp() 方法**
```go
// Resp 获取最近一次请求的响应(在 Verify 回调中使用)
func (a *Api) Resp() *ApiResponse {
return a.lastResp
}
```
**3. execute 中调用 verifyFn 前设置 lastResp**(第 296 行附近):
`if passed && c.verifyFn != nil` 之前加一行:
```go
c.api.lastResp = resp
```
**4. WithSession 中传递 lastResp**(可选,因为 WithSession 创建新实例时 lastResp 默认 nil 即可,Verify 内不会跨实例调用)。
### 改动影响
- Verify 的签名 `func(a *Api) error` 不变,完全向后兼容
- 现有 Verify 回调代码无需修改
- 新的 Verify 回调可以用 `a.Resp()` 访问响应体做值断言
- 外部 `resp.GetBody()` + `resp.Fail()` 仍然可用,作为兼容方式保留
### 文档改动([docs/Testing_API测试框架.md](docs/Testing_API测试框架.md)
将"用例编写范式"中的推荐模式从"Verify 做 DB 校验 + 外部 resp 做值断言"改为**统一在 Verify 内完成**
```go
resp := 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"))
}
// ...
return nil
}).
Post("正常创建订单", 0, Map{
"id": int64(1), "sn": "sample", "total_price": float64(1.0),
})
```
文档中需要调整的位置:
- 快速开始的完整范例
- 用例编写范式的流程图和表格
- "正确请求与响应断言"小节(合并为"正确请求与 Verify 校验"
- "不好的写法 vs 推荐的写法"对比
- 保留 `resp.GetBody()` + `resp.Fail()` 的说明但标注为兼容用法
@@ -0,0 +1,53 @@
---
name: 整理推荐写法风格
overview: 将全文散落的"推荐/不推荐"对比块统一清理,文档全程只陈述正确做法,在「用例编写范式」末尾新增「简写支持说明」小节,介绍框架支持的简写行为,并以一句忠告收尾。
todos:
- id: clean-error-case-block
content: 错误用例代码块:去掉 `// 推荐:` 前缀和整个「不推荐」代码段(第 246-256 行)
status: completed
- id: replace-bad-vs-good
content: 「不好的写法 vs 推荐的写法」整节替换为「简写支持说明」,说明框架支持的简写行为+场景,以忠告句收尾(第 412-458 行)
status: completed
- id: clean-apiresponse-labels
content: ApiResponse 去掉「推荐方式」标签,「兼容方式」改为自然描述(第 595-613 行)
status: completed
isProject: false
---
# 整理推荐写法风格
目标文件:[docs/Testing_API测试框架.md](docs/Testing_API测试框架.md)
## 三处改动
### 1. 第 246-256 行 — 错误用例编写规范代码块
当前:代码块里有 `// 推荐:...``// 不推荐:...` 两段
改后:只保留完整写法的代码,去掉 `// 推荐:` 注释前缀和整个"不推荐"代码段。
```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. 第 412-458 行 — 「不好的写法 vs 推荐的写法」整节
当前:三组 bad/good 对比块(省略 msg、只校验 status、硬编码 ID
改后:整节替换为「简写支持说明」,结构如下:
- **标题**`### 简写支持说明`
- **说明框架支持的三种简写**,每种说明行为(不是批评,是告知机制):
- 省略第三参数:`a.Post("请先登录", 2)` — desc 自动作为期望 msg,适合 desc 与 msg 完全一致的场景
- 省略 expect`a.Post("创建成功", 0)` — 仅断言 status=0,不校验 result 内容或数据库状态
- 省略 Verify:适合纯查询、无副作用的接口,只需验证格式即可
- **忠告**(一行):`> 对于涉及数据写入、状态变更、副作用的接口,省略过程校验等于充分不测试。`
### 3. 第 595-613 行 — ApiResponse「推荐方式 / 兼容方式」标签
当前:用「推荐方式」和「兼容方式」两个标签
改后:去掉「推荐方式 —」标签,直接展示 Verify 用法;「兼容方式」改为:`也可以通过返回值在外部断言(适合不需要 DB 校验的简单场景):`
@@ -0,0 +1,70 @@
---
name: 精简测试框架文档
overview: 将 Testing_API测试框架.md 从 1210 行精简到约 750-800 行,删除与"编写测试用例"无关的冗余内容,同时保留/强化全过程验证示例,防止 AI 写出只验证接口通不通的浅层测试。
todos:
- id: simplify-biz-code
content: 「快速开始 → 第一步:业务代码」改成极简示例(3-5行的伪代码),不再展开完整 OrderCtr,节约约40行
status: completed
- id: delete-framework-changes
content: 删除「框架改动说明」整节(约25行)
status: completed
- id: delete-login-compare
content: 删除「不好的写法 vs 推荐的写法」中的登录完整对比示例(约45行),前面各小节已有对比,不需要再重复一遍
status: completed
- id: trim-swagger-console
content: 缩减「API 调试控制台」,只保留 GenerateSwagger 调用方式和输出目录结构,删除14行功能特性表和侧边栏示意图(约-30行)
status: completed
- id: trim-api-spec-fields
content: 删除「api-spec.json 覆盖率字段说明」中的字段详解大表和徽章规则(约-30行),只保留 JSON 结构示例
status: completed
- id: trim-concurrency
content: 精简「并发保护与缓存隔离」,删除逐操作加锁保护表,只保留结论性描述(约-20行)
status: completed
- id: trim-apiresponse
content: 删除 ApiResponse GetBody vs Obj 的解释性注释块(约-10行)
status: completed
- id: trim-run-range
content: 删除「指定运行范围」末尾重复的子测试层级树(约-10行)
status: completed
- id: trim-binary
content: 精简「二进制与非JSON响应校验」,删除图片下载示例(与xlsx示例高度重复,约-30行)
status: completed
isProject: false
---
# 精简测试框架文档
目标:[docs/Testing_API测试框架.md](docs/Testing_API测试框架.md) 从 1210 行 → 约 750-800 行
## 核心原则调整(与初稿的区别)
- **快速开始必须完整**:第一步「业务代码」不删除,改为极简示意(3-5行伪代码),让读者明白"这里有个接口"即可,不展开完整逻辑
- **全过程验证示例必须保留并强调**:Verify + DB 校验 + 响应值断言的示例是文档核心,AI 容易偷懒只写 `Post("xxx", 0)` 不做任何校验,这种浅层用例要明确标为"不好的写法"
- **其余按实用性裁剪**
## 删除/精简内容(约 -400 行)
- **精简「第一步:业务代码」**(-40 行):OrderCtr 有50行,改成极简伪代码 + 注释说明接口做什么,让后续测试示例有上下文即可
- **删除「框架改动说明」整节**(-25 行):内部实现历史,对写测试无任何指导价值
- **删除「不好的写法 → 登录完整对比」**(-45 行):前面3个对比子节已充分说明问题,第4个只是换了场景重复
- **缩减「API 调试控制台」**(-30 行):只保留 `GenerateSwagger` 调用 + 输出目录结构,删除14行功能表和侧边栏结构图
- **删除「api-spec.json 字段详解表」**-30 行):与写测试无关
- **精简「并发保护与缓存隔离」**(-20 行):删除逐操作加锁表,保留结论
- **删除 ApiResponse GetBody vs Obj 解释块**-10 行)
- **删除「指定运行范围」末尾层级树**(-10 行):bash 示例中注释已足够
- **精简「二进制响应校验」**(-30 行):只保留 xlsx + CSV,删除重复的图片示例
## 保留/强化内容
- 概述能力表格
- **快速开始(完整4步,业务代码改极简)**
- **用例编写范式(全部4节 + 不好vs推荐对比的前3节)** ← 核心,不裁
- 核心类型
- 链式 API 参考(三张表)
- API 速查表
- 事务隔离机制
- 覆盖率报告(保留 PrintCoverage 输出示例)
- 指定运行范围(bash 示例)
- 并发保护与缓存隔离(精简版)
- 二进制响应校验(精简版)
@@ -0,0 +1,145 @@
---
name: 重写API测试框架文档
overview: 重新编撰 Testing_API测试框架.md,以"错误用例先行 → 准备数据 → 正确请求(含响应断言+DB校验)"的完整流程为核心范式,让文档成为可直接照搬的编写指南。
todos:
- id: rewrite-doc
content: 重写 docs/Testing_API测试框架.md:错误用例先行 → 模拟数据准备 → 正确请求(响应断言必须+DB校验) → 逐步讲解各环节 → API速查表 → 其余章节微调
status: completed
isProject: false
---
# 重写 API 测试框架文档
## 用户确认的编写规范
1. **顺序**:先写错误请求用例,再写正确请求用例
2. **错误用例**:三个参数必须填满 `Post("描述", status, "具体错误消息")`,因为错误码是复用的(如 status=3 可能对应多种参数校验错误)
3. **正确请求前**:用 `a.DB()` 模拟插入数据,既准备测试依赖数据,也可作为后续 DB 校验的对照
4. **响应字段断言**:正确响应情况下**必须**断言(至少覆盖重要字段),不能只写 `Post("描述", 0)`
5. **数据库校验**:只要接口改变了数据库状态,都**建议**用 Verify 校验结果
## 标准用例编写流程(范式)
```
错误用例(参数校验、权限校验等)
准备测试数据(a.DB().Insert/Update 模拟前置数据)
正确请求 + 响应结构校验(ExpectResult / 第三参数 Map
响应字段断言(resp.GetBody() 检查关键业务字段)
数据库状态校验(Verify 查库确认写入/更新结果)
```
## 核心范例(贯穿文档的"创建订单"示例)
以下为快速开始中使用的完整范例,展示从业务代码到测试用例的映射:
```go
// order_test.go
var OrderTest = CtrTest{
"create": {Desc: "创建订单", Func: func(a *Api) {
// ======== 第一步:错误用例 ========
// 错误码复用,三个参数都必须填满
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(999), "quantity": 3, "address": "XX路1号"}).
Post("商品不存在", 4, "商品不存在")
// ======== 第二步:准备测试数据 ========
// 插入一条商品数据,后面的正确请求会依赖它
goodsId := a.DB().Insert("goods", Map{
"name": "测试商品", "price": 29.9, "stock": 100, "state": 1,
})
// ======== 第三步:正确请求 + 响应断言 + DB校验 ========
resp := a.WithSession(Map{"user_id": int64(1)}).
JSON(Map{"goods_id": goodsId, "quantity": 3, "address": "XX路1号"}).
Verify(func(a *Api) error {
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),
})
// 关键业务字段断言
orderId := resp.GetBody().GetMap("result").GetCeilInt64("id")
if orderId <= 0 {
resp.Fail("返回的订单 ID 必须大于 0")
}
sn := resp.GetBody().GetMap("result").GetString("sn")
if sn == "" {
resp.Fail("返回的订单编号 sn 不能为空")
}
}},
}
```
## 文档结构
### 不变章节(微调措辞)
- 概述(能力表格)
- 核心类型
- 链式 API 参考(在 expect 参数规则部分强调:错误用例建议三参数写满)
- 事务隔离机制
- 覆盖率报告
- API 调试控制台
- 指定运行范围
- 并发保护与缓存隔离
- 框架改动说明
### 重写/新增章节
#### 1. "快速开始" — 使用上述完整范例
- 先展示业务代码(handler),再展示测试代码
- 范例中完整体现五步流程
- 目录结构、注册方式、运行方式保持原有说明
#### 2. "用例编写范式" — 新增章节,替代原"完整场景对照"
按顺序逐步讲解范例中的每个环节:
- **错误用例编写规范**:为什么三参数必须写满(错误码复用)、如何覆盖权限/参数/业务错误
- **测试数据准备**`a.DB().Insert()` 的使用场景和注意事项(事务内操作,自动回滚)
- **正确请求 + 响应结构校验**:第三参数 Map 的类型样本规则、ExpectResult 后置校验
- **响应字段断言**`resp.GetBody()` / `resp.Fail()` 的用法、哪些字段必须断言
- **数据库状态校验**:Verify 的使用时机(只要改了DB就建议校验)、校验主表+关联表+字段值
- 包含"不好的写法 vs 推荐的写法"对比
#### 3. "API 速查表" — 精简版场景对照
保留各种参数组合(JSON/Form/Query/File/混合/Session 等)的快速参考,但每种只一行示例代码,不再是大段散乱的代码块。
## 改动范围
- 仅改动 `docs/Testing_API测试框架.md`,约 750-850 行
- 不涉及 Go 源码
- 不涉及其他文档
+517 -295
View File
@@ -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,生产代码路径不受任何影响。
+81 -3
View File
@@ -1,11 +1,89 @@
package app
import (
"fmt"
. "code.hoteas.com/golang/hotime"
. "code.hoteas.com/golang/hotime/common"
)
var CacheTest = CtrTest{
"all": {Desc: "缓存全部测试", Func: func(a *Api) { a.Get("缓存全部测试", 0) }},
"compat": {Desc: "缓存兼容模式测试", Func: func(a *Api) { a.Get("兼容模式测试", 0) }},
"batch": {Desc: "批量缓存测试", Func: func(a *Api) { a.Get("批量缓存测试", 0) }},
"all": {Desc: "缓存全部测试", Func: func(a *Api) {
a.Verify(func(a *Api) error {
result := a.Resp().GetBody().GetMap("result")
if result == nil {
return fmt.Errorf("result 为 nil")
}
if !result.GetBool("success") {
return fmt.Errorf("缓存测试未全部通过")
}
if result.GetString("cache_mode") == "" {
return fmt.Errorf("cache_mode 字段缺失")
}
tests := result.GetSlice("tests")
if len(tests) == 0 {
return fmt.Errorf("tests 数组为空")
}
for i := 0; i < len(tests); i++ {
item := tests.GetMap(i)
if item != nil && !item.GetBool("result") {
return fmt.Errorf("子项 '%s' 失败", item.GetString("name"))
}
}
return nil
}).Get("缓存全部测试", 0, Map{
"name": "sample",
"cache_mode": "sample",
"success": true,
"cleanup": "sample",
"tests": Slice{Map{
"name": "sample", "result": true,
}},
})
}},
// 兼容模式下的老表回退/写新删老操作在测试事务隔离下已知异常(cache API
// 使用独立连接,不在 testTx 事务内),因此只校验结构完整性
"compat": {Desc: "缓存兼容模式测试", Func: func(a *Api) {
a.Verify(func(a *Api) error {
result := a.Resp().GetBody().GetMap("result")
if result == nil {
return fmt.Errorf("result 为 nil")
}
if result.GetString("test_name") == "" {
return fmt.Errorf("test_name 字段缺失")
}
if result.GetString("timestamp") == "" {
return fmt.Errorf("timestamp 字段缺失")
}
tests := result.GetSlice("tests")
if len(tests) == 0 {
return fmt.Errorf("tests 数组为空")
}
return nil
}).Get("兼容模式测试", 0, Map{
"test_name": "sample",
"timestamp": "sample",
"success": true,
"tests": Slice{Map{
"name": "sample", "result": true,
}},
})
}},
"batch": {Desc: "批量缓存测试", Func: func(a *Api) {
a.Verify(func(a *Api) error {
result := a.Resp().GetBody().GetMap("result")
if result == nil {
return fmt.Errorf("result 为 nil")
}
msg := result.GetString("message")
if msg == "" {
return fmt.Errorf("message 字段为空")
}
return nil
}).Get("批量缓存测试", 0, Map{
"message": "sample",
})
}},
}
+81 -3
View File
@@ -1,12 +1,90 @@
package app
import (
"fmt"
. "code.hoteas.com/golang/hotime"
. "code.hoteas.com/golang/hotime/common"
)
var DmdbTest = CtrTest{
"tables": {Desc: "查询达梦所有表", Func: func(a *Api) { a.Get("USER_TABLES 查询", 0) }},
"describe": {Desc: "查询达梦表结构", Func: func(a *Api) { a.Query(Map{"table": "admin"}).Get("USER_TAB_COLUMNS 查询", 0) }},
"rawsql": {Desc: "达梦原生 SQL 测试", Func: func(a *Api) { a.Get("达梦原生SQL", 0) }},
"tables": {Desc: "查询达梦所有表", Func: func(a *Api) {
if a.DB().Type != "dm" && a.DB().Type != "dameng" {
return
}
a.Verify(func(a *Api) error {
result := a.Resp().GetBody().GetMap("result")
if result == nil {
return fmt.Errorf("result 为 nil")
}
tables := result.GetSlice("tables")
if tables == nil {
return fmt.Errorf("tables 字段缺失")
}
if len(tables) == 0 {
return fmt.Errorf("达梦表列表为空,至少应有测试表")
}
return nil
}).Get("USER_TABLES 查询", 0, Map{
"tables": Slice{Map{"name": "sample"}},
})
}},
"describe": {Desc: "查询达梦表结构", Func: func(a *Api) {
if a.DB().Type != "dm" && a.DB().Type != "dameng" {
return
}
// ======== 错误用例 ========
a.Get("不传table参数", 1, "请提供 table 参数")
// ======== 正确请求 + 结构校验 + Verify ========
a.Query(Map{"table": "admin"}).
Verify(func(a *Api) error {
result := a.Resp().GetBody().GetMap("result")
if result == nil {
return fmt.Errorf("result 为 nil")
}
if result.GetString("table") != "admin" {
return fmt.Errorf("table 期望 'admin', 实际 '%s'", result.GetString("table"))
}
columns := result.GetSlice("columns")
if len(columns) == 0 {
return fmt.Errorf("columns 为空,admin 表应有字段定义")
}
return nil
}).
Get("USER_TAB_COLUMNS 查询", 0, Map{
"table": "sample", "columns": Slice{Map{"name": "sample", "type": "sample"}}, "sample_data": Slice{},
})
}},
"rawsql": {Desc: "达梦原生 SQL 测试", Func: func(a *Api) {
if a.DB().Type != "dm" && a.DB().Type != "dameng" {
return
}
a.Verify(func(a *Api) error {
result := a.Resp().GetBody().GetMap("result")
if result == nil {
return fmt.Errorf("result 为 nil")
}
if !result.GetBool("success") {
return fmt.Errorf("达梦原生 SQL 测试未通过")
}
tests := result.GetSlice("tests")
if len(tests) == 0 {
return fmt.Errorf("tests 数组为空")
}
for i := 0; i < len(tests); i++ {
item := tests.GetMap(i)
if item != nil && !item.GetBool("result") {
return fmt.Errorf("子项 '%s' 失败", item.GetString("name"))
}
}
return nil
}).Get("达梦原生SQL", 0, Map{
"name": "sample", "success": true,
"tests": Slice{Map{"name": "sample", "result": true}},
})
}},
}
+201 -27
View File
@@ -8,25 +8,109 @@ import (
)
var ExpectDemoTest = CtrTest{
"string_result": {Desc: "返回字符串", Func: func(a *Api) {
a.Get("result是字符串", 0, "操作成功")
// ─── 基础类型结构校验 + Verify 值断言 ──────────────────
"string_result": {Desc: "返回字符串-结构+值校验", Func: func(a *Api) {
a.Verify(func(a *Api) error {
result := a.Resp().GetBody().GetString("result")
if result != "操作成功" {
return fmt.Errorf("result 期望 '操作成功', 实际 '%s'", result)
}
return nil
}).Get("result是字符串", 0, "样本")
}},
"number_result": {Desc: "返回整数", Func: func(a *Api) {
a.Get("result是整数", 0, int64(1))
"number_result": {Desc: "返回整数-结构+值校验", Func: func(a *Api) {
a.Verify(func(a *Api) error {
result := a.Resp().GetBody().GetCeilInt64("result")
if result != 42 {
return fmt.Errorf("result 期望 42, 实际 %d", result)
}
return nil
}).Get("result是整数", 0, int64(1))
}},
"float_result": {Desc: "返回小数", Func: func(a *Api) {
a.Get("result是小数", 0, float64(1.0))
"float_result": {Desc: "返回小数-结构+值校验", Func: func(a *Api) {
a.Verify(func(a *Api) error {
result := a.Resp().GetBody().GetFloat64("result")
if result != 3.14 {
return fmt.Errorf("result 期望 3.14, 实际 %f", result)
}
return nil
}).Get("result是小数", 0, float64(1.0))
}},
"bool_result": {Desc: "返回布尔", Func: func(a *Api) {
a.Get("result是布尔", 0, true)
"bool_result": {Desc: "返回布尔-结构+值校验", Func: func(a *Api) {
a.Verify(func(a *Api) error {
result := a.Resp().GetBody().GetBool("result")
if !result {
return fmt.Errorf("result 期望 true, 实际 false")
}
return nil
}).Get("result是布尔", 0, true)
}},
"map_result": {Desc: "返回对象", Func: func(a *Api) {
a.Get("result是Map-校验字段名和类型", 0, Map{
"map_result": {Desc: "返回对象-结构+值校验", Func: func(a *Api) {
a.Verify(func(a *Api) error {
result := a.Resp().GetBody().GetMap("result")
if result == nil {
return fmt.Errorf("result 为 nil")
}
if result.GetCeilInt64("id") != 1 {
return fmt.Errorf("id 期望 1, 实际 %d", result.GetCeilInt64("id"))
}
if result.GetString("name") != "示例商品" {
return fmt.Errorf("name 期望 '示例商品', 实际 '%s'", result.GetString("name"))
}
if !result.GetBool("in_stock") {
return fmt.Errorf("in_stock 期望 true")
}
return nil
}).Get("result是Map-校验字段名+类型+值", 0, Map{
"id": int64(1), "name": "sample", "price": float64(1.0), "in_stock": true,
})
}},
"nested_result": {Desc: "返回嵌套对象", Func: func(a *Api) {
a.Get("深层嵌套Map+Slice+Map", 0, Map{
"nested_result": {Desc: "返回嵌套对象-结构+值校验", Func: func(a *Api) {
a.Verify(func(a *Api) error {
result := a.Resp().GetBody().GetMap("result")
if result == nil {
return fmt.Errorf("result 为 nil")
}
if result.GetCeilInt64("id") != 100 {
return fmt.Errorf("id 期望 100, 实际 %d", result.GetCeilInt64("id"))
}
if result.GetString("title") != "测试订单" {
return fmt.Errorf("title 期望 '测试订单', 实际 '%s'", result.GetString("title"))
}
customer := result.GetMap("customer")
if customer == nil {
return fmt.Errorf("customer 为 nil")
}
if customer.GetString("name") != "张三" {
return fmt.Errorf("customer.name 期望 '张三', 实际 '%s'", customer.GetString("name"))
}
if customer.GetString("phone") != "13800138000" {
return fmt.Errorf("customer.phone 期望 '13800138000', 实际 '%s'", customer.GetString("phone"))
}
items := result.GetSlice("items")
if len(items) != 2 {
return fmt.Errorf("items 期望 2 条, 实际 %d 条", len(items))
}
firstItem := items.GetMap(0)
if firstItem.GetString("goods_name") != "苹果" {
return fmt.Errorf("items[0].goods_name 期望 '苹果', 实际 '%s'", firstItem.GetString("goods_name"))
}
delivery := result.GetMap("extra").GetMap("delivery")
if delivery == nil {
return fmt.Errorf("extra.delivery 为 nil")
}
if delivery.GetString("address") != "XX路1号" {
return fmt.Errorf("extra.delivery.address 期望 'XX路1号', 实际 '%s'", delivery.GetString("address"))
}
return nil
}).Get("深层嵌套Map+Slice+Map", 0, Map{
"id": int64(1), "title": "sample",
"customer": Map{"id": int64(1), "name": "sample", "phone": "sample"},
"items": Slice{Map{
@@ -37,42 +121,132 @@ var ExpectDemoTest = CtrTest{
},
})
}},
"slice_result": {Desc: "返回数组", Func: func(a *Api) {
a.Get("result直接是Slice", 0, Slice{Map{"id": int64(1), "name": "sample"}})
"slice_result": {Desc: "返回数组-结构+值校验", Func: func(a *Api) {
a.Verify(func(a *Api) error {
result := a.Resp().GetBody().GetSlice("result")
if len(result) != 2 {
return fmt.Errorf("result 期望 2 条, 实际 %d 条", len(result))
}
first := result.GetMap(0)
if first.GetString("name") != "标签A" {
return fmt.Errorf("result[0].name 期望 '标签A', 实际 '%s'", first.GetString("name"))
}
second := result.GetMap(1)
if second.GetString("name") != "标签B" {
return fmt.Errorf("result[1].name 期望 '标签B', 实际 '%s'", second.GetString("name"))
}
return nil
}).Get("result直接是Slice", 0, Slice{Map{"id": int64(1), "name": "sample"}})
}},
"error_demo": {Desc: "错误+成功混合", Func: func(a *Api) {
a.Form(Map{"name": ""}).Post("名称为空-错误断言", 3, "名称不能为空")
a.Form(Map{"name": "测试"}).Post("名称正确-结构校验", 0, Map{
"id": int64(1), "name": "sample", "created": true,
})
// ─── 错误先行 + 正确请求完整范式 ─────────────────────
"error_demo": {Desc: "错误先行+正确请求完整校验", Func: func(a *Api) {
// ======== 第一步:错误用例(先行) ========
a.Form(Map{"name": ""}).Post("名称为空-缺少必填字段", 3, "名称不能为空")
// ======== 第二步:正确请求 + 结构校验 + Verify 值断言 ========
a.Form(Map{"name": "测试商品"}).
Verify(func(a *Api) error {
result := a.Resp().GetBody().GetMap("result")
if result == nil {
return fmt.Errorf("result 为 nil")
}
if result.GetCeilInt64("id") != 1 {
return fmt.Errorf("id 期望 1, 实际 %d", result.GetCeilInt64("id"))
}
if result.GetString("name") != "测试商品" {
return fmt.Errorf("name 期望 '测试商品', 实际 '%s'", result.GetString("name"))
}
if !result.GetBool("created") {
return fmt.Errorf("created 期望 true")
}
return nil
}).
Post("名称正确-结构+值校验", 0, Map{
"id": int64(1), "name": "sample", "created": true,
})
}},
"no_expect": {Desc: "不传预期(仅校验status)", Func: func(a *Api) {
a.Get("只校验status=0", 0)
"no_expect": {Desc: "最小正确用例-仍需值断言", Func: func(a *Api) {
a.Verify(func(a *Api) error {
result := a.Resp().GetBody().GetMap("result")
if result == nil {
return fmt.Errorf("result 为 nil")
}
if result.GetCeilInt64("id") != 1 {
return fmt.Errorf("id 期望 1, 实际 %d", result.GetCeilInt64("id"))
}
if result.GetString("data") != "无预期校验" {
return fmt.Errorf("data 期望 '无预期校验', 实际 '%s'", result.GetString("data"))
}
return nil
}).Get("有Verify值断言", 0, Map{"id": int64(1), "data": "sample"})
}},
"verify_demo": {Desc: "Verify数据库校验", Func: func(a *Api) {
// ─── Verify 完整范式:错误 → 数据准备 → 正确请求 + 响应断言 + DB 校验 ─
"verify_demo": {Desc: "Verify完整范式演示", Func: func(a *Api) {
// ======== 第一步:错误用例 ========
a.Form(Map{"name": ""}).Post("名称为空", 3, "名称不能为空")
a.Form(Map{"name": "verify_test"}).
// ======== 第二步:准备测试数据 ========
// 先插入一条已有记录,用于后续 DB 校验时确认"新增"行为
a.DB().Insert("test_batch", Map{
"name": "existing_record", "title": "verify_demo", "state": 1,
})
// ======== 第三步:正确请求 + Verify 统一校验 ========
name := "verify_test"
a.Form(Map{"name": name}).
Verify(func(a *Api) error {
row := a.DB().Get("test_batch", "*", Map{"AND": Map{"name": "verify_test", "title": "verify_demo"}})
// 响应值断言
result := a.Resp().GetBody().GetMap("result")
if result == nil {
return fmt.Errorf("result 为 nil")
}
if result.GetCeilInt64("id") <= 0 {
return fmt.Errorf("返回的 id 必须大于 0, 实际 %d", result.GetCeilInt64("id"))
}
if result.GetString("name") != name {
return fmt.Errorf("name 期望 '%s', 实际 '%s'", name, result.GetString("name"))
}
// 数据库状态校验
row := a.DB().Get("test_batch", "*", Map{"AND": Map{"name": name, "title": "verify_demo"}})
if row == nil {
return fmt.Errorf("test_batch 表未找到 name=verify_test 的记录")
return fmt.Errorf("test_batch 表未找到 name=%s 的记录", name)
}
if row.GetCeilInt64("state") != 0 {
return fmt.Errorf("test_batch.state 期望 0, 实际 %d", row.GetCeilInt64("state"))
}
// 校验之前插入的记录仍然存在(验证不会误删)
existing := a.DB().Get("test_batch", "*", Map{"AND": Map{"name": "existing_record", "title": "verify_demo"}})
if existing == nil {
return fmt.Errorf("之前插入的 existing_record 丢失")
}
return nil
}).
Post("写入并校验DB-正确", 0, Map{"id": int64(1), "name": "sample"})
Post("写入并校验DB+响应", 0, Map{"id": int64(1), "name": "sample"})
// ======== 演示故意失败的 Verify ========
a.Form(Map{"name": "verify_fail"}).
Verify(func(a *Api) error {
result := a.Resp().GetBody().GetMap("result")
if result == nil {
return fmt.Errorf("result 为 nil")
}
if result.GetCeilInt64("id") <= 0 {
return fmt.Errorf("返回的 id 必须大于 0, 实际 %d", result.GetCeilInt64("id"))
}
row := a.DB().Get("test_batch", "*", Map{"AND": Map{"name": "verify_fail", "title": "verify_demo"}})
if row == nil {
return fmt.Errorf("test_batch 表未找到 name=verify_fail 的记录")
}
if row.GetCeilInt64("state") != 99 {
return fmt.Errorf("test_batch.state 期望 99, 实际 %d(接口写入的是0,这里故意校验99演示失败效果)", row.GetCeilInt64("state"))
return fmt.Errorf("test_batch.state 期望 99, 实际 %d(接口写入 0故意校验 99 演示失败效果)", row.GetCeilInt64("state"))
}
return nil
}).
+81 -3
View File
@@ -1,12 +1,90 @@
package app
import (
"fmt"
. "code.hoteas.com/golang/hotime"
. "code.hoteas.com/golang/hotime/common"
)
var MysqlTest = CtrTest{
"tables": {Desc: "查询 MySQL 所有表", Func: func(a *Api) { a.Get("SHOW TABLES", 0) }},
"describe": {Desc: "查询 MySQL 表结构", Func: func(a *Api) { a.Query(Map{"table": "admin"}).Get("DESCRIBE admin", 0) }},
"rawsql": {Desc: "MySQL 原生 SQL 测试", Func: func(a *Api) { a.Get("MySQL原生SQL", 0) }},
"tables": {Desc: "查询 MySQL 所有表", Func: func(a *Api) {
if a.DB().Type != "mysql" {
return
}
a.Verify(func(a *Api) error {
result := a.Resp().GetBody().GetMap("result")
if result == nil {
return fmt.Errorf("result 为 nil")
}
tables := result.GetSlice("tables")
if tables == nil {
return fmt.Errorf("tables 字段缺失")
}
if len(tables) == 0 {
return fmt.Errorf("MySQL 表列表为空,至少应有测试表")
}
return nil
}).Get("SHOW TABLES", 0, Map{
"tables": Slice{},
})
}},
"describe": {Desc: "查询 MySQL 表结构", Func: func(a *Api) {
if a.DB().Type != "mysql" {
return
}
// ======== 错误用例 ========
a.Get("不传table参数", 1, "请提供 table 参数")
// ======== 正确请求 + 结构校验 + Verify ========
a.Query(Map{"table": "admin"}).
Verify(func(a *Api) error {
result := a.Resp().GetBody().GetMap("result")
if result == nil {
return fmt.Errorf("result 为 nil")
}
if result.GetString("table") != "admin" {
return fmt.Errorf("table 期望 'admin', 实际 '%s'", result.GetString("table"))
}
columns := result.GetSlice("columns")
if len(columns) == 0 {
return fmt.Errorf("columns 为空,admin 表应有字段定义")
}
return nil
}).
Get("DESCRIBE admin", 0, Map{
"table": "sample", "columns": Slice{}, "sample_data": Slice{},
})
}},
"rawsql": {Desc: "MySQL 原生 SQL 测试", Func: func(a *Api) {
if a.DB().Type != "mysql" {
return
}
a.Verify(func(a *Api) error {
result := a.Resp().GetBody().GetMap("result")
if result == nil {
return fmt.Errorf("result 为 nil")
}
if !result.GetBool("success") {
return fmt.Errorf("MySQL 原生 SQL 测试未通过")
}
tests := result.GetSlice("tests")
if len(tests) == 0 {
return fmt.Errorf("tests 数组为空")
}
for i := 0; i < len(tests); i++ {
item := tests.GetMap(i)
if item != nil && !item.GetBool("result") {
return fmt.Errorf("子项 '%s' 失败", item.GetString("name"))
}
}
return nil
}).Get("MySQL原生SQL", 0, Map{
"name": "sample", "success": true,
"tests": Slice{Map{"name": "sample", "result": true}},
})
}},
}
+173 -10
View File
@@ -1,18 +1,181 @@
package app
import (
"fmt"
. "code.hoteas.com/golang/hotime"
. "code.hoteas.com/golang/hotime/common"
)
var TestTest = CtrTest{
"all": {Desc: "运行全部通用测试", Func: func(a *Api) { a.Get("全部测试", 0) }},
"crud": {Desc: "基础 CRUD 测试", Func: func(a *Api) { a.Get("CRUD测试", 0) }},
"condition": {Desc: "条件查询语法测试", Func: func(a *Api) { a.Get("条件查询", 0) }},
"chain": {Desc: "链式查询测试", Func: func(a *Api) { a.Get("链式查询", 0) }},
"join": {Desc: "JOIN 查询测试", Func: func(a *Api) { a.Get("JOIN查询", 0) }},
"aggregate": {Desc: "聚合函数测试", Func: func(a *Api) { a.Get("聚合函数", 0) }},
"pagination": {Desc: "分页查询测试", Func: func(a *Api) { a.Get("分页查询", 0) }},
"batch": {Desc: "批量插入测试", Func: func(a *Api) { a.Get("批量插入", 0) }},
"upsert": {Desc: "Upsert 测试", Func: func(a *Api) { a.Get("Upsert测试", 0) }},
"transaction": {Desc: "事务测试", Func: func(a *Api) { a.Get("事务测试", 0) }},
// ======== 错误接口测试 ========
"test": {Desc: "固定错误返回", Func: func(a *Api) {
a.Get("固定返回错误码2", 2, "dsadasd")
}},
// ======== ORM 全量测试 ========
"all": {Desc: "运行全部通用测试", Func: func(a *Api) {
a.Verify(func(a *Api) error {
result := a.Resp().GetBody().GetMap("result")
if result == nil {
return fmt.Errorf("result 为 nil")
}
subTests := []string{
"1_basic_crud", "2_condition_syntax", "3_chain_query",
"4_join_query", "5_aggregate", "6_pagination",
"7_batch_insert", "8_upsert", "9_transaction",
}
for _, key := range subTests {
sub := result.GetMap(key)
if sub == nil {
return fmt.Errorf("子测试 %s 缺失", key)
}
if sub.GetString("name") == "" {
return fmt.Errorf("子测试 %s 缺少 name 字段", key)
}
tests := sub.GetSlice("tests")
if len(tests) == 0 {
return fmt.Errorf("子测试 %s 的 tests 数组为空", key)
}
}
return nil
}).Get("全部测试", 0, Map{
"1_basic_crud": Map{
"name": "sample", "success": true,
"tests": Slice{Map{"name": "sample", "result": true}},
},
"2_condition_syntax": Map{
"name": "sample", "success": true,
"tests": Slice{Map{"name": "sample", "result": true}},
},
"3_chain_query": Map{
"name": "sample", "success": true,
"tests": Slice{Map{"name": "sample"}},
},
"4_join_query": Map{
"name": "sample", "success": true,
"tests": Slice{Map{"name": "sample"}},
},
"5_aggregate": Map{
"name": "sample", "success": true,
"tests": Slice{Map{"name": "sample"}},
},
"6_pagination": Map{
"name": "sample", "success": true,
"tests": Slice{Map{"name": "sample"}},
},
"7_batch_insert": Map{
"name": "sample", "success": true,
"tests": Slice{Map{"name": "sample"}},
},
"8_upsert": Map{
"name": "sample", "success": true,
"tests": Slice{Map{"name": "sample"}},
},
"9_transaction": Map{
"name": "sample", "success": true,
"tests": Slice{Map{"name": "sample"}},
},
})
}},
// ======== 单项 ORM 测试 ========
"crud": {Desc: "基础 CRUD 测试", Func: func(a *Api) {
a.Verify(func(a *Api) error {
return verifyOrmSubTest(a, "基础CRUD测试")
}).Get("CRUD测试", 0, Map{
"name": "sample", "success": true,
"tests": Slice{Map{"name": "sample", "result": true}},
})
}},
"condition": {Desc: "条件查询语法测试", Func: func(a *Api) {
a.Verify(func(a *Api) error {
return verifyOrmSubTest(a, "条件查询语法测试")
}).Get("条件查询", 0, Map{
"name": "sample", "success": true,
"tests": Slice{Map{"name": "sample", "result": true}},
})
}},
"chain": {Desc: "链式查询测试", Func: func(a *Api) {
a.Verify(func(a *Api) error {
return verifyOrmSubTest(a, "链式查询测试")
}).Get("链式查询", 0, Map{
"name": "sample", "success": true,
"tests": Slice{Map{"name": "sample"}},
})
}},
"join": {Desc: "JOIN 查询测试", Func: func(a *Api) {
a.Verify(func(a *Api) error {
return verifyOrmSubTest(a, "JOIN查询测试")
}).Get("JOIN查询", 0, Map{
"name": "sample", "success": true,
"tests": Slice{Map{"name": "sample"}},
})
}},
"aggregate": {Desc: "聚合函数测试", Func: func(a *Api) {
a.Verify(func(a *Api) error {
return verifyOrmSubTest(a, "聚合函数测试")
}).Get("聚合函数", 0, Map{
"name": "sample", "success": true,
"tests": Slice{Map{"name": "sample"}},
})
}},
"pagination": {Desc: "分页查询测试", Func: func(a *Api) {
a.Verify(func(a *Api) error {
return verifyOrmSubTest(a, "分页查询测试")
}).Get("分页查询", 0, Map{
"name": "sample", "success": true,
"tests": Slice{Map{"name": "sample"}},
})
}},
"batch": {Desc: "批量插入测试", Func: func(a *Api) {
a.Verify(func(a *Api) error {
return verifyOrmSubTest(a, "批量插入测试")
}).Get("批量插入", 0, Map{
"name": "sample", "success": true,
"tests": Slice{Map{"name": "sample"}},
})
}},
"upsert": {Desc: "Upsert 测试", Func: func(a *Api) {
a.Verify(func(a *Api) error {
return verifyOrmSubTest(a, "Upsert测试")
}).Get("Upsert测试", 0, Map{
"name": "sample", "success": true,
"tests": Slice{Map{"name": "sample"}},
})
}},
"transaction": {Desc: "事务测试", Func: func(a *Api) {
a.Verify(func(a *Api) error {
return verifyOrmSubTest(a, "事务测试")
}).Get("事务测试", 0, Map{
"name": "sample", "success": true,
"tests": Slice{Map{"name": "sample"}},
})
}},
}
// verifyOrmSubTest 校验 ORM 自测端点的结构完整性
// 注意:这些 handler 是诊断工具,某些子项(如 table.column 格式聚合)在特定数据库下
// 预期 result=false,因此不逐项检查 result 标志,只验证结构
func verifyOrmSubTest(a *Api, testName string) error {
result := a.Resp().GetBody().GetMap("result")
if result == nil {
return fmt.Errorf("result 为 nil")
}
if result.GetString("name") == "" {
return fmt.Errorf("%s 缺少 name 字段", testName)
}
tests := result.GetSlice("tests")
if len(tests) == 0 {
return fmt.Errorf("%s tests 数组为空", testName)
}
return nil
}
File diff suppressed because it is too large Load Diff
+25 -5
View File
@@ -44,10 +44,11 @@ type ApiTestDef struct {
// Api 测试 API 入口,由 RunTests 自动创建并注入
type Api struct {
app *TestApp
path string
t *testing.T
session Map
app *TestApp
path string
t *testing.T
session Map
lastResp *ApiResponse
}
// WithSession 返回一个携带 session 的新 Api 实例
@@ -111,6 +112,14 @@ func (a *Api) Delete(desc string, status int, expect ...interface{}) *ApiRespons
return a.newCase().Delete(desc, status, expect...)
}
// Verify 设置请求后的校验函数,返回 ApiCase 构建器
// 在回调中可用 a.Resp() 做响应值断言 + a.DB() 做数据库校验
func (a *Api) Verify(fn func(a *Api) error) *ApiCase {
c := a.newCase()
c.verifyFn = fn
return c
}
// Note 为下一条用例设置备注,返回可继续链式的 ApiCase
func (a *Api) Note(note string) *ApiCase {
c := a.newCase()
@@ -123,6 +132,11 @@ func (a *Api) DB() *HoTimeDB {
return &a.app.Db
}
// Resp 获取最近一次请求的响应,在 Verify 回调中用于响应值断言
func (a *Api) Resp() *ApiResponse {
return a.lastResp
}
func (a *Api) newCase() *ApiCase {
return &ApiCase{
api: a,
@@ -292,6 +306,7 @@ func (c *ApiCase) execute(method, desc string, expectStatus int, expectArgs ...i
}
}
c.api.lastResp = resp
var verifyError string
if passed && c.verifyFn != nil {
if err := c.verifyFn(c.api); err != nil {
@@ -440,11 +455,16 @@ func (r *ApiResponse) GetMsg() string {
return ""
}
// GetBody 获取完整的响应 body
// GetBody 获取完整的响应 body(JSON 解析后的 Map,可链式 Get*)
func (r *ApiResponse) GetBody() Map {
return r.Body
}
// GetRawBody 获取原始响应字节(用于非 JSON 响应如文件下载、二进制流校验)
func (r *ApiResponse) GetRawBody() []byte {
return r.RawBody
}
// Fail 手动标记测试失败(用于自定义断言)
func (r *ApiResponse) Fail(msg string) {
if r.t != nil {
+21 -8
View File
@@ -67,6 +67,7 @@ type TestRecord struct {
type TestCollector struct {
mu sync.Mutex
Records []TestRecord
Visited map[string]bool // 被 RunTests 实际调用过的路径(不论是否产生记录)
}
func (c *TestCollector) Add(r TestRecord) {
@@ -75,6 +76,16 @@ func (c *TestCollector) Add(r TestRecord) {
c.Records = append(c.Records, r)
}
// MarkVisited 标记路径已被测试框架调用(由 RunTests 在进入方法级 t.Run 时调用)
func (c *TestCollector) MarkVisited(path string) {
c.mu.Lock()
defer c.mu.Unlock()
if c.Visited == nil {
c.Visited = map[string]bool{}
}
c.Visited[path] = true
}
// NewTestApp 创建测试应用实例
// configPath: 配置文件路径(如 "../config/config.json"
// projects: 项目定义(路由 + 测试)
@@ -172,15 +183,17 @@ func (that *TestApp) RunTests(t *testing.T) {
for methodName, apiTest := range ctrTest {
methodName := methodName
apiTest := apiTest
t.Run(methodName, func(t *testing.T) {
err := that.Db.BeginTestTx()
if err != nil {
t.Fatal("开启测试事务失败:", err)
}
defer that.Db.RollbackTestTx()
t.Run(methodName, func(t *testing.T) {
path := "/" + projName + "/" + ctrName + "/" + methodName
that.collector.MarkVisited(path)
path := "/" + projName + "/" + ctrName + "/" + methodName
api := &Api{
err := that.Db.BeginTestTx()
if err != nil {
t.Fatal("开启测试事务失败:", err)
}
defer that.Db.RollbackTestTx()
api := &Api{
app: that,
path: path,
t: t,
+66
View File
@@ -193,6 +193,10 @@ func (that *TestApp) GenerateSwagger(title, version, outputDir string) error {
that.collector.mu.Lock()
records := make([]TestRecord, len(that.collector.Records))
copy(records, that.collector.Records)
visited := map[string]bool{}
for k, v := range that.collector.Visited {
visited[k] = v
}
that.collector.mu.Unlock()
pathRecords := map[string][]TestRecord{}
@@ -206,6 +210,25 @@ func (that *TestApp) GenerateSwagger(title, version, outputDir string) error {
return fmt.Errorf("创建模块目录 %s 失败: %w", projName, err)
}
// 判断是否为部分运行(-run 过滤):统计本项目下所有端点是否全部被访问
totalPaths := 0
visitedPaths := 0
for ctrName, ctr := range projDef.Proj {
for methodName := range ctr {
totalPaths++
if visited["/"+projName+"/"+ctrName+"/"+methodName] {
visitedPaths++
}
}
}
isPartialRun := visitedPaths > 0 && visitedPaths < totalPaths
// 仅部分运行时才加载已有 spec 用于合并,全量运行时清空重建
var existingEndpoints map[string]map[string]interface{}
if isPartialRun {
existingEndpoints = loadExistingEndpoints(filepath.Join(moduleDir, "api-spec.json"))
}
endpoints := []map[string]interface{}{}
for ctrName, ctr := range projDef.Proj {
for methodName := range ctr {
@@ -223,9 +246,24 @@ func (that *TestApp) GenerateSwagger(title, version, outputDir string) error {
}
recs := pathRecords[apiPath]
// 部分运行且该路径未被访问 → 保留已有 spec 中的数据
if isPartialRun && !visited[apiPath] {
if existing, ok := existingEndpoints[apiPath]; ok {
endpoints = append(endpoints, existing)
continue
}
}
httpMethod := "POST"
if len(recs) > 0 && recs[0].Method != "" {
httpMethod = strings.ToUpper(recs[0].Method)
} else if isPartialRun {
if existing, ok := existingEndpoints[apiPath]; ok {
if m, ok := existing["method"].(string); ok && m != "" {
httpMethod = m
}
}
}
var cases []swaggerTestCase
@@ -292,6 +330,34 @@ func (that *TestApp) GenerateSwagger(title, version, outputDir string) error {
return nil
}
// loadExistingEndpoints 读取已有的 api-spec.json,返回 path → endpoint 映射。
// 用于 -run 部分运行时保留未运行端点的已有数据,避免覆盖清空。
func loadExistingEndpoints(specPath string) map[string]map[string]interface{} {
result := map[string]map[string]interface{}{}
data, err := os.ReadFile(specPath)
if err != nil {
return result
}
var spec map[string]interface{}
if err := json.Unmarshal(data, &spec); err != nil {
return result
}
eps, ok := spec["endpoints"].([]interface{})
if !ok {
return result
}
for _, raw := range eps {
ep, ok := raw.(map[string]interface{})
if !ok {
continue
}
if path, ok := ep["path"].(string); ok {
result[path] = ep
}
}
return result
}
func generateSwaggerPortal(swaggerRoot string) error {
entries, err := os.ReadDir(swaggerRoot)
if err != nil {