--- 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("HoTime 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/your-app/app/user.go` -- 末尾添加 UserTest - **修改** `d:/work/your-app/app/init.go` -- 添加 ProjectTest - **新增** `d:/work/your-app/app/app_test.go`