diff --git a/.cursor/plans/api接口测试方案_05b9404e.plan.md b/.cursor/plans/api接口测试方案_05b9404e.plan.md new file mode 100644 index 0000000..f254910 --- /dev/null +++ b/.cursor/plans/api接口测试方案_05b9404e.plan.md @@ -0,0 +1,299 @@ +--- +name: API接口测试方案 +overview: hotime 框架新增链式测试 API:数据在前方法在后,Post/Get 一步完成用例+断言,TestProj 合并注册,自动生成 Swagger UI。 +todos: + - id: framework-testing-helper + content: hotimev1.5/testing_helper.go:TestApp、NewTestApp、SetupForTest、TestRequest/WithSession、TestResponse + status: pending + - id: framework-testing-api + content: hotimev1.5/testing_api.go:TestProj/ProjTest/CtrTest、Api(JSON/Query/Form/File/WithSession→Post/Get)、RunTests + status: pending + - id: framework-testing-swagger + content: hotimev1.5/testing_swagger.go:GenerateSwagger 合并所有项目生成一份 Swagger + status: pending + - id: xbc-user-test + content: xbc/app/user.go 末尾添加 UserTest,init.go 添加 ProjectTest,新增 app_test.go + status: pending +isProject: false +--- + +# API 接口测试 + Swagger 文档自动生成(最终版) + +## 一、最终链式设计:数据在前,方法在后 + +每条链以 `Post/Get/Put/Delete("描述", 期望status, [期望msg])` 结尾,这个终端调用同时完成:命名用例 + 发送请求 + 校验结果 + 收集文档。 + +```go +// 数据 → 方法(终端操作) +a.JSON(Map{"name": "138", "password": "123"}).Post("正常登录", 0) +a.JSON(Map{"name": "138", "password": ""}).Post("密码为空", 4, "用户名或密码不能为空") +a.Get("未登录", 2, "请先登录") +a.Query(Map{"shop_id": "1"}).Get("查询列表", 0) +a.WithSession(Map{"user_id": int64(1)}).Get("已登录", 0) +a.Query(Map{"token": "abc"}).JSON(Map{"phone": "138"}).Post("混合传参", 3, "异常") +a.File("file", "a.png", imgBytes).Post("上传文件", 0) +``` + +### 对比之前的写法 + +``` +之前(三步):a.PostCase("正常登录").JSON(Map{...}).Test(0) +之前(两步):a.Post("正常登录", Map{...}).Test(0) +现在(一步):a.JSON(Map{...}).Post("正常登录", 0) ← 终端操作只有一个 +现在(零数据):a.Get("未登录", 2, "请先登录") ← 最简一行 +``` + +## 二、完整场景对照 + +```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 + 参数 +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": "new"}).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": "1"}).Post("表单提交", 0) + +// 文件上传 +a.File("file", "a.png", imgBytes).Post("上传文件", 0) + +// 文件 + 表单字段 +a.File("file", "a.png", imgBytes).Form(Map{"type": "avatar"}).Post("上传+表单", 0) + +// PUT JSON +a.JSON(Map{"name": "新名字"}).Put("更新信息", 0) + +// DELETE + 参数 +a.Query(Map{"id": "1"}).Delete("删除", 0) + +// 校验响应数据(status=0 时) +resp := a.JSON(Map{"name": "138", "password": "123"}).Post("正常登录", 0) +if resp.GetBody().GetMap("result").GetString("token") == "" { + resp.Fail("缺少 token") +} +``` + +## 三、完整示例:user.go + +```go +var UserCtr = Ctr{ + "login": func(that *Context) { /* ... */ }, + "info": func(that *Context) { /* ... */ }, + "token": func(that *Context) { /* ... */ }, + "create": func(that *Context) { /* ... */ }, + "file": func(that *Context) { /* ... */ }, +} + +// ========= 接口测试 ========= +var UserTest = CtrTest{ + "login": {"用户账号密码登录", 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, "手机号码格式错误") + a.JSON(Map{"name": "13800138000", "password": "wrong"}).Post("密码错误", 4, "用户名或密码错误") + }}, + "info": {"获取用户信息", func(a *Api) { + a.Get("未登录", 2, "请先登录") + a.WithSession(Map{"user_id": int64(1)}).Get("已登录", 0) + }}, + "token": {"获取会话Token", func(a *Api) { + a.JSON(Map{"phone": "13800138000"}).Post("正确手机号", 0) + a.JSON(Map{"phone": "138"}).Post("手机号格式错误", 3, "请输入正确的手机号") + }}, + "create": {"用户注册", func(a *Api) { + phone := "138" + ObjToStr(RandX(10000000, 99999999)) + defer a.DB().Delete("user", Map{"phone": phone}) + + a.JSON(Map{"phone": phone, "password": "123456"}).Post("正常注册", 0) + a.JSON(Map{"phone": phone, "password": "123456"}).Post("重复注册", 3, "该手机号已被注册") + a.JSON(Map{"phone": "138", "password": "123456"}).Post("手机号格式错误", 3, "请输入正确的手机号") + a.JSON(Map{"phone": "13899999999", "password": "12"}).Post("密码太短", 3, "密码不能少于6位") + }}, + "forget": {"忘记密码", func(a *Api) { + a.JSON(Map{"phone": "13800138000"}).Post("无token", 3, "异常") + a.Query(Map{"token": "abc"}).JSON(Map{"phone": "138", "code": "1234"}).Post("手机号错误", 3, "请输入正确的手机号") + }}, + "file": {"文件上传", func(a *Api) { + a.File("file", "test.txt", []byte("hello")).Post("未登录上传", 2, "你还没有登录") + a.WithSession(Map{"user_id": int64(1)}).File("file", "test.txt", []byte("hello")).Post("已登录上传", 0) + }}, +} +``` + +## 四、TestProj 注册 + app_test.go + +### init.go + +```go +var Project = Proj{ + "user": UserCtr, + "goods": GoodsCtr, + // ... +} + +var ProjectTest = ProjTest{ + "user": UserTest, + "goods": GoodsTest, +} +``` + +### app_test.go + +```go +package app + +import ( + "os" + "testing" + . "code.hoteas.com/golang/hotime" +) + +var testApp *TestApp + +func TestMain(m *testing.M) { + testApp = NewTestApp("../config/config.json", TestProj{ + "app": {Project, ProjectTest}, + }) + code := m.Run() + testApp.GenerateSwagger("小帮菜 API", "2.1.0", testApp.Config.GetString("tpt")) + os.Exit(code) +} + +func TestApi(t *testing.T) { + testApp.RunTests(t) +} +``` + +## 五、Swagger:多项目合并为一份文档 + +`GenerateSwagger` 遍历 `TestProj` 中所有项目的所有测试数据,生成**一份合并的 OpenAPI JSON**。每个项目作为 Swagger 的 tag 分组: + +```json +{ + "tags": [ + {"name": "app/user", "description": "用户接口"}, + {"name": "app/goods", "description": "货品接口"}, + {"name": "customer/customer", "description": "客户端接口"} + ], + "paths": { + "/app/user/login": { ... }, + "/app/user/info": { ... }, + "/customer/customer/bindPhone": { ... } + } +} +``` + +多项目不会覆盖,全部汇聚在同一个文件中,按 tag 分组展示。 + +## 六、指定运行范围 + +Go 的 `-run` 参数支持按子测试层级过滤(`/` 分隔正则匹配): + +```bash +# 全部 +go test ./app/... -v + +# 只跑 user 模块的所有接口 +go test ./app/... -v -run TestApi/user + +# 只跑 user 模块的 login 接口 +go test ./app/... -v -run TestApi/user/login + +# 只跑 login 接口的"密码为空"用例 +go test ./app/... -v -run TestApi/user/login/密码为空 + +# 跑 user 和 goods 两个模块 +go test ./app/... -v -run "TestApi/(user|goods)" + +# 只跑所有模块中包含"未登录"的用例 +go test ./app/... -v -run "TestApi/.*/.*未登录" +``` + +**不需要框架做任何额外处理**,Go 的 `t.Run()` 子测试机制天然支持。 + +## 七、框架类型设计 + +```go +// === 注册类型 === +type TestProj map[string]TestProjDef +type TestProjDef struct { Proj Proj; Tests ProjTest } +type ProjTest map[string]CtrTest +type CtrTest map[string]ApiTestDef +type ApiTestDef struct { Desc string; Func func(a *Api) } + +// === Api:起点,数据设置 === +type Api struct { + app *TestApp + path string + t interface{} + desc string + session Map +} + +// 数据设置(返回 *ApiCase,开始构建) +func (a *Api) JSON(body interface{}) *ApiCase +func (a *Api) Query(params Map) *ApiCase +func (a *Api) Form(body Map) *ApiCase +func (a *Api) File(field, name string, content []byte) *ApiCase + +// Session(返回新 *Api,后续调用都带此 Session) +func (a *Api) WithSession(s Map) *Api + +// 直接终端(无数据时直接调用) +func (a *Api) Get(desc string, status int, msg ...string) *ApiResponse +func (a *Api) Post(desc string, status int, msg ...string) *ApiResponse +func (a *Api) Put(desc string, status int, msg ...string) *ApiResponse +func (a *Api) Delete(desc string, status int, msg ...string) *ApiResponse + +// 数据库 +func (a *Api) DB() *HoTimeDB + +// === ApiCase:链式构建器 === +type ApiCase struct { /* api, session, query, jsonBody, formBody, file... */ } + +// 继续叠加数据 +func (c *ApiCase) JSON(body interface{}) *ApiCase +func (c *ApiCase) Query(params Map) *ApiCase +func (c *ApiCase) Form(body Map) *ApiCase +func (c *ApiCase) File(field, name string, content []byte) *ApiCase +func (c *ApiCase) WithSession(s Map) *ApiCase + +// 终端操作(HTTP 方法 + 用例名 + 断言) +func (c *ApiCase) Get(desc string, status int, msg ...string) *ApiResponse +func (c *ApiCase) Post(desc string, status int, msg ...string) *ApiResponse +func (c *ApiCase) Put(desc string, status int, msg ...string) *ApiResponse +func (c *ApiCase) Delete(desc string, status int, msg ...string) *ApiResponse + +// === ApiResponse === +type ApiResponse struct { StatusCode int; Body Map; RawBody []byte } +func (r *ApiResponse) GetStatus() int +func (r *ApiResponse) GetResult() interface{} +func (r *ApiResponse) GetMsg() string +func (r *ApiResponse) GetBody() Map +func (r *ApiResponse) Fail(msg string) // 自定义断言失败 +``` + +## 八、文件清单 + +- **新增** `d:/work/hotimev1.5/testing_helper.go` +- **新增** `d:/work/hotimev1.5/testing_api.go` +- **新增** `d:/work/hotimev1.5/testing_swagger.go` +- **修改** `d:/work/xbc/app/user.go` -- 末尾添加 UserTest +- **修改** `d:/work/xbc/app/init.go` -- 添加 ProjectTest +- **新增** `d:/work/xbc/app/app_test.go` diff --git a/.cursor/plans/api测试框架完整实现_ce7fcbde.plan.md b/.cursor/plans/api测试框架完整实现_ce7fcbde.plan.md new file mode 100644 index 0000000..3d57502 --- /dev/null +++ b/.cursor/plans/api测试框架完整实现_ce7fcbde.plan.md @@ -0,0 +1,180 @@ +--- +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 + } + // 以下原有逻辑不变... +} +``` + +**改动量:** 3 个文件,约 50 行,生产代码零影响(testTx 默认 nil)。 + +## 二、链式测试 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 示例) + +### 7. 修改 [xbc/app/user.go](d:/work/xbc/app/user.go) -- 末尾添加 `var UserTest = CtrTest{...}` + +```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, "该手机号已被注册") + }}, +} +``` + +### 8. 修改 [xbc/app/init.go](d:/work/xbc/app/init.go) -- 添加 `var ProjectTest = ProjTest{...}` + +### 9. 新增 [xbc/app/app_test.go](d:/work/xbc/app/app_test.go) + +```go +func TestMain(m *testing.M) { + testApp = NewTestApp("../config/config.json", TestProj{ + "app": {Project, ProjectTest}, + }) + code := m.Run() + testApp.PrintCoverage() + testApp.GenerateSwagger("小帮菜 API", "2.1.0", testApp.Config.GetString("tpt")) + os.Exit(code) +} + +func TestApi(t *testing.T) { + testApp.RunTests(t) +} +``` + +## 五、文件改动汇总 + +- **修改** `d:/work/hotimev1.5/db/db.go` -- 加 testTx 字段 + BeginTestTx/RollbackTestTx +- **修改** `d:/work/hotimev1.5/db/query.go` -- Query/Exec 各加 testTx 判断 +- **修改** `d:/work/hotimev1.5/db/transaction.go` -- Action 加 SAVEPOINT 分支 + testTx 传递 +- **新增** `d:/work/hotimev1.5/testing_helper.go` -- TestApp、RunTests、覆盖率 +- **新增** `d:/work/hotimev1.5/testing_api.go` -- 链式 API + TestCollector +- **新增** `d:/work/hotimev1.5/testing_swagger.go` -- Swagger 生成 +- **修改** `d:/work/xbc/app/user.go` -- 末尾添加 UserTest +- **修改** `d:/work/xbc/app/init.go` -- 添加 ProjectTest +- **新增** `d:/work/xbc/app/app_test.go` -- 测试入口 + diff --git a/.cursor/plans/测试框架文档编写_3cbaf8c4.plan.md b/.cursor/plans/测试框架文档编写_3cbaf8c4.plan.md new file mode 100644 index 0000000..f198f41 --- /dev/null +++ b/.cursor/plans/测试框架文档编写_3cbaf8c4.plan.md @@ -0,0 +1,41 @@ +--- +name: 测试框架文档编写 +overview: 在 docs/ 目录新增 API 测试框架文档,并在 README.md 文档表格中增加入口链接,保持与现有文档风格一致。 +todos: + - id: docs-testing + content: 新增 docs/Testing_API测试框架.md 文档 + status: completed + - id: readme-entry + content: README.md 文档表格增加测试框架入口 + status: completed +isProject: false +--- + +# API 测试框架文档 + +## 改动文件 + +### 1. 新增 `docs/Testing_API测试框架.md` + +参照现有文档风格(如 QUICKSTART.md、HoTimeDB_API参考.md),包含以下章节: + +- 概述(事务回滚隔离 + 链式 API + 覆盖率 + Swagger) +- 快速开始(3 步接入示例) +- 核心类型(TestProj/TestProjDef/ProjTest/CtrTest/ApiTestDef) +- 链式 API 参考(Api/ApiCase/ApiResponse 的所有方法) +- 完整场景对照表(GET/POST/JSON/Form/File/混合/WithSession) +- 事务回滚机制说明(testTx + SAVEPOINT 原理) +- 覆盖率报告(PrintCoverage 输出示例) +- Swagger 文档生成(GenerateSwagger 用法) +- 指定运行范围(go test -run 用法) +- 框架改动说明(db 层 3 文件改动概述) + +### 2. 修改 [README.md](d:/work/hotimev1.5/README.md) + +在文档表格(第 18-27 行)中增加一行: + +```markdown +| [API 测试框架](docs/Testing_API测试框架.md) | 接口测试、事务隔离、覆盖率追踪、Swagger 文档生成 | +``` + +插入位置:在"改进规划"行之前,作为文档表格的倒数第二行。 \ No newline at end of file diff --git a/README.md b/README.md index 6ca03e0..66ed04c 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# HoTime +# HoTime **高性能 Go Web 服务框架** @@ -11,6 +11,7 @@ - **三级缓存** - Memory > Redis > DB,自动穿透与回填 - **Session 管理** - 内置会话管理,支持多种存储后端 - **代码生成** - 根据数据库表自动生成 CRUD 接口 +- **API 测试** - 内置接口测试框架,链式 API、事务自动回滚、覆盖率报告、交互式调试控制台 - **开箱即用** - 微信支付/公众号/小程序、阿里云、腾讯云等 SDK 内置 ## 文档 @@ -24,6 +25,7 @@ | [代码生成器](docs/CodeGen_使用说明.md) | 自动 CRUD 代码生成、配置规则 | | [数据库设计规范](docs/DatabaseDesign_数据库设计规范.md) | 表命名、字段命名、时间类型、必有字段规范 | | [代码生成配置规范](docs/CodeConfig_代码生成配置规范.md) | codeConfig、菜单权限、字段规则配置说明 | +| [API 测试框架](docs/Testing_API测试框架.md) | 接口测试、事务隔离、覆盖率追踪、API 调试控制台生成 | | [改进规划](docs/ROADMAP_改进规划.md) | 待改进项、设计思考、版本迭代规划 | ## 安装 @@ -59,6 +61,7 @@ go get code.hoteas.com/golang/hotime | 内置缓存 | ✅ 三级缓存 | ❌ | ❌ | ❌ | | Session | ✅ 内置 | ❌ 需插件 | ❌ 需插件 | ❌ 需插件 | | 代码生成 | ✅ | ❌ | ❌ | ❌ | +| API 测试框架 | ✅ 内置 | ❌ 需插件 | ❌ 需插件 | ❌ 需插件 | | 微信/支付集成 | ✅ 内置 | ❌ | ❌ | ❌ | ## 适用场景 diff --git a/application.go b/application.go index 7d01224..7096559 100644 --- a/application.go +++ b/application.go @@ -1,14 +1,8 @@ package hotime import ( - . "code.hoteas.com/golang/hotime/cache" - "code.hoteas.com/golang/hotime/code" - . "code.hoteas.com/golang/hotime/common" - . "code.hoteas.com/golang/hotime/db" - . "code.hoteas.com/golang/hotime/log" "database/sql" "fmt" - "github.com/sirupsen/logrus" "io/ioutil" "net/http" "net/url" @@ -17,6 +11,13 @@ import ( "strconv" "strings" "time" + + . "code.hoteas.com/golang/hotime/cache" + "code.hoteas.com/golang/hotime/code" + . "code.hoteas.com/golang/hotime/common" + . "code.hoteas.com/golang/hotime/db" + . "code.hoteas.com/golang/hotime/log" + "github.com/sirupsen/logrus" ) type Application struct { diff --git a/db/db.go b/db/db.go index 25a5ad2..b6c3bcc 100644 --- a/db/db.go +++ b/db/db.go @@ -32,6 +32,7 @@ type HoTimeDB struct { mu sync.RWMutex limitMu sync.Mutex Dialect Dialect // 数据库方言适配器 + testTx *sql.Tx // 测试事务:设置后所有操作都在此事务内,测试结束回滚 } // SetConnect 设置数据库配置连接 @@ -145,3 +146,23 @@ func trimQuotes(s string) string { } return s } + +// BeginTestTx 开启测试事务,设置后所有 Query/Exec/Action 都在此事务内执行 +func (that *HoTimeDB) BeginTestTx() error { + tx, err := that.DB.Begin() + if err != nil { + return err + } + that.testTx = tx + return nil +} + +// RollbackTestTx 回滚测试事务,清除 testTx 状态 +func (that *HoTimeDB) RollbackTestTx() error { + if that.testTx != nil { + err := that.testTx.Rollback() + that.testTx = nil + return err + } + return nil +} diff --git a/db/query.go b/db/query.go index f3df0dd..db6d1ac 100644 --- a/db/query.go +++ b/db/query.go @@ -61,7 +61,9 @@ func (that *HoTimeDB) queryWithRetry(query string, retried bool, args ...interfa // 处理参数中的 slice 类型 processedArgs := that.processArgs(args) - if that.Tx != nil { + 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...) @@ -117,7 +119,9 @@ func (that *HoTimeDB) execWithRetry(query string, retried bool, args ...interfac // 处理参数中的 slice 类型 processedArgs := that.processArgs(args) - if that.Tx != nil { + if that.testTx != nil { + resl, e = that.testTx.Exec(query, processedArgs...) + } else if that.Tx != nil { resl, e = that.Tx.Exec(query, processedArgs...) } else { resl, e = that.DB.Exec(query, processedArgs...) diff --git a/db/transaction.go b/db/transaction.go index c9beb65..f1ca70d 100644 --- a/db/transaction.go +++ b/db/transaction.go @@ -1,11 +1,16 @@ package db import ( + "fmt" "sync" + "sync/atomic" ) +var savepointCounter uint64 + // Action 事务操作 // 如果 action 返回 true 则提交事务;返回 false 则回滚 +// 测试模式下(testTx != nil),使用 SAVEPOINT 代替真事务,确保嵌套事务不会绕过外层测试事务 func (that *HoTimeDB) Action(action func(db HoTimeDB) (isSuccess bool)) (isSuccess bool) { db := HoTimeDB{ DB: that.DB, @@ -26,6 +31,20 @@ func (that *HoTimeDB) Action(action func(db HoTimeDB) (isSuccess bool)) (isSucce Dialect: that.Dialect, mu: sync.RWMutex{}, limitMu: sync.Mutex{}, + testTx: that.testTx, + } + + if that.testTx != nil { + spName := fmt.Sprintf("sp_%d", atomic.AddUint64(&savepointCounter, 1)) + _, _ = 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 } tx, err := db.Begin() diff --git a/docs/Testing_API测试框架.md b/docs/Testing_API测试框架.md new file mode 100644 index 0000000..3b793c3 --- /dev/null +++ b/docs/Testing_API测试框架.md @@ -0,0 +1,515 @@ +# HoTime API 测试框架使用说明 + +不启动 HTTP 服务、自动事务回滚、链式 API、覆盖率报告、API 调试控制台生成。 + +## 目录 + +- [概述](#概述) +- [快速开始](#快速开始) +- [核心类型](#核心类型) +- [链式 API 参考](#链式-api-参考) +- [完整场景对照](#完整场景对照) +- [事务隔离机制](#事务隔离机制) +- [覆盖率报告](#覆盖率报告) +- [API 调试控制台](#api-调试控制台) +- [指定运行范围](#指定运行范围) +- [框架改动说明](#框架改动说明) + +--- + +## 概述 + +HoTime 内置 API 测试框架,核心能力: + +| 能力 | 说明 | +|------|------| +| **零启动** | 基于 `net/http/httptest`,不需要启动 HTTP 服务器 | +| **事务回滚** | 每个接口方法独立开启数据库事务,测试结束自动回滚,数据库不留任何痕迹 | +| **SAVEPOINT** | 业务代码中的 `that.Db.Action()` 在测试模式下自动用 SAVEPOINT 替代真事务,嵌套事务完整可用 | +| **链式 API** | `a.JSON(...).Post("描述", 期望status)` 一行完成:数据组装 + 请求 + 断言 | +| **覆盖率** | 自动对比路由注册表与测试定义,输出哪些接口已覆盖、哪些未覆盖 | +| **调试控制台** | 根据测试用例自动生成交互式 API 调试控制台,支持在线测试、认证管理、参数编辑 | + +--- + +## 快速开始 + +### 第一步:创建测试定义文件(推荐) + +**推荐方式**:为每个 handler 创建对应的 `_test.go` 文件,测试定义与业务代码分离。`_test.go` 文件仅在 `go test` 时编译,不影响生产构建。 + +```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) { /* ... */ }, +} +``` + +```go +// app/user_test.go — 测试定义 +package app + +import . "code.hoteas.com/golang/hotime" +import . "code.hoteas.com/golang/hotime/common" + +var UserTest = CtrTest{ + "login": {Desc: "用户登录", Func: func(a *Api) { + a.JSON(Map{"name": "13800138000", "password": "123456"}).Post("正常登录", 0) + a.JSON(Map{"name": "13800138000", "password": ""}).Post("密码为空", 4, "用户名或密码不能为空") + a.JSON(Map{"name": "1380013", "password": "123456"}).Post("手机号格式错误", 4, "手机号码格式错误") + }}, + "create": {Desc: "用户注册", Func: func(a *Api) { + phone := "138" + ObjToStr(RandX(10000000, 99999999)) + a.JSON(Map{"phone": phone, "password": "123456"}).Post("正常注册", 0) + a.JSON(Map{"phone": phone, "password": "123456"}).Post("重复注册", 3, "该手机号已被注册") + }}, +} +``` + +> **为什么要分离?** `_test.go` 文件仅在 `go test` 时编译,不会进入生产二进制,保持业务代码干净。同时符合 Go 语言惯例:测试相关代码放在 `_test.go` 文件中。 + +### 第二步:注册路由和测试 + +路由在 `init.go` 中注册,测试注册放在 `app_test.go` 中(因为 `_test.go` 中的变量只在测试编译时可见): + +```go +// app/init.go — 只注册路由 +var Project = Proj{ + "user": UserCtr, + // ... +} +``` + +```go +// app/app_test.go — 注册测试 + 运行入口 +package app + +import ( + "os" + "testing" + . "code.hoteas.com/golang/hotime" +) + +var ProjectTest = ProjTest{ + "user": UserTest, +} + +var testApp *TestApp + +func TestMain(m *testing.M) { + os.Chdir("..") // 确保工作目录为项目根目录 + testApp = NewTestApp("config/config.json", TestProj{ + "app": {Proj: Project, Tests: ProjectTest}, + }) + code := m.Run() + testApp.PrintCoverage() + testApp.GenerateSwagger("My API", "1.0.0", testApp.Config.GetString("tpt")) + os.Exit(code) +} + +func TestApi(t *testing.T) { + testApp.RunTests(t) +} +``` + +### 推荐目录结构 + +``` +app/ +├── user.go ← 业务代码 (UserCtr) +├── user_test.go ← 测试定义 (UserTest) +├── tags.go ← 业务代码 (TagsCtr) +├── tags_test.go ← 测试定义 (TagsTest) +├── init.go ← 路由注册 (Project) +└── app_test.go ← 测试注册 (ProjectTest) + TestMain + TestApi +``` + +### 运行测试 + +```bash +go test ./app/... -v +``` + +> **注意**:`TestMain` 中的 `os.Chdir("..")` 确保 `go test ./app/` 的工作目录为项目根目录,使配置文件路径和模板输出路径正确。 + +--- + +## 核心类型 + +### 注册类型 + +```go +// 顶层:多项目集合(项目名 → 定义) +type TestProj map[string]TestProjDef + +// 单项目:路由 + 测试绑定在一起 +type TestProjDef struct { + Proj Proj // 路由定义(与 Run 传入的 Router 保持一致) + Tests ProjTest // 测试定义 +} + +// 控制器级测试(控制器名 → 控制器测试集合) +type ProjTest map[string]CtrTest + +// 方法级测试(方法名 → 单条用例定义) +type CtrTest map[string]ApiTestDef + +// 单个接口的用例定义 +type ApiTestDef struct { + Desc string // 接口描述,用于 Swagger 说明和 t.Run 层级名称 + Func func(a *Api) // 测试函数体 +} +``` + +### TestApp + +```go +// NewTestApp 创建测试应用 +// configPath: 配置文件路径 +// projects: TestProj 注册所有项目的路由和测试 +// listeners: 可选,与 SetConnectListener 相同的请求拦截器 +func NewTestApp(configPath string, projects TestProj, listeners ...func(*Context) bool) *TestApp + +// RunTests 运行所有注册的测试(每个方法级别自动事务隔离) +func (app *TestApp) RunTests(t *testing.T) + +// PrintCoverage 输出覆盖率报告,返回报告结构体 +func (app *TestApp) PrintCoverage() CoverageReport + +// GenerateSwagger 生成 API 调试控制台(api-spec.json + index.html) +func (app *TestApp) GenerateSwagger(title, version, outputDir string) error + +// DB 获取测试应用的数据库实例(在事务内) +func (app *TestApp) DB() *HoTimeDB +``` + +--- + +## 链式 API 参考 + +测试函数接收 `*Api` 作为入口,调用数据设置方法返回 `*ApiCase`,再调用终端方法发出请求并断言。 + +### Api — 测试入口 + +| 方法 | 说明 | 返回 | +|------|------|------| +| `a.JSON(body)` | 设置 JSON body | `*ApiCase` | +| `a.Query(params)` | 设置 URL 查询参数 | `*ApiCase` | +| `a.Form(body)` | 设置 form 表单 body | `*ApiCase` | +| `a.File(field, name, content)` | 设置上传文件 | `*ApiCase` | +| `a.WithSession(s)` | 返回携带 session 的新 Api | `*Api` | +| `a.Get(desc, status, msg...)` | 直接 GET 请求(无数据场景) | `*ApiResponse` | +| `a.Post(desc, status, msg...)` | 直接 POST 请求(无数据场景) | `*ApiResponse` | +| `a.Put(desc, status, msg...)` | 直接 PUT 请求 | `*ApiResponse` | +| `a.Delete(desc, status, msg...)` | 直接 DELETE 请求 | `*ApiResponse` | +| `a.DB()` | 获取数据库实例(在事务内,可用于准备/清查数据) | `*HoTimeDB` | + +### ApiCase — 链式构建器 + +`Api` 的数据设置方法返回 `*ApiCase`,支持继续叠加数据: + +| 方法 | 说明 | 返回 | +|------|------|------| +| `c.JSON(body)` | 追加 JSON body | `*ApiCase` | +| `c.Query(params)` | 追加 URL 查询参数 | `*ApiCase` | +| `c.Form(body)` | 追加 form 字段 | `*ApiCase` | +| `c.File(field, name, content)` | 设置上传文件 | `*ApiCase` | +| `c.WithSession(s)` | 覆盖本次请求的 session | `*ApiCase` | +| `c.Get(desc, status, msg...)` | 终端:发送 GET 请求并断言 | `*ApiResponse` | +| `c.Post(desc, status, msg...)` | 终端:发送 POST 请求并断言 | `*ApiResponse` | +| `c.Put(desc, status, msg...)` | 终端:发送 PUT 请求并断言 | `*ApiResponse` | +| `c.Delete(desc, status, msg...)` | 终端:发送 DELETE 请求并断言 | `*ApiResponse` | + +**终端方法参数说明:** + +| 参数 | 类型 | 说明 | +|------|------|------| +| `desc` | string | 用例描述,作为 `t.Run` 的子测试名称 | +| `status` | int | 期望的 JSON 响应体中 `status` 字段值(0=成功) | +| `msg` | ...string | 可选,期望的 `msg` 字段值(传空则不校验) | + +### ApiResponse — 响应对象 + +终端方法返回 `*ApiResponse`,可用于进一步的自定义断言: + +```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:完整响应体 + +// 自定义断言 +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) + +// 校验响应中的具体字段 +resp := a.JSON(Map{"name": "138", "password": "123"}).Post("正常登录", 0) +if resp.GetBody().GetMap("result").GetString("token") == "" { + resp.Fail("响应中缺少 token 字段") +} +``` + +--- + +## 事务隔离机制 + +### 工作原理 + +`RunTests` 在每个**方法级**测试前开启一个数据库事务(`testTx`),测试结束后无论成功与否一律回滚: + +``` +TestApi +└── app(项目名) + └── user(控制器名) + └── create(方法名)← 在这一级 BEGIN → 运行测试用例 → ROLLBACK + └── 正常注册(用例) + └── 重复注册(用例) +``` + +所有测试用例共享同一个 `testTx`,因此"正常注册"写入的数据对"重复注册"可见,模拟了真实的并发隔离场景。 + +### 嵌套事务 (SAVEPOINT) + +业务代码中常见的 `that.Db.Action()` 在测试模式下自动切换为 SAVEPOINT 机制,不会脱离外层测试事务: + +```go +// 业务代码(无需修改) +that.Db.Action(func(db HoTimeDB) bool { + db.Insert("order", orderData) // 在 SAVEPOINT sp_N 内执行 + db.Update("customer", balanceData, where) + return true // → RELEASE SAVEPOINT sp_N(提交到外层 testTx) + // 返回 false → ROLLBACK TO SAVEPOINT sp_N(只回滚这一层) +}) +// 测试结束 → ROLLBACK testTx(回滚一切) +``` + +| 机制 | 生产模式 | 测试模式 | +|------|----------|----------| +| `Action()` | `BEGIN` / `COMMIT` / `ROLLBACK` | `SAVEPOINT` / `RELEASE` / `ROLLBACK TO` | +| 数据持久化 | 持久化到数据库 | 测试结束全部回滚 | +| 对现有代码影响 | 无 | 无(`testTx` 默认 nil) | + +### 一次性数据的处理 + +对于需要随机数据的测试(如注册),直接用 `RandX` 生成随机值即可,**无需手动清理**: + +```go +// app/user_test.go 中的 UserTest 片段 +"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, "该手机号已被注册") +}}, +``` + +--- + +## 覆盖率报告 + +`PrintCoverage()` 自动对比 `Proj`(所有路由)和 `ProjTest`(有测试的路由),在 `TestMain` 中调用: + +```go +func TestMain(m *testing.M) { + // ... + code := m.Run() + testApp.PrintCoverage() // 在测试运行完成后输出报告 + os.Exit(code) +} +``` + +**输出示例:** + +``` +========== API 测试覆盖率报告 ========== +总接口: 42 | 已覆盖: 6 | 覆盖率: 14.3% + +已覆盖的接口: + + /app/user/login 4 用例 (4 通过, 0 失败) 12ms + + /app/user/info 2 用例 (2 通过, 0 失败) 5ms + + /app/user/token 2 用例 (2 通过, 0 失败) 4ms + + /app/user/create 4 用例 (4 通过, 0 失败) 18ms + + /app/user/forget 2 用例 (2 通过, 0 失败) 6ms + + /app/user/file 2 用例 (2 通过, 0 失败) 9ms + +未覆盖的接口: + - app/goods/list + - app/goods/create + - app/order/list + - ... (共36个未覆盖) +======================================== +``` + +--- + +## API 调试控制台 + +`GenerateSwagger` 遍历 `TestProj` 中所有项目的路由和测试用例,生成一个**交互式 API 调试控制台**(暗色高对比度主题),支持在线调试、参数编辑、认证管理。 + +```go +testApp.GenerateSwagger( + "My API", // 文档标题 + "2.1.0", // 版本号 + testApp.Config.GetString("tpt"), // 输出目录(如 "tpt") +) +``` + +生成文件: + +``` +{outputDir}/swagger/ +├── api-spec.json ← 自定义 API 规范文件(包含路由、测试用例、请求/响应数据) +└── index.html ← 交互式调试控制台 +``` + +访问地址:`http://localhost:8081/swagger/` + +### 控制台功能 + +| 功能 | 说明 | +|------|------| +| **三级导航** | 左侧侧边栏按 项目 > 控制器 > 方法 三级树形展开 | +| **搜索和筛选** | 支持关键字搜索,按 全部/已测试/未测试 筛选 | +| **测试用例查看** | 每个接口的测试用例以手风琴形式展示(第一个默认展开),包含请求头、请求参数(Query/JSON/Form/文件)、响应结果 | +| **接口调试** | 可编辑的请求头、Query 参数、文件上传、请求体(表单/JSON 互斥切换),直接发送请求 | +| **预填用例** | 下拉选择测试用例一键填充请求参数,方便快速调试 | +| **全局认证** | 顶部认证栏支持四种模式:无认证、Header (Authorization)、URL (?token=)、Cookie,修改后实时同步到各参数区域 | +| **Cookie 隔离** | Header/URL 认证模式下自动阻止浏览器发送 Cookie(`credentials: 'omit'`),避免意外自动授权 | +| **cURL 生成** | 每次请求自动生成对应的 cURL 命令,支持一键复制 | +| **响应格式化** | JSON 响应自动格式化显示,支持复制 | +| **覆盖率可视化** | 已测试接口显示通过/失败计数徽章,未测试接口标记"未测试" | + +### 多项目支持 + +所有注册项目的路由和测试用例合并到同一份 `api-spec.json`,在侧边栏按项目名分组,不会互相覆盖: + +``` +侧边栏结构: +├── app(项目) +│ ├── user(控制器) +│ │ ├── POST login 用户登录 ✅ 4/4 +│ │ ├── POST info 未测试 +│ │ └── POST create 用户注册 ✅ 4/4 +│ └── goods(控制器) +│ └── ... +└── customer(项目) + └── pay(控制器) + └── ... +``` + +--- + +## 指定运行范围 + +`RunTests` 使用 Go 标准 `t.Run()` 子测试机制,天然支持 `-run` 参数过滤: + +```bash +# 运行全部测试 +go test ./app/... -v + +# 只跑 user 控制器的所有接口 +go test ./app/... -v -run TestApi/app/user + +# 只跑 user/login 接口 +go test ./app/... -v -run TestApi/app/user/login + +# 只跑 login 接口中"密码为空"这一个用例 +go test ./app/... -v -run "TestApi/app/user/login/密码为空" + +# 同时跑 user 和 goods 两个控制器 +go test ./app/... -v -run "TestApi/app/(user|goods)" + +# 跑所有控制器中包含"未登录"的用例 +go test ./app/... -v -run "TestApi/.*/.*未登录" +``` + +子测试层级结构: + +``` +TestApi +└── {项目名} ← -run TestApi/app + └── {控制器名} ← -run TestApi/app/user + └── {方法名} ← -run TestApi/app/user/login + └── {ApiTestDef.Desc}(接口描述) + └── {用例描述} ← -run TestApi/app/user/login/密码为空 +``` + +--- + +## 框架改动说明 + +测试框架对 `hotimev1.5` 共改动 6 个文件,其中 3 个为修改、3 个为新增: + +### 修改的文件 + +| 文件 | 改动 | 生产影响 | +|------|------|----------| +| `db/db.go` | 新增 `testTx *sql.Tx` 字段 + `BeginTestTx()`/`RollbackTestTx()` 方法 | 无(`testTx` 默认 nil) | +| `db/query.go` | `Query` 和 `Exec` 各增加一个 `if testTx != nil` 分支 | 无(分支默认跳过) | +| `db/transaction.go` | `Action()` 增加 SAVEPOINT 分支 | 无(`testTx` 为 nil 时走原有逻辑) | + +### 新增的文件 + +| 文件 | 内容 | +|------|------| +| `testing_helper.go` | `TestApp`、`NewTestApp`、`SetupForTest`、`RunTests`(事务隔离)、`PrintCoverage`(覆盖率) | +| `testing_api.go` | 注册类型 + `Api`/`ApiCase`/`ApiResponse` 链式 API + `TestCollector` | +| `testing_swagger.go` | `GenerateSwagger`:合并所有项目生成 API 调试控制台(`api-spec.json` + `index.html`) | + +> 所有新增文件仅在 `go test` 环境下被使用,生产构建不引入 `testing` 包依赖,完全不影响线上服务。 diff --git a/testing_api.go b/testing_api.go new file mode 100644 index 0000000..886cd14 --- /dev/null +++ b/testing_api.go @@ -0,0 +1,377 @@ +package hotime + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "mime/multipart" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" + + . "code.hoteas.com/golang/hotime/common" + . "code.hoteas.com/golang/hotime/db" +) + +// === 注册类型 === + +// TestProj 测试项目集合(项目名 → 定义) +type TestProj map[string]TestProjDef + +// TestProjDef 单个项目的路由 + 测试定义 +type TestProjDef struct { + Proj Proj + Tests ProjTest +} + +// ProjTest 项目级测试(控制器名 → 控制器测试) +type ProjTest map[string]CtrTest + +// CtrTest 控制器级测试(方法名 → 用例定义) +type CtrTest map[string]ApiTestDef + +// ApiTestDef 单个接口的测试定义 +type ApiTestDef struct { + Desc string + Func func(a *Api) +} + +// === Api:测试起点 === + +// Api 测试 API 入口,由 RunTests 自动创建并注入 +type Api struct { + app *TestApp + path string + t *testing.T + session Map +} + +// WithSession 返回一个携带 session 的新 Api 实例 +func (a *Api) WithSession(s Map) *Api { + return &Api{ + app: a.app, + path: a.path, + t: a.t, + session: s, + } +} + +// JSON 设置 JSON body,返回 ApiCase 构建器 +func (a *Api) JSON(body interface{}) *ApiCase { + c := a.newCase() + c.jsonBody = body + return c +} + +// Query 设置 URL 查询参数,返回 ApiCase 构建器 +func (a *Api) Query(params Map) *ApiCase { + c := a.newCase() + c.query = params + return c +} + +// Form 设置表单 body,返回 ApiCase 构建器 +func (a *Api) Form(body Map) *ApiCase { + c := a.newCase() + c.formBody = body + return c +} + +// File 设置上传文件,返回 ApiCase 构建器 +func (a *Api) File(field, name string, content []byte) *ApiCase { + c := a.newCase() + c.fileField = field + c.fileName = name + c.fileContent = content + return c +} + +// Get 直接发送 GET 请求(无数据场景) +func (a *Api) Get(desc string, status int, msg ...string) *ApiResponse { + return a.newCase().Get(desc, status, msg...) +} + +// Post 直接发送 POST 请求(无数据场景) +func (a *Api) Post(desc string, status int, msg ...string) *ApiResponse { + return a.newCase().Post(desc, status, msg...) +} + +// Put 直接发送 PUT 请求(无数据场景) +func (a *Api) Put(desc string, status int, msg ...string) *ApiResponse { + return a.newCase().Put(desc, status, msg...) +} + +// Delete 直接发送 DELETE 请求(无数据场景) +func (a *Api) Delete(desc string, status int, msg ...string) *ApiResponse { + return a.newCase().Delete(desc, status, msg...) +} + +// DB 获取数据库实例(在测试事务内) +func (a *Api) DB() *HoTimeDB { + return &a.app.Db +} + +func (a *Api) newCase() *ApiCase { + return &ApiCase{ + api: a, + session: a.session, + } +} + +// === ApiCase:链式构建器 === + +// ApiCase 测试用例构建器,支持链式调用叠加请求数据 +type ApiCase struct { + api *Api + session Map + query Map + jsonBody interface{} + formBody Map + fileField string + fileName string + fileContent []byte +} + +// JSON 叠加 JSON body +func (c *ApiCase) JSON(body interface{}) *ApiCase { + c.jsonBody = body + return c +} + +// Query 叠加 URL 查询参数 +func (c *ApiCase) Query(params Map) *ApiCase { + if c.query == nil { + c.query = params + } else { + for k, v := range params { + c.query[k] = v + } + } + return c +} + +// Form 叠加表单 body +func (c *ApiCase) Form(body Map) *ApiCase { + if c.formBody == nil { + c.formBody = body + } else { + for k, v := range body { + c.formBody[k] = v + } + } + return c +} + +// File 设置上传文件 +func (c *ApiCase) File(field, name string, content []byte) *ApiCase { + c.fileField = field + c.fileName = name + c.fileContent = content + return c +} + +// WithSession 覆盖本次请求的 session +func (c *ApiCase) WithSession(s Map) *ApiCase { + c.session = s + return c +} + +// Get 终端操作:GET 请求 + 断言 +func (c *ApiCase) Get(desc string, status int, msg ...string) *ApiResponse { + return c.execute("GET", desc, status, msg...) +} + +// Post 终端操作:POST 请求 + 断言 +func (c *ApiCase) Post(desc string, status int, msg ...string) *ApiResponse { + return c.execute("POST", desc, status, msg...) +} + +// Put 终端操作:PUT 请求 + 断言 +func (c *ApiCase) Put(desc string, status int, msg ...string) *ApiResponse { + return c.execute("PUT", desc, status, msg...) +} + +// Delete 终端操作:DELETE 请求 + 断言 +func (c *ApiCase) Delete(desc string, status int, msg ...string) *ApiResponse { + return c.execute("DELETE", desc, status, msg...) +} + +func (c *ApiCase) execute(method, desc string, expectStatus int, expectMsg ...string) *ApiResponse { + t := c.api.t + var resp *ApiResponse + + t.Run(desc, func(t *testing.T) { + start := time.Now() + + req := c.buildRequest(method) + w := httptest.NewRecorder() + c.api.app.Application.ServeHTTP(w, req) + + duration := time.Since(start) + + body := Map{} + if len(w.Body.Bytes()) > 0 { + _ = json.Unmarshal(w.Body.Bytes(), &body) + } + + resp = &ApiResponse{ + StatusCode: w.Code, + Body: body, + RawBody: w.Body.Bytes(), + t: t, + } + + passed := true + actualStatus := body.GetInt("status") + if actualStatus != expectStatus { + t.Errorf("状态码不匹配: 期望 %d, 实际 %d\n响应: %s", expectStatus, actualStatus, w.Body.String()) + passed = false + } + + if passed && len(expectMsg) > 0 && expectMsg[0] != "" { + actualMsg := resp.GetMsg() + if actualMsg != expectMsg[0] { + t.Errorf("消息不匹配: 期望 %q, 实际 %q\n响应: %s", expectMsg[0], actualMsg, w.Body.String()) + passed = false + } + } + + record := TestRecord{ + Path: c.api.path, + Desc: desc, + CaseName: desc, + Passed: passed, + Duration: duration, + Method: method, + } + if c.query != nil { + record.Query = c.query + } + if c.jsonBody != nil { + record.JsonBody = c.jsonBody + } + if c.formBody != nil { + record.FormBody = c.formBody + } + if c.fileContent != nil { + record.HasFile = true + record.FileField = c.fileField + } + if len(body) > 0 { + record.ResponseBody = body + } + c.api.app.collector.Add(record) + }) + + if resp == nil { + resp = &ApiResponse{t: t} + } + return resp +} + +func (c *ApiCase) buildRequest(method string) *http.Request { + path := c.api.path + + if c.query != nil { + q := url.Values{} + for k, v := range c.query { + q.Set(k, ObjToStr(v)) + } + path += "?" + q.Encode() + } + + var body io.Reader + var contentType string + + switch { + case c.fileContent != nil: + buf := &bytes.Buffer{} + writer := multipart.NewWriter(buf) + part, _ := writer.CreateFormFile(c.fileField, c.fileName) + _, _ = part.Write(c.fileContent) + if c.formBody != nil { + for k, v := range c.formBody { + _ = writer.WriteField(k, ObjToStr(v)) + } + } + _ = writer.Close() + body = buf + contentType = writer.FormDataContentType() + + case c.jsonBody != nil: + jsonBytes, _ := json.Marshal(c.jsonBody) + body = bytes.NewReader(jsonBytes) + contentType = "application/json" + + case c.formBody != nil: + form := url.Values{} + for k, v := range c.formBody { + form.Set(k, ObjToStr(v)) + } + body = strings.NewReader(form.Encode()) + contentType = "application/x-www-form-urlencoded" + } + + req := httptest.NewRequest(method, path, body) + if contentType != "" { + req.Header.Set("Content-Type", contentType) + } + + if c.session != nil { + sessionId := Md5("test_session_" + ObjToStr(time.Now().UnixNano())) + c.api.app.HoTimeCache.Session(HEAD_SESSION_ADD+sessionId, c.session) + req.Header.Set("Authorization", sessionId) + } + + return req +} + +// === ApiResponse === + +// ApiResponse 测试响应对象 +type ApiResponse struct { + StatusCode int + Body Map + RawBody []byte + t *testing.T +} + +// GetStatus 获取业务状态码(JSON body 中的 status 字段) +func (r *ApiResponse) GetStatus() int { + return r.Body.GetInt("status") +} + +// GetResult 获取响应结果(JSON body 中的 result 字段) +func (r *ApiResponse) GetResult() interface{} { + return r.Body.Get("result") +} + +// GetMsg 获取响应消息 +// Display 错误格式为 {"status":N, "result":{"msg":"xxx"}},成功时无 msg +func (r *ApiResponse) GetMsg() string { + if msg := r.Body.GetString("msg"); msg != "" { + return msg + } + if result := r.Body.GetMap("result"); result != nil { + return result.GetString("msg") + } + return "" +} + +// GetBody 获取完整的响应 body +func (r *ApiResponse) GetBody() Map { + return r.Body +} + +// Fail 手动标记测试失败(用于自定义断言) +func (r *ApiResponse) Fail(msg string) { + if r.t != nil { + r.t.Errorf("自定义断言失败: %s\n响应: %s", msg, string(r.RawBody)) + } else { + fmt.Printf("自定义断言失败: %s\n", msg) + } +} diff --git a/testing_helper.go b/testing_helper.go new file mode 100644 index 0000000..529c3e3 --- /dev/null +++ b/testing_helper.go @@ -0,0 +1,274 @@ +package hotime + +import ( + "fmt" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + . "code.hoteas.com/golang/hotime/db" +) + +// TestApp 测试应用,封装 Application 提供测试能力 +type TestApp struct { + *Application + projs TestProj + collector *TestCollector +} + +// TestResponse httptest 的原始响应封装 +type TestResponse struct { + StatusCode int + Body []byte + Header http.Header +} + +// CoverageReport 覆盖率报告 +type CoverageReport struct { + Total int + Covered int + Missing []string + Details []MethodCoverage +} + +// MethodCoverage 单个接口的覆盖详情 +type MethodCoverage struct { + Path string + HasTest bool + CaseCount int + Passed int + Failed int + Duration time.Duration +} + +// TestRecord 单条测试记录 +type TestRecord struct { + Path string + Desc string + CaseName string + Passed bool + Duration time.Duration + Method string + Query map[string]interface{} + JsonBody interface{} + FormBody map[string]interface{} + HasFile bool + FileField string + ResponseBody map[string]interface{} +} + +// TestCollector 线程安全的测试记录收集器 +type TestCollector struct { + mu sync.Mutex + Records []TestRecord +} + +func (c *TestCollector) Add(r TestRecord) { + c.mu.Lock() + defer c.mu.Unlock() + c.Records = append(c.Records, r) +} + +// NewTestApp 创建测试应用实例 +// configPath: 配置文件路径(如 "../config/config.json") +// projects: 项目定义(路由 + 测试) +// listeners: 可选的请求拦截器(与 SetConnectListener 相同) +func NewTestApp(configPath string, projects TestProj, listeners ...func(*Context) bool) *TestApp { + app := Init(configPath) + + for _, lis := range listeners { + app.SetConnectListener(lis) + } + + router := Router{} + for projName, projDef := range projects { + router[projName] = projDef.Proj + } + app.SetupForTest(router) + + return &TestApp{ + Application: app, + projs: projects, + collector: &TestCollector{}, + } +} + +// SetupForTest 初始化路由(复用 Run 的路由注册逻辑,但不启动 HTTP 服务) +func (that *Application) SetupForTest(router Router) { + if that.Router == nil { + that.Router = Router{} + } + for k := range router { + v := router[k] + if that.Router[k] == nil { + that.Router[k] = v + } + for k1 := range v { + v1 := v[k1] + if that.Router[k][k1] == nil { + that.Router[k][k1] = v1 + } + for k2 := range v1 { + v2 := v1[k2] + that.Router[k][k1][k2] = v2 + } + } + } + + that.MethodRouter = MethodRouter{} + modeRouterStrict := true + if that.Config.GetBool("modeRouterStrict") == false { + modeRouterStrict = false + } + for pk, pv := range that.Router { + if !modeRouterStrict { + pk = strings.ToLower(pk) + } + for ck, cv := range pv { + if !modeRouterStrict { + ck = strings.ToLower(ck) + } + for mk, mv := range cv { + if !modeRouterStrict { + mk = strings.ToLower(mk) + } + that.MethodRouter["/"+pk+"/"+ck+"/"+mk] = mv + } + } + } +} + +// TestRequest 发送测试 HTTP 请求 +func (that *TestApp) TestRequest(method, path string, body *http.Request) TestResponse { + w := httptest.NewRecorder() + that.Application.ServeHTTP(w, body) + return TestResponse{ + StatusCode: w.Code, + Body: w.Body.Bytes(), + Header: w.Header(), + } +} + +// RunTests 运行所有注册的测试,每个方法级别使用事务隔离 +func (that *TestApp) RunTests(t *testing.T) { + for projName, projDef := range that.projs { + projName := projName + projDef := projDef + t.Run(projName, func(t *testing.T) { + for ctrName, ctrTest := range projDef.Tests { + ctrName := ctrName + ctrTest := ctrTest + t.Run(ctrName, func(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() + + path := "/" + projName + "/" + ctrName + "/" + methodName + api := &Api{ + app: that, + path: path, + t: t, + } + t.Run(apiTest.Desc, func(t *testing.T) { + api.t = t + apiTest.Func(api) + }) + }) + } + }) + } + }) + } +} + +// PrintCoverage 输出 API 测试覆盖率报告 +func (that *TestApp) PrintCoverage() CoverageReport { + report := CoverageReport{} + + pathRecords := map[string][]TestRecord{} + that.collector.mu.Lock() + for _, r := range that.collector.Records { + pathRecords[r.Path] = append(pathRecords[r.Path], r) + } + that.collector.mu.Unlock() + + for projName, projDef := range that.projs { + for ctrName, ctr := range projDef.Proj { + for methodName := range ctr { + path := "/" + projName + "/" + ctrName + "/" + methodName + report.Total++ + + ctrTest, hasCtr := projDef.Tests[ctrName] + if !hasCtr { + report.Missing = append(report.Missing, projName+"/"+ctrName+"/"+methodName) + report.Details = append(report.Details, MethodCoverage{Path: path, HasTest: false}) + continue + } + _, hasMethod := ctrTest[methodName] + if !hasMethod { + report.Missing = append(report.Missing, projName+"/"+ctrName+"/"+methodName) + report.Details = append(report.Details, MethodCoverage{Path: path, HasTest: false}) + continue + } + + report.Covered++ + mc := MethodCoverage{Path: path, HasTest: true} + if records, ok := pathRecords[path]; ok { + mc.CaseCount = len(records) + for _, r := range records { + mc.Duration += r.Duration + if r.Passed { + mc.Passed++ + } else { + mc.Failed++ + } + } + } + report.Details = append(report.Details, mc) + } + } + } + + fmt.Println("\n========== API 测试覆盖率报告 ==========") + if report.Total > 0 { + fmt.Printf("总接口: %d | 已覆盖: %d | 覆盖率: %.1f%%\n", + report.Total, report.Covered, + float64(report.Covered)/float64(report.Total)*100) + } else { + fmt.Println("总接口: 0") + } + + fmt.Println("\n已覆盖的接口:") + for _, d := range report.Details { + if d.HasTest && d.CaseCount > 0 { + fmt.Printf(" + %s\t%d 用例 (%d 通过, %d 失败) %v\n", + d.Path, d.CaseCount, d.Passed, d.Failed, d.Duration) + } else if d.HasTest { + fmt.Printf(" + %s\t(已定义, 未运行)\n", d.Path) + } + } + + if len(report.Missing) > 0 { + fmt.Println("\n未覆盖的接口:") + for _, path := range report.Missing { + fmt.Println(" -", path) + } + } + fmt.Println("========================================") + + return report +} + +// DB 获取测试应用的数据库实例(带 testTx) +func (that *TestApp) DB() *HoTimeDB { + return &that.Db +} diff --git a/testing_swagger.go b/testing_swagger.go new file mode 100644 index 0000000..f97f1e7 --- /dev/null +++ b/testing_swagger.go @@ -0,0 +1,508 @@ +package hotime + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + + . "code.hoteas.com/golang/hotime/common" +) + +type swaggerTestCase struct { + Name string `json:"name"` + Method string `json:"method"` + Passed bool `json:"passed"` + Query map[string]interface{} `json:"query,omitempty"` + Json interface{} `json:"json,omitempty"` + Form map[string]interface{} `json:"form,omitempty"` + HasFile bool `json:"hasFile,omitempty"` + FileField string `json:"fileField,omitempty"` + Response map[string]interface{} `json:"response,omitempty"` +} + +func (that *TestApp) GenerateSwagger(title, version, outputDir string) error { + if outputDir == "" { + outputDir = "tpt" + } + swaggerDir := filepath.Join(outputDir, "swagger") + if err := os.MkdirAll(swaggerDir, os.ModePerm); err != nil { + return fmt.Errorf("创建 swagger 目录失败: %w", err) + } + + that.collector.mu.Lock() + records := make([]TestRecord, len(that.collector.Records)) + copy(records, that.collector.Records) + that.collector.mu.Unlock() + + pathRecords := map[string][]TestRecord{} + for _, r := range records { + pathRecords[r.Path] = append(pathRecords[r.Path], r) + } + + endpoints := []map[string]interface{}{} + + for projName, projDef := range that.projs { + for ctrName, ctr := range projDef.Proj { + for methodName := range ctr { + apiPath := "/" + projName + "/" + ctrName + "/" + methodName + ctrTest, hasCtr := projDef.Tests[ctrName] + hasTest := false + var apiTest ApiTestDef + if hasCtr { + apiTest, hasTest = ctrTest[methodName] + } + + summary := methodName + if hasTest { + summary = apiTest.Desc + } + + recs := pathRecords[apiPath] + httpMethod := "POST" + if len(recs) > 0 && recs[0].Method != "" { + httpMethod = strings.ToUpper(recs[0].Method) + } + + var cases []swaggerTestCase + for _, r := range recs { + tc := swaggerTestCase{ + Name: r.CaseName, Method: r.Method, Passed: r.Passed, + Response: r.ResponseBody, + } + if r.Query != nil { + tc.Query = r.Query + } + if r.JsonBody != nil { + tc.Json = r.JsonBody + } + if r.FormBody != nil { + tc.Form = r.FormBody + } + if r.HasFile { + tc.HasFile = true + tc.FileField = r.FileField + } + cases = append(cases, tc) + } + + endpoints = append(endpoints, map[string]interface{}{ + "path": apiPath, + "project": projName, + "ctr": ctrName, + "method": httpMethod, + "summary": summary, + "tested": hasTest, + "cases": cases, + }) + } + } + } + + sort.Slice(endpoints, func(i, j int) bool { + return endpoints[i]["path"].(string) < endpoints[j]["path"].(string) + }) + + spec := map[string]interface{}{ + "title": title, + "version": version, + "endpoints": endpoints, + } + + specJSON, err := json.MarshalIndent(spec, "", " ") + if err != nil { + return fmt.Errorf("序列化 JSON 失败: %w", err) + } + if err := os.WriteFile(filepath.Join(swaggerDir, "api-spec.json"), specJSON, os.ModePerm); err != nil { + return fmt.Errorf("写入 api-spec.json 失败: %w", err) + } + if err := os.WriteFile(filepath.Join(swaggerDir, "index.html"), []byte(apiConsoleHTML()), os.ModePerm); err != nil { + return fmt.Errorf("写入 index.html 失败: %w", err) + } + + fmt.Printf("Swagger 文档已生成: %s\n", swaggerDir) + return nil +} + +func apiConsoleHTML() string { + return ` +API 调试控制台 + + +
+
+

+ +
+ + + +
+
+
+
+ +
+
+ + + +
+
← 选择一个接口开始
+
+ +` +} + +func inferSchema(v interface{}) map[string]interface{} { + if v == nil { + return map[string]interface{}{"type": "string"} + } + switch v.(type) { + case int, int64, int32, float64, float32: + return map[string]interface{}{"type": "number"} + case bool: + return map[string]interface{}{"type": "boolean"} + case map[string]interface{}, Map: + props := map[string]interface{}{} + var m map[string]interface{} + switch val := v.(type) { + case Map: + m = map[string]interface{}(val) + case map[string]interface{}: + m = val + } + for k, val := range m { + props[k] = inferSchema(val) + } + return map[string]interface{}{"type": "object", "properties": props} + default: + return map[string]interface{}{"type": "string"} + } +}