2026-07-13 07:45:51 +08:00
|
|
|
|
---
|
2026-03-14 10:19:57 +08:00
|
|
|
|
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()
|
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)
|
|
|
|
|
|
}
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
## 五、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`
|
2026-07-13 07:45:51 +08:00
|
|
|
|
- **修改** `d:/work/your-app/app/user.go` -- 末尾添加 UserTest
|
|
|
|
|
|
- **修改** `d:/work/your-app/app/init.go` -- 添加 ProjectTest
|
|
|
|
|
|
- **新增** `d:/work/your-app/app/app_test.go`
|