2026-07-13 07:45:51 +08:00
|
|
|
|
---
|
2026-03-14 10:19:57 +08:00
|
|
|
|
name: API测试框架完整实现
|
|
|
|
|
|
overview: 在 hotimev1.5 框架中实现完整的 API 测试基础设施:链式测试 API、事务回滚隔离、覆盖率追踪、Swagger 文档自动生成。
|
|
|
|
|
|
todos:
|
|
|
|
|
|
- id: db-testtx
|
|
|
|
|
|
content: db/db.go:加 testTx 字段 + BeginTestTx/RollbackTestTx 方法
|
|
|
|
|
|
status: completed
|
|
|
|
|
|
- id: db-query
|
|
|
|
|
|
content: db/query.go:Query(行64) 和 Exec(行120) 加 testTx 优先判断
|
|
|
|
|
|
status: completed
|
|
|
|
|
|
- id: db-transaction
|
|
|
|
|
|
content: db/transaction.go:Action 加 testTx 传递 + SAVEPOINT 分支
|
|
|
|
|
|
status: completed
|
|
|
|
|
|
- id: testing-helper
|
|
|
|
|
|
content: 新增 testing_helper.go:TestApp、NewTestApp、SetupForTest、RunTests(事务隔离)、PrintCoverage
|
|
|
|
|
|
status: completed
|
|
|
|
|
|
- id: testing-api
|
|
|
|
|
|
content: 新增 testing_api.go:注册类型 + Api/ApiCase/ApiResponse 链式 API + TestCollector
|
|
|
|
|
|
status: completed
|
|
|
|
|
|
- id: testing-swagger
|
|
|
|
|
|
content: 新增 testing_swagger.go:GenerateSwagger 合并所有项目生成 Swagger
|
|
|
|
|
|
status: completed
|
|
|
|
|
|
- id: xbc-integration
|
|
|
|
|
|
content: xbc 业务层:user.go 加 UserTest、init.go 加 ProjectTest、新增 app_test.go
|
|
|
|
|
|
status: completed
|
|
|
|
|
|
isProject: false
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
# API 测试框架完整实现
|
|
|
|
|
|
|
|
|
|
|
|
## 一、事务回滚隔离(db 层改造)
|
|
|
|
|
|
|
|
|
|
|
|
核心思路:给 `HoTimeDB` 增加 `testTx` 字段,优先级 `testTx > Tx > DB`。测试模式下 `Action()` 用 MySQL `SAVEPOINT` 代替真事务,保证嵌套事务都在外层测试事务内。
|
|
|
|
|
|
|
|
|
|
|
|
### 1. [db/db.go](d:/work/hotimev1.5/db/db.go) -- 加字段 + 方法
|
|
|
|
|
|
|
|
|
|
|
|
```go
|
|
|
|
|
|
// HoTimeDB 结构体新增字段
|
|
|
|
|
|
testTx *sql.Tx // 测试事务:设置后所有操作都在此事务内
|
|
|
|
|
|
|
|
|
|
|
|
// 新增两个方法
|
|
|
|
|
|
func (that *HoTimeDB) BeginTestTx() error
|
|
|
|
|
|
func (that *HoTimeDB) RollbackTestTx() error
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
### 2. [db/query.go](d:/work/hotimev1.5/db/query.go) -- Query(行64) 和 Exec(行120) 各加一个判断
|
|
|
|
|
|
|
|
|
|
|
|
```go
|
|
|
|
|
|
// 原:if that.Tx != nil { ... } else { ... }
|
|
|
|
|
|
// 改:
|
|
|
|
|
|
if that.testTx != nil {
|
|
|
|
|
|
resl, err = that.testTx.Query(query, processedArgs...)
|
|
|
|
|
|
} else if that.Tx != nil {
|
|
|
|
|
|
resl, err = that.Tx.Query(query, processedArgs...)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
resl, err = db.Query(query, processedArgs...)
|
|
|
|
|
|
}
|
|
|
|
|
|
// Exec 同理
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
### 3. [db/transaction.go](d:/work/hotimev1.5/db/transaction.go) -- Action 加 SAVEPOINT 分支
|
|
|
|
|
|
|
|
|
|
|
|
```go
|
|
|
|
|
|
func (that *HoTimeDB) Action(action func(db HoTimeDB) (isSuccess bool)) (isSuccess bool) {
|
|
|
|
|
|
db := HoTimeDB{
|
|
|
|
|
|
// ...现有字段拷贝...
|
|
|
|
|
|
testTx: that.testTx, // 新增:传递 testTx
|
|
|
|
|
|
}
|
|
|
|
|
|
// 新增:测试模式用 SAVEPOINT
|
|
|
|
|
|
if that.testTx != nil {
|
|
|
|
|
|
spName := "sp_" + Md5(ObjToStr(RandX(100000, 999999)))[:8]
|
|
|
|
|
|
_, _ = that.testTx.Exec("SAVEPOINT " + spName)
|
|
|
|
|
|
db.Tx = that.testTx
|
|
|
|
|
|
isSuccess = action(db)
|
|
|
|
|
|
if !isSuccess {
|
|
|
|
|
|
_, _ = that.testTx.Exec("ROLLBACK TO SAVEPOINT " + spName)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
_, _ = that.testTx.Exec("RELEASE SAVEPOINT " + spName)
|
|
|
|
|
|
}
|
|
|
|
|
|
return isSuccess
|
|
|
|
|
|
}
|
|
|
|
|
|
// 以下原有逻辑不变...
|
|
|
|
|
|
}
|
|
|
|
|
|
```
|
|
|
|
|
|
|
2026-03-14 13:06:32 +08:00
|
|
|
|
### 3.1 testTx 并发保护(调试发现的关键问题)
|
|
|
|
|
|
|
|
|
|
|
|
**问题现象:** 测试运行时出现 `[mysql] busy buffer`、`bad connection` 错误,导致测试挂起。
|
|
|
|
|
|
|
|
|
|
|
|
**根因分析:** `testTx` 是单个 `*sql.Tx` 连接,MySQL 驱动不允许在同一连接上并发执行查询。业务代码中存在 `go func()` 启动的 goroutine(如 `syncGoodsToYst`、`stats.go` 中的统计协程),这些 goroutine 会并发访问 `testTx`,导致驱动层的 `busy buffer` 错误。
|
|
|
|
|
|
|
|
|
|
|
|
**解决方案:** 在 `HoTimeDB` 中新增 `testMu *sync.Mutex`,在 `BeginTestTx()` 时初始化。所有通过 `testTx` 执行的 Query、Exec、SAVEPOINT 操作都通过 `testMu` 加锁保护,确保串行化访问。
|
|
|
|
|
|
|
|
|
|
|
|
```go
|
|
|
|
|
|
// db/db.go - HoTimeDB 新增字段
|
|
|
|
|
|
testMu *sync.Mutex // 保护 testTx 单连接不被并发访问
|
|
|
|
|
|
|
|
|
|
|
|
// db/db.go - BeginTestTx 中初始化
|
|
|
|
|
|
that.testMu = &sync.Mutex{}
|
|
|
|
|
|
|
|
|
|
|
|
// db/query.go - Query 中加锁
|
|
|
|
|
|
if that.testTx != nil {
|
|
|
|
|
|
if that.testMu != nil { that.testMu.Lock() }
|
|
|
|
|
|
resl, err = that.testTx.Query(query, processedArgs...)
|
|
|
|
|
|
// ... 消费 result set ...
|
|
|
|
|
|
if that.testMu != nil { that.testMu.Unlock() }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// db/query.go - Exec 中加锁
|
|
|
|
|
|
if that.testTx != nil {
|
|
|
|
|
|
if that.testMu != nil { that.testMu.Lock() }
|
|
|
|
|
|
resl, e = that.testTx.Exec(query, processedArgs...)
|
|
|
|
|
|
if that.testMu != nil { that.testMu.Unlock() }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// db/transaction.go - SAVEPOINT 操作加锁
|
|
|
|
|
|
if that.testMu != nil { that.testMu.Lock() }
|
|
|
|
|
|
_, _ = that.testTx.Exec("SAVEPOINT " + spName)
|
|
|
|
|
|
if that.testMu != nil { that.testMu.Unlock() }
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
**注意事项:**
|
|
|
|
|
|
- 生产环境不受影响(`testMu` 默认 nil,所有锁检查都有 nil 保护)
|
|
|
|
|
|
- 互斥锁确保 goroutine 中的 DB 操作排队执行而非并发冲突
|
|
|
|
|
|
- Query 锁的范围包括结果集消费(`that.Row(resl)`),防止未读完数据就发起新查询
|
|
|
|
|
|
|
|
|
|
|
|
**改动量:** 3 个文件,约 50 行核心改动 + mutex 保护约 30 行,生产代码零影响(testTx/testMu 默认 nil)。
|
|
|
|
|
|
|
|
|
|
|
|
### 3.2 测试模式下禁用 DB/Redis 缓存(调试发现的关键问题)
|
|
|
|
|
|
|
|
|
|
|
|
**问题现象:** 测试运行时出现 `Error 1205: Lock wait timeout exceeded` 错误,指向 `DELETE FROM cached` 操作。
|
|
|
|
|
|
|
|
|
|
|
|
**根因分析:** 框架的 `HoTimeCache` 支持三级缓存(memory > redis > db)。在测试模式下,DB 缓存的读写操作(如 `DELETE FROM cached`)会尝试使用独立连接,而 `testTx` 持有事务锁,导致 `Lock wait timeout`。即使走 `testTx`,缓存的并发读写也与事务隔离产生冲突。
|
|
|
|
|
|
|
|
|
|
|
|
**解决方案:**
|
|
|
|
|
|
|
|
|
|
|
|
1. 在 `cache/cache.go` 中新增 `DisableDbCache()` 方法:
|
|
|
|
|
|
```go
|
|
|
|
|
|
func (that *HoTimeCache) DisableDbCache() {
|
|
|
|
|
|
that.dbCache = nil
|
|
|
|
|
|
that.redisCache = nil
|
|
|
|
|
|
}
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
2. 在 `testing_helper.go` 的 `NewTestApp` 中调用:
|
|
|
|
|
|
```go
|
|
|
|
|
|
if app.HoTimeCache != nil {
|
|
|
|
|
|
app.HoTimeCache.DisableDbCache()
|
|
|
|
|
|
}
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
3. 在 `db/crud.go` 的 `Select` 方法中,当 `testTx` 激活时跳过缓存逻辑:
|
|
|
|
|
|
```go
|
|
|
|
|
|
if that.testTx == nil {
|
|
|
|
|
|
// 原有缓存读写逻辑
|
|
|
|
|
|
}
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
**影响范围:** 测试期间仅使用 memory 缓存,不影响生产环境。Session 在测试中通过 `WithSession()` 直接注入,不依赖 DB/Redis 缓存。
|
|
|
|
|
|
|
|
|
|
|
|
### 3.3 测试模式下 Query 的重试策略调整
|
|
|
|
|
|
|
|
|
|
|
|
**问题:** `queryWithRetry` 和 `execWithRetry` 在遇到连接错误时会调用 `db.Ping()` 尝试重连,但 `testTx` 是单连接事务,Ping 会创建新连接导致 `busy buffer`。
|
|
|
|
|
|
|
|
|
|
|
|
**解决:** 当 `testTx != nil` 时,跳过 `db.Ping()` 和重试逻辑,直接返回错误。
|
2026-03-14 10:19:57 +08:00
|
|
|
|
|
|
|
|
|
|
## 二、链式测试 API
|
|
|
|
|
|
|
|
|
|
|
|
### 4. 新增 [testing_helper.go](d:/work/hotimev1.5/testing_helper.go)
|
|
|
|
|
|
|
|
|
|
|
|
- `TestApp` 结构体(包含 `*Application`、`projs TestProj`、`collector *TestCollector`)
|
|
|
|
|
|
- `NewTestApp(configPath, TestProj, listeners...)` -- 初始化 Application、注册路由,不启动 HTTP 服务
|
|
|
|
|
|
- `SetupForTest(router Router)` -- 复用 `Run()` 的路由初始化逻辑
|
|
|
|
|
|
- `TestRequest(method, path, body) TestResponse` / `TestRequestWithSession(...)` -- 通过 `httptest` 发请求
|
|
|
|
|
|
- `TestResponse` 结构体
|
|
|
|
|
|
- `RunTests(t)` -- 遍历 TestProj,每个方法级别 `BeginTestTx() + defer RollbackTestTx()`
|
|
|
|
|
|
|
|
|
|
|
|
### 5. 新增 [testing_api.go](d:/work/hotimev1.5/testing_api.go)
|
|
|
|
|
|
|
|
|
|
|
|
注册类型:
|
|
|
|
|
|
|
|
|
|
|
|
- `TestProj map[string]TestProjDef`
|
|
|
|
|
|
- `TestProjDef struct { Proj Proj; Tests ProjTest }`
|
|
|
|
|
|
- `ProjTest map[string]CtrTest`
|
|
|
|
|
|
- `CtrTest map[string]ApiTestDef`
|
|
|
|
|
|
- `ApiTestDef struct { Desc string; Func func(a *Api) }`
|
|
|
|
|
|
|
|
|
|
|
|
链式 API:
|
|
|
|
|
|
|
|
|
|
|
|
- `Api` -- 起点,提供 `JSON/Query/Form/File/WithSession` 数据设置和 `Get/Post/Put/Delete` 直接终端
|
|
|
|
|
|
- `ApiCase` -- 链式构建器,支持叠加多种数据类型,终端操作 `Get/Post/Put/Delete(desc, status, msg...)`
|
|
|
|
|
|
- `ApiResponse` -- 响应对象,提供 `GetStatus/GetResult/GetMsg/GetBody/Fail`
|
|
|
|
|
|
- 每个终端方法内自动记录 `TestRecord` 到 `TestCollector`
|
|
|
|
|
|
|
|
|
|
|
|
### 6. 新增 [testing_swagger.go](d:/work/hotimev1.5/testing_swagger.go)
|
|
|
|
|
|
|
|
|
|
|
|
- `GenerateSwagger(title, version, outputDir)` -- 遍历 TestProj 生成合并的 OpenAPI 3.0 JSON + Swagger UI HTML
|
|
|
|
|
|
- 利用覆盖率数据,未覆盖接口在文档中标注"暂无测试用例"
|
|
|
|
|
|
|
|
|
|
|
|
## 三、覆盖率追踪
|
|
|
|
|
|
|
|
|
|
|
|
集成在 `testing_helper.go` 中:
|
|
|
|
|
|
|
|
|
|
|
|
- `CoverageReport` / `MethodCoverage` / `TestRecord` / `TestCollector` 类型
|
|
|
|
|
|
- `PrintCoverage()` -- 对比 `Proj`(所有路由)和 `ProjTest`(有测试的路由),输出覆盖率报告
|
|
|
|
|
|
- 运行时追踪:每个 `Post/Get/Put/Delete` 终端方法自动收集 `TestRecord`(路径、用例名、通过/失败、耗时)
|
|
|
|
|
|
- 最终输出:总接口数、已覆盖数、覆盖率百分比、每个接口的用例数和通过/失败计数
|
|
|
|
|
|
|
|
|
|
|
|
## 四、业务层接入(xbc 示例)
|
|
|
|
|
|
|
2026-07-13 07:45:51 +08:00
|
|
|
|
### 7. 修改 [app/app/user.go](d:/work/your-app/app/user.go) -- 末尾添加 `var UserTest = CtrTest{...}`
|
2026-03-14 10:19:57 +08:00
|
|
|
|
|
|
|
|
|
|
```go
|
|
|
|
|
|
var UserTest = CtrTest{
|
|
|
|
|
|
"login": {"用户登录", func(a *Api) {
|
|
|
|
|
|
a.JSON(Map{"name": "138", "password": "123456"}).Post("正常登录", 0)
|
|
|
|
|
|
// ...
|
|
|
|
|
|
}},
|
|
|
|
|
|
"create": {"用户注册", func(a *Api) {
|
|
|
|
|
|
phone := "138" + ObjToStr(RandX(10000000, 99999999))
|
|
|
|
|
|
// 不需要 defer 清理,事务自动回滚
|
|
|
|
|
|
a.JSON(Map{"phone": phone, "password": "123456"}).Post("正常注册", 0)
|
|
|
|
|
|
a.JSON(Map{"phone": phone, "password": "123456"}).Post("重复注册", 3, "该手机号已被注册")
|
|
|
|
|
|
}},
|
|
|
|
|
|
}
|
|
|
|
|
|
```
|
|
|
|
|
|
|
2026-07-13 07:45:51 +08:00
|
|
|
|
### 8. 修改 [app/app/init.go](d:/work/your-app/app/init.go) -- 添加 `var ProjectTest = ProjTest{...}`
|
2026-03-14 10:19:57 +08:00
|
|
|
|
|
2026-07-13 07:45:51 +08:00
|
|
|
|
### 9. 新增 [app/app/app_test.go](d:/work/your-app/app/app_test.go)
|
2026-03-14 10:19:57 +08:00
|
|
|
|
|
|
|
|
|
|
```go
|
|
|
|
|
|
func TestMain(m *testing.M) {
|
|
|
|
|
|
testApp = NewTestApp("../config/config.json", TestProj{
|
|
|
|
|
|
"app": {Project, ProjectTest},
|
|
|
|
|
|
})
|
|
|
|
|
|
code := m.Run()
|
|
|
|
|
|
testApp.PrintCoverage()
|
2026-07-13 07:45:51 +08:00
|
|
|
|
testApp.GenerateSwagger("HoTime API", "2.1.0", testApp.Config.GetString("tpt"))
|
2026-03-14 10:19:57 +08:00
|
|
|
|
os.Exit(code)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestApi(t *testing.T) {
|
|
|
|
|
|
testApp.RunTests(t)
|
|
|
|
|
|
}
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
## 五、文件改动汇总
|
|
|
|
|
|
|
2026-03-14 13:06:32 +08:00
|
|
|
|
### hotimev1.5 框架层
|
|
|
|
|
|
- **修改** `db/db.go` -- 加 testTx + testMu 字段,BeginTestTx(初始化 mutex)/RollbackTestTx
|
|
|
|
|
|
- **修改** `db/query.go` -- Query/Exec 各加 testTx 判断 + testMu 互斥锁保护 + 跳过重试逻辑
|
|
|
|
|
|
- **修改** `db/transaction.go` -- Action 加 SAVEPOINT 分支 + testTx/testMu 传递 + SAVEPOINT 操作加锁
|
|
|
|
|
|
- **修改** `db/crud.go` -- Select 中 testTx 激活时跳过缓存逻辑
|
|
|
|
|
|
- **修改** `cache/cache.go` -- 新增 DisableDbCache() 方法 + 新增 SessionsGet/SessionsSet/SessionsDelete 批量操作
|
|
|
|
|
|
- **修改** `session.go` -- 对接批量 Session 缓存操作
|
|
|
|
|
|
- **修改** `testing_helper.go` -- NewTestApp 中调用 DisableDbCache()
|
|
|
|
|
|
- **新增** `testing_helper.go` -- TestApp、RunTests、覆盖率
|
|
|
|
|
|
- **新增** `testing_api.go` -- 链式 API + TestCollector
|
|
|
|
|
|
- **新增** `testing_swagger.go` -- Swagger 生成
|
|
|
|
|
|
|
|
|
|
|
|
### xbc 业务层
|
|
|
|
|
|
- **修改** `app/user.go` -- 末尾添加 UserTest
|
|
|
|
|
|
- **修改** `app/init.go` -- 添加 ProjectTest
|
|
|
|
|
|
- **新增** `app/app_test.go` -- 测试入口(含 Swagger 生成)
|
|
|
|
|
|
- **新增** `app/*_test.go` -- 30+ 个控制器测试文件
|
|
|
|
|
|
- **新增** `wx/wx_test.go` + `wx/*_test.go` -- 微信端测试入口 + 7 个控制器测试
|
|
|
|
|
|
- **新增** `dd/dd_test.go` + `dd/*_test.go` -- 钉钉端测试入口 + 6 个控制器测试
|
|
|
|
|
|
- **新增** `customer/customer_h5_test.go` + `customer/*_test.go` -- H5 客户端测试
|
|
|
|
|
|
- **新增** `sms/sms_test.go` + `sms/sms_ctr_test.go` -- 短信服务测试
|
|
|
|
|
|
- **修改** `app/wechat.go` -- 修复 log.Println -> log.Printf(go vet 警告)
|
|
|
|
|
|
- **修改** `customer/init.go` -- 修复 log.Println -> log.Printf(go vet 警告)
|
|
|
|
|
|
|
|
|
|
|
|
## 六、已知问题与调试经验
|
|
|
|
|
|
|
|
|
|
|
|
### 问题一:busy buffer / bad connection
|
|
|
|
|
|
- **现象:** 测试运行数秒后出现 `[mysql] busy buffer`,随后 `bad connection`,测试挂起
|
|
|
|
|
|
- **根因:** 业务代码中 `go func()` 启动的 goroutine(如 `app/goods.go:syncGoodsToYst`、`app/stats.go` 中的统计协程)并发访问 `testTx` 单连接
|
|
|
|
|
|
- **解决:** 在 `HoTimeDB` 中新增 `testMu *sync.Mutex`,所有 testTx 的 Query/Exec/SAVEPOINT 操作加互斥锁
|
|
|
|
|
|
- **验证方式:** 通过在 query.go 中插入带时间戳和 goroutine 计数的日志,观察到操作严格串行化,无重叠
|
|
|
|
|
|
|
|
|
|
|
|
### 问题二:Lock wait timeout
|
|
|
|
|
|
- **现象:** `Error 1205: Lock wait timeout exceeded`,指向 `DELETE FROM cached`
|
|
|
|
|
|
- **根因:** `HoTimeCache` 的 DB 缓存通过独立连接操作 `cached` 表,与 `testTx` 事务锁冲突
|
|
|
|
|
|
- **解决:** 测试启动时调用 `DisableDbCache()` 禁用 DB/Redis 缓存,仅保留 memory 缓存
|
|
|
|
|
|
|
|
|
|
|
|
### 问题三:go vet 编译失败
|
|
|
|
|
|
- **现象:** `go test` 因 `go vet` 警告 `log.Println call has possible Printf formatting directive %v` 而拒绝运行
|
|
|
|
|
|
- **根因:** `app/wechat.go` 和 `customer/init.go` 中误用 `log.Println` 传入 `%v` 格式化指令
|
|
|
|
|
|
- **解决:** 改为 `log.Printf`
|
2026-03-14 10:19:57 +08:00
|
|
|
|
|