diff --git a/.cursor/plans/verify统一断言改造_2510a4b9.plan.md b/.cursor/plans/verify统一断言改造_2510a4b9.plan.md new file mode 100644 index 0000000..123de38 --- /dev/null +++ b/.cursor/plans/verify统一断言改造_2510a4b9.plan.md @@ -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()` 的说明但标注为兼容用法 + diff --git a/.cursor/plans/整理推荐写法风格_08f3ffd1.plan.md b/.cursor/plans/整理推荐写法风格_08f3ffd1.plan.md new file mode 100644 index 0000000..c0e055a --- /dev/null +++ b/.cursor/plans/整理推荐写法风格_08f3ffd1.plan.md @@ -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 校验的简单场景):` \ No newline at end of file diff --git a/.cursor/plans/精简测试框架文档_af7f35a5.plan.md b/.cursor/plans/精简测试框架文档_af7f35a5.plan.md new file mode 100644 index 0000000..f2b95ef --- /dev/null +++ b/.cursor/plans/精简测试框架文档_af7f35a5.plan.md @@ -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 示例) +- 并发保护与缓存隔离(精简版) +- 二进制响应校验(精简版) + diff --git a/.cursor/plans/重写api测试框架文档_48c87f92.plan.md b/.cursor/plans/重写api测试框架文档_48c87f92.plan.md new file mode 100644 index 0000000..4a06598 --- /dev/null +++ b/.cursor/plans/重写api测试框架文档_48c87f92.plan.md @@ -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 源码 +- 不涉及其他文档 + diff --git a/docs/Testing_API测试框架.md b/docs/Testing_API测试框架.md index 677e1aa..c510080 100644 --- a/docs/Testing_API测试框架.md +++ b/docs/Testing_API测试框架.md @@ -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,生产代码路径不受任何影响。 diff --git a/example/app/cache_test.go b/example/app/cache_test.go index 33d02b4..56cf07f 100644 --- a/example/app/cache_test.go +++ b/example/app/cache_test.go @@ -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", + }) + }}, } diff --git a/example/app/dmdb_test.go b/example/app/dmdb_test.go index f090722..fa1fca9 100644 --- a/example/app/dmdb_test.go +++ b/example/app/dmdb_test.go @@ -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}}, + }) + }}, } diff --git a/example/app/expect_demo_test.go b/example/app/expect_demo_test.go index ce44fc5..801dae8 100644 --- a/example/app/expect_demo_test.go +++ b/example/app/expect_demo_test.go @@ -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 }). diff --git a/example/app/mysql_test.go b/example/app/mysql_test.go index fd0f9ee..2eb5523 100644 --- a/example/app/mysql_test.go +++ b/example/app/mysql_test.go @@ -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}}, + }) + }}, } diff --git a/example/app/test_test.go b/example/app/test_test.go index 263a5a8..0da0d42 100644 --- a/example/app/test_test.go +++ b/example/app/test_test.go @@ -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 } diff --git a/example/tpt/swagger/app/api-spec.json b/example/tpt/swagger/app/api-spec.json index 0c7cb30..f28b111 100644 --- a/example/tpt/swagger/app/api-spec.json +++ b/example/tpt/swagger/app/api-spec.json @@ -3,8 +3,23 @@ { "cases": [ { - "name": "缓存全部测试", + "expect": { + "result": { + "cache_mode": "sample", + "cleanup": "sample", + "name": "sample", + "success": true, + "tests": [ + { + "name": "sample", + "result": true + } + ] + }, + "status": 0 + }, "method": "GET", + "name": "缓存全部测试", "passed": true, "response": { "result": { @@ -97,9 +112,6 @@ ] }, "status": 0 - }, - "expect": { - "status": 0 } } ], @@ -114,17 +126,20 @@ { "cases": [ { - "name": "批量缓存测试", + "expect": { + "result": { + "message": "sample" + }, + "status": 0 + }, "method": "GET", + "name": "批量缓存测试", "passed": true, "response": { "result": { "message": "批量缓存测试完成,请查看控制台输出和日志文件" }, "status": 0 - }, - "expect": { - "status": 0 } } ], @@ -139,8 +154,22 @@ { "cases": [ { - "name": "兼容模式测试", + "expect": { + "result": { + "success": true, + "test_name": "sample", + "tests": [ + { + "name": "sample", + "result": true + } + ], + "timestamp": "sample" + }, + "status": 0 + }, "method": "GET", + "name": "兼容模式测试", "passed": true, "response": { "result": { @@ -171,7 +200,7 @@ "key_in_new_table": false, "name": "4. 测试兼容模式老表回退读取", "result": false, - "test_key": "test_compat_fallback_1774071258597810000" + "test_key": "test_compat_fallback_1774804199747243800" }, { "name": "5. 测试兼容模式写新删老", @@ -189,12 +218,9 @@ "result": false } ], - "timestamp": "2026-03-21 13:34:18" + "timestamp": "2026-03-30 01:09:59" }, "status": 0 - }, - "expect": { - "status": 0 } } ], @@ -209,8 +235,48 @@ { "cases": [ { - "name": "USER_TAB_COLUMNS 查询", + "expect": { + "error": { + "msg": "请提供 table 参数", + "type": "内部系统异常" + }, + "result": { + "msg": "请提供 table 参数", + "type": "内部系统异常" + }, + "status": 1 + }, "method": "GET", + "name": "不传table参数", + "passed": true, + "response": { + "error": { + "msg": "请提供 table 参数", + "type": "内部系统异常" + }, + "result": { + "msg": "请提供 table 参数", + "type": "内部系统异常" + }, + "status": 1 + } + }, + { + "expect": { + "result": { + "columns": [ + { + "name": "sample", + "type": "sample" + } + ], + "sample_data": [], + "table": "sample" + }, + "status": 0 + }, + "method": "GET", + "name": "USER_TAB_COLUMNS 查询", "passed": true, "query": { "table": "admin" @@ -218,12 +284,48 @@ "response": { "result": { "columns": [ + { + "label": "ID", + "must": "N", + "name": "id", + "type": "INT" + }, + { + "label": "ID", + "must": "N", + "name": "id", + "type": "INT" + }, { "label": null, "must": "N", "name": "id", "type": "INT" }, + { + "label": null, + "must": "N", + "name": "id", + "type": "INT" + }, + { + "label": "用户名", + "must": "Y", + "name": "name", + "type": "VARCHAR" + }, + { + "label": null, + "must": "Y", + "name": "name", + "type": "VARCHAR" + }, + { + "label": "用户名", + "must": "Y", + "name": "name", + "type": "VARCHAR" + }, { "label": null, "must": "Y", @@ -236,18 +338,78 @@ "name": "phone", "type": "VARCHAR" }, + { + "label": "密码", + "must": "Y", + "name": "password", + "type": "VARCHAR" + }, { "label": null, "must": "Y", + "name": "password", + "type": "VARCHAR" + }, + { + "label": null, + "must": "Y", + "name": "create_time", + "type": "TIMESTAMP" + }, + { + "label": null, + "must": "Y", + "name": "state", + "type": "INT" + }, + { + "label": "创建时间", + "must": "Y", + "name": "create_time", + "type": "TIMESTAMP" + }, + { + "label": "状态:0-正常,1-异常", + "must": "Y", "name": "state", "type": "INT" }, { "label": null, "must": "Y", + "name": "modify_time", + "type": "TIMESTAMP" + }, + { + "label": "变更时间", + "must": "Y", + "name": "modify_time", + "type": "TIMESTAMP" + }, + { + "label": "密码", + "must": "Y", "name": "password", "type": "VARCHAR" }, + { + "label": null, + "must": "Y", + "name": "password", + "type": "VARCHAR" + }, + { + "label": null, + "must": "Y", + "name": "state", + "type": "INT" + }, + { + "label": "状态:0-正常,1-异常", + "must": "Y", + "name": "state", + "type": "INT" + }, { "label": null, "must": "Y", @@ -266,11 +428,23 @@ "name": "create_time", "type": "TIMESTAMP" }, + { + "label": "创建时间", + "must": "Y", + "name": "create_time", + "type": "TIMESTAMP" + }, { "label": null, "must": "Y", "name": "modify_time", "type": "TIMESTAMP" + }, + { + "label": "变更时间", + "must": "Y", + "name": "modify_time", + "type": "TIMESTAMP" } ], "sample_data": [ @@ -311,9 +485,6 @@ "table": "admin" }, "status": 0 - }, - "expect": { - "status": 0 } } ], @@ -321,10 +492,10 @@ "method": "GET", "params": [ { + "in": "query", "name": "table", "required": false, - "type": "string", - "in": "query" + "type": "string" } ], "path": "/app/dmdb/describe", @@ -335,8 +506,21 @@ { "cases": [ { - "name": "达梦原生SQL", + "expect": { + "result": { + "name": "sample", + "success": true, + "tests": [ + { + "name": "sample", + "result": true + } + ] + }, + "status": 0 + }, "method": "GET", + "name": "达梦原生SQL", "passed": true, "response": { "result": { @@ -354,59 +538,80 @@ "result": true }, { - "count": 46, + "count": 47, "name": "USER_TABLES 查询", "result": true }, { "columns": [ { - "column_name": "id", - "data_type": "INT" + "COLUMN_NAME": "id", + "DATA_TYPE": "INT" }, { - "column_name": "name", - "data_type": "VARCHAR" + "COLUMN_NAME": "id", + "DATA_TYPE": "INT" }, { - "column_name": "phone", - "data_type": "VARCHAR" + "COLUMN_NAME": "name", + "DATA_TYPE": "VARCHAR" }, { - "column_name": "state", - "data_type": "INT" + "COLUMN_NAME": "name", + "DATA_TYPE": "VARCHAR" }, { - "column_name": "password", - "data_type": "VARCHAR" + "COLUMN_NAME": "password", + "DATA_TYPE": "VARCHAR" }, { - "column_name": "role_id", - "data_type": "INT" + "COLUMN_NAME": "phone", + "DATA_TYPE": "VARCHAR" }, { - "column_name": "title", - "data_type": "VARCHAR" + "COLUMN_NAME": "create_time", + "DATA_TYPE": "TIMESTAMP" }, { - "column_name": "create_time", - "data_type": "TIMESTAMP" + "COLUMN_NAME": "state", + "DATA_TYPE": "INT" }, { - "column_name": "modify_time", - "data_type": "TIMESTAMP" + "COLUMN_NAME": "modify_time", + "DATA_TYPE": "TIMESTAMP" + }, + { + "COLUMN_NAME": "password", + "DATA_TYPE": "VARCHAR" + }, + { + "COLUMN_NAME": "state", + "DATA_TYPE": "INT" + }, + { + "COLUMN_NAME": "role_id", + "DATA_TYPE": "INT" + }, + { + "COLUMN_NAME": "title", + "DATA_TYPE": "VARCHAR" + }, + { + "COLUMN_NAME": "create_time", + "DATA_TYPE": "TIMESTAMP" + }, + { + "COLUMN_NAME": "modify_time", + "DATA_TYPE": "TIMESTAMP" } ], - "count": 9, + "count": 15, "name": "USER_TAB_COLUMNS 查询", "result": true } ] }, "status": 0 - }, - "expect": { - "status": 0 } } ], @@ -421,8 +626,18 @@ { "cases": [ { - "name": "USER_TABLES 查询", + "expect": { + "result": { + "tables": [ + { + "name": "sample" + } + ] + }, + "status": 0 + }, "method": "GET", + "name": "USER_TABLES 查询", "passed": true, "response": { "result": { @@ -499,6 +714,10 @@ "label": null, "name": "test_batch" }, + { + "label": "管理员", + "name": "admin" + }, { "label": "应用表", "name": "app" @@ -614,9 +833,6 @@ ] }, "status": 0 - }, - "expect": { - "status": 0 } } ], @@ -631,14 +847,14 @@ { "cases": [ { - "name": "result是布尔", - "method": "GET", - "passed": true, - "response": { + "expect": { "result": true, "status": 0 }, - "expect": { + "method": "GET", + "name": "result是布尔", + "passed": true, + "response": { "result": true, "status": 0 } @@ -649,19 +865,13 @@ "params": [], "path": "/app/expect_demo/bool_result", "project": "app", - "summary": "返回布尔", + "summary": "返回布尔-结构+值校验", "tested": true }, { "cases": [ { - "name": "名称为空-错误断言", - "method": "POST", - "passed": true, - "form": { - "name": "" - }, - "response": { + "expect": { "error": { "msg": "名称不能为空", "type": "请求参数异常" @@ -672,7 +882,13 @@ }, "status": 3 }, - "expect": { + "form": { + "name": "" + }, + "method": "POST", + "name": "名称为空-缺少必填字段", + "passed": true, + "response": { "error": { "msg": "名称不能为空", "type": "请求参数异常" @@ -685,20 +901,6 @@ } }, { - "name": "名称正确-结构校验", - "method": "POST", - "passed": true, - "form": { - "name": "测试" - }, - "response": { - "result": { - "created": true, - "id": 1, - "name": "测试" - }, - "status": 0 - }, "expect": { "result": { "created": true, @@ -706,6 +908,20 @@ "name": "sample" }, "status": 0 + }, + "form": { + "name": "测试商品" + }, + "method": "POST", + "name": "名称正确-结构+值校验", + "passed": true, + "response": { + "result": { + "created": true, + "id": 1, + "name": "测试商品" + }, + "status": 0 } } ], @@ -713,30 +929,30 @@ "method": "POST", "params": [ { + "in": "form", "name": "name", "required": false, - "type": "string", - "in": "form" + "type": "string" } ], "path": "/app/expect_demo/error_demo", "project": "app", - "summary": "错误+成功混合", + "summary": "错误先行+正确请求完整校验", "tested": true }, { "cases": [ { - "name": "result是小数", + "expect": { + "result": 1, + "status": 0 + }, "method": "GET", + "name": "result是小数", "passed": true, "response": { "result": 3.14, "status": 0 - }, - "expect": { - "result": 1, - "status": 0 } } ], @@ -745,14 +961,23 @@ "params": [], "path": "/app/expect_demo/float_result", "project": "app", - "summary": "返回小数", + "summary": "返回小数-结构+值校验", "tested": true }, { "cases": [ { - "name": "result是Map-校验字段名和类型", + "expect": { + "result": { + "id": 1, + "in_stock": true, + "name": "sample", + "price": 1 + }, + "status": 0 + }, "method": "GET", + "name": "result是Map-校验字段名+类型+值", "passed": true, "response": { "result": { @@ -762,15 +987,6 @@ "price": 19.9 }, "status": 0 - }, - "expect": { - "result": { - "id": 1, - "in_stock": true, - "name": "sample", - "price": 1 - }, - "status": 0 } } ], @@ -779,14 +995,40 @@ "params": [], "path": "/app/expect_demo/map_result", "project": "app", - "summary": "返回对象", + "summary": "返回对象-结构+值校验", "tested": true }, { "cases": [ { - "name": "深层嵌套Map+Slice+Map", + "expect": { + "result": { + "customer": { + "id": 1, + "name": "sample", + "phone": "sample" + }, + "extra": { + "delivery": { + "address": "sample", + "fee": 1 + } + }, + "id": 1, + "items": [ + { + "goods_name": "sample", + "id": 1, + "price": 1, + "quantity": 1 + } + ], + "title": "sample" + }, + "status": 0 + }, "method": "GET", + "name": "深层嵌套Map+Slice+Map", "passed": true, "response": { "result": { @@ -819,32 +1061,6 @@ "title": "测试订单" }, "status": 0 - }, - "expect": { - "result": { - "customer": { - "id": 1, - "name": "sample", - "phone": "sample" - }, - "extra": { - "delivery": { - "address": "sample", - "fee": 1 - } - }, - "id": 1, - "items": [ - { - "goods_name": "sample", - "id": 1, - "price": 1, - "quantity": 1 - } - ], - "title": "sample" - }, - "status": 0 } } ], @@ -853,14 +1069,21 @@ "params": [], "path": "/app/expect_demo/nested_result", "project": "app", - "summary": "返回嵌套对象", + "summary": "返回嵌套对象-结构+值校验", "tested": true }, { "cases": [ { - "name": "只校验status=0", + "expect": { + "result": { + "data": "sample", + "id": 1 + }, + "status": 0 + }, "method": "GET", + "name": "有Verify值断言", "passed": true, "response": { "result": { @@ -868,9 +1091,6 @@ "id": 1 }, "status": 0 - }, - "expect": { - "status": 0 } } ], @@ -879,22 +1099,22 @@ "params": [], "path": "/app/expect_demo/no_expect", "project": "app", - "summary": "不传预期(仅校验status)", + "summary": "最小正确用例-仍需值断言", "tested": true }, { "cases": [ { - "name": "result是整数", + "expect": { + "result": 1, + "status": 0 + }, "method": "GET", + "name": "result是整数", "passed": true, "response": { "result": 42, "status": 0 - }, - "expect": { - "result": 1, - "status": 0 } } ], @@ -903,14 +1123,23 @@ "params": [], "path": "/app/expect_demo/number_result", "project": "app", - "summary": "返回整数", + "summary": "返回整数-结构+值校验", "tested": true }, { "cases": [ { - "name": "result直接是Slice", + "expect": { + "result": [ + { + "id": 1, + "name": "sample" + } + ], + "status": 0 + }, "method": "GET", + "name": "result直接是Slice", "passed": true, "response": { "result": [ @@ -924,15 +1153,6 @@ } ], "status": 0 - }, - "expect": { - "result": [ - { - "id": 1, - "name": "sample" - } - ], - "status": 0 } } ], @@ -941,20 +1161,20 @@ "params": [], "path": "/app/expect_demo/slice_result", "project": "app", - "summary": "返回数组", + "summary": "返回数组-结构+值校验", "tested": true }, { "cases": [ { - "name": "result是字符串", - "method": "GET", - "passed": true, - "response": { - "result": "操作成功", + "expect": { + "result": "样本", "status": 0 }, - "expect": { + "method": "GET", + "name": "result是字符串", + "passed": true, + "response": { "result": "操作成功", "status": 0 } @@ -965,19 +1185,13 @@ "params": [], "path": "/app/expect_demo/string_result", "project": "app", - "summary": "返回字符串", + "summary": "返回字符串-结构+值校验", "tested": true }, { "cases": [ { - "name": "名称为空", - "method": "POST", - "passed": true, - "form": { - "name": "" - }, - "response": { + "expect": { "error": { "msg": "名称不能为空", "type": "请求参数异常" @@ -988,7 +1202,13 @@ }, "status": 3 }, - "expect": { + "form": { + "name": "" + }, + "method": "POST", + "name": "名称为空", + "passed": true, + "response": { "error": { "msg": "名称不能为空", "type": "请求参数异常" @@ -1001,68 +1221,95 @@ } }, { - "name": "写入并校验DB-正确", - "method": "POST", - "passed": true, - "form": { - "name": "verify_test" - }, - "response": { - "result": { - "id": 173, - "name": "verify_test" - }, - "status": 0 - }, "expect": { "result": { "id": 1, "name": "sample" }, "status": 0 + }, + "form": { + "name": "verify_test" + }, + "method": "POST", + "name": "写入并校验DB+响应", + "passed": true, + "response": { + "result": { + "id": 196, + "name": "verify_test" + }, + "status": 0 } }, { - "name": "写入并校验DB-故意失败", - "method": "POST", - "passed": false, + "expect": { + "result": { + "id": 1, + "name": "sample" + }, + "status": 0 + }, "form": { "name": "verify_fail" }, + "method": "POST", + "name": "写入并校验DB-故意失败", + "passed": false, "response": { "result": { - "id": 174, + "id": 197, "name": "verify_fail" }, "status": 0 }, - "expect": { - "result": { - "id": 1, - "name": "sample" - }, - "status": 0 - }, - "verifyError": "test_batch.state 期望 99, 实际 0(接口写入的是0,这里故意校验99来演示失败效果)" + "verifyError": "test_batch.state 期望 99, 实际 0(接口写入 0,故意校验 99 演示失败效果)" } ], "ctr": "expect_demo", "method": "POST", "params": [ { + "in": "form", "name": "name", "required": false, - "type": "string", - "in": "form" + "type": "string" } ], "path": "/app/expect_demo/verify_demo", "project": "app", - "summary": "Verify数据库校验", + "summary": "Verify完整范式演示", "tested": true }, { "cases": [ + { + "name": "不传table参数", + "method": "GET", + "passed": true, + "response": { + "error": { + "msg": "请提供 table 参数", + "type": "内部系统异常" + }, + "result": { + "msg": "请提供 table 参数", + "type": "内部系统异常" + }, + "status": 1 + }, + "expect": { + "error": { + "msg": "请提供 table 参数", + "type": "内部系统异常" + }, + "result": { + "msg": "请提供 table 参数", + "type": "内部系统异常" + }, + "status": 1 + } + }, { "name": "DESCRIBE admin", "method": "GET", @@ -1072,12 +1319,202 @@ }, "response": { "result": { - "reason": "当前非 MySQL 数据库", - "skip": true + "columns": [ + { + "Default": null, + "Extra": "auto_increment", + "Field": "id", + "Key": "PRI", + "Null": "NO", + "Type": "int(11) unsigned" + }, + { + "Default": null, + "Extra": "", + "Field": "name", + "Key": "", + "Null": "YES", + "Type": "varchar(100)" + }, + { + "Default": null, + "Extra": "", + "Field": "phone", + "Key": "UNI", + "Null": "YES", + "Type": "varchar(20)" + }, + { + "Default": "0", + "Extra": "", + "Field": "state", + "Key": "", + "Null": "YES", + "Type": "int(2)" + }, + { + "Default": null, + "Extra": "", + "Field": "password", + "Key": "", + "Null": "YES", + "Type": "varchar(100)" + }, + { + "Default": "0", + "Extra": "", + "Field": "role_id", + "Key": "", + "Null": "YES", + "Type": "int(11)" + }, + { + "Default": null, + "Extra": "", + "Field": "title", + "Key": "", + "Null": "YES", + "Type": "varchar(100)" + }, + { + "Default": null, + "Extra": "", + "Field": "create_time", + "Key": "", + "Null": "YES", + "Type": "datetime" + }, + { + "Default": null, + "Extra": "", + "Field": "modify_time", + "Key": "", + "Null": "YES", + "Type": "datetime" + } + ], + "sample_data": [ + { + "create_time": "2026-03-20 02:32:47", + "id": 1, + "modify_time": "2026-03-20 02:32:47", + "name": "超级管理员", + "password": "admin123", + "phone": "13800000001", + "role_id": 1, + "state": 1, + "title": "系统管理员" + }, + { + "create_time": "2026-03-20 02:32:47", + "id": 2, + "modify_time": "2026-03-20 02:32:47", + "name": "编辑员", + "password": "editor123", + "phone": "13800000002", + "role_id": 2, + "state": 1, + "title": "内容编辑" + }, + { + "create_time": "2026-03-20 02:32:47", + "id": 3, + "modify_time": "2026-03-20 02:32:47", + "name": "审核员", + "password": "review123", + "phone": "13800000003", + "role_id": 3, + "state": 1, + "title": "内容审核" + }, + { + "create_time": "2026-03-20 02:43:03", + "id": 6, + "modify_time": "2026-03-20 02:43:03", + "name": "测试管理员_1773974584", + "password": "test123456", + "phone": "13873974584", + "role_id": 1, + "state": 1, + "title": "更新后的职位" + }, + { + "create_time": "2026-03-20 02:43:09", + "id": 9, + "modify_time": "2026-03-20 02:43:09", + "name": "测试管理员_1773974590", + "password": "test123456", + "phone": "13873974590", + "role_id": 1, + "state": 1, + "title": "更新后的职位" + }, + { + "create_time": "2026-03-20 02:50:47", + "id": 12, + "modify_time": "2026-03-20 02:50:47", + "name": "测试管理员_1773975048", + "password": "test123456", + "phone": "13873975048", + "role_id": 1, + "state": 1, + "title": "更新后的职位" + }, + { + "create_time": "2026-03-20 02:57:19", + "id": 18, + "modify_time": "2026-03-20 02:57:19", + "name": "测试管理员_1773975440", + "password": "test123456", + "phone": "13873975440", + "role_id": 1, + "state": 1, + "title": "更新后的职位" + }, + { + "create_time": "2026-03-29 16:47:48", + "id": 22, + "modify_time": "2026-03-29 16:47:48", + "name": "测试管理员_1774802868", + "password": "test123456", + "phone": "13874802868", + "role_id": 1, + "state": 1, + "title": "更新后的职位" + }, + { + "create_time": "2026-03-29 16:47:49", + "id": 25, + "modify_time": "2026-03-29 16:47:50", + "name": "测试管理员_1774802869", + "password": "test123456", + "phone": "13874802869", + "role_id": 1, + "state": 1, + "title": "更新后的职位" + }, + { + "create_time": "2026-03-29 16:54:20", + "id": 28, + "modify_time": "2026-03-29 16:54:20", + "name": "测试管理员_1774803260", + "password": "test123456", + "phone": "13874803260", + "role_id": 1, + "state": 1, + "title": "更新后的职位" + } + ], + "table": "admin" }, "status": 0 }, "expect": { + "result": { + "columns": [], + "sample_data": [], + "table": "sample" + }, "status": 0 } } @@ -1105,12 +1542,52 @@ "passed": true, "response": { "result": { - "reason": "当前非 MySQL 数据库", - "skip": true + "name": "MySQL原生SQL测试", + "success": true, + "tests": [ + { + "count": 5, + "name": "Query 原生查询 (反引号)", + "result": true + }, + { + "affected": 1, + "name": "Exec 原生执行 (反引号)", + "result": true + }, + { + "count": 5, + "lastQuery": "SELECT id, title FROM `article` WHERE id IN (?, ?, ?, ?, ?) LIMIT 10", + "name": "IN (?) 非空数组展开", + "result": true + }, + { + "count": 0, + "lastQuery": "SELECT id, title FROM `article` WHERE 1=0 LIMIT 10", + "name": "IN (?) 空数组替换为1=0", + "result": true + }, + { + "count": 10, + "lastQuery": "SELECT id, title FROM `article` WHERE 1=1 LIMIT 10", + "name": "NOT IN (?) 空数组替换为1=1", + "result": true + } + ] }, "status": 0 }, "expect": { + "result": { + "name": "sample", + "success": true, + "tests": [ + { + "name": "sample", + "result": true + } + ] + }, "status": 0 } } @@ -1131,12 +1608,99 @@ "passed": true, "response": { "result": { - "reason": "当前非 MySQL 数据库", - "skip": true + "tables": [ + { + "Tables_in_test": "体育" + }, + { + "Tables_in_test": "军事" + }, + { + "Tables_in_test": "历史" + }, + { + "Tables_in_test": "地理" + }, + { + "Tables_in_test": "外语" + }, + { + "Tables_in_test": "天文" + }, + { + "Tables_in_test": "影音" + }, + { + "Tables_in_test": "数学" + }, + { + "Tables_in_test": "数学1" + }, + { + "Tables_in_test": "文学" + }, + { + "Tables_in_test": "生物" + }, + { + "Tables_in_test": "综合" + }, + { + "Tables_in_test": "admin" + }, + { + "Tables_in_test": "article" + }, + { + "Tables_in_test": "auth_role" + }, + { + "Tables_in_test": "auth_user" + }, + { + "Tables_in_test": "cached" + }, + { + "Tables_in_test": "ctg" + }, + { + "Tables_in_test": "history" + }, + { + "Tables_in_test": "hotime_cache" + }, + { + "Tables_in_test": "ibang0420_iqiyi" + }, + { + "Tables_in_test": "question" + }, + { + "Tables_in_test": "role" + }, + { + "Tables_in_test": "role_user" + }, + { + "Tables_in_test": "stroage" + }, + { + "Tables_in_test": "test_batch" + }, + { + "Tables_in_test": "user" + }, + { + "Tables_in_test": "version" + } + ] }, "status": 0 }, "expect": { + "result": { + "tables": [] + }, "status": 0 } } @@ -1152,8 +1716,20 @@ { "cases": [ { - "name": "聚合函数", + "expect": { + "result": { + "name": "sample", + "success": true, + "tests": [ + { + "name": "sample" + } + ] + }, + "status": 0 + }, "method": "GET", + "name": "聚合函数", "passed": true, "response": { "result": { @@ -1161,12 +1737,12 @@ "success": true, "tests": [ { - "count": 0, + "count": 99, "name": "Count 总数统计", "result": true }, { - "count": 0, + "count": 81, "name": "Count 条件统计", "result": true }, @@ -1174,23 +1750,23 @@ "lastQuery": "SELECT SUM(click_num) FROM \"article\" WHERE \"state\" =? ;", "name": "Sum 求和 (单字段名)", "result": true, - "sum": 0 + "sum": 110799 }, { - "avg": 0, + "avg": 1367.888889, "lastQuery": "SELECT AVG(click_num) FROM \"article\" WHERE \"state\" =? ;", "name": "Avg 平均值 (单字段名)", "result": true }, { "lastQuery": "SELECT MAX(click_num) FROM \"article\" WHERE \"state\" =? ;", - "max": 0, + "max": 9999, "name": "Max 最大值 (单字段名)", "result": true }, { "lastQuery": "SELECT MIN(sort) FROM \"article\" WHERE \"state\" =? ;", - "min": 0, + "min": 1, "name": "Min 最小值 (单字段名)", "result": true }, @@ -1226,15 +1802,15 @@ }, { "lastQuery": "SELECT SUM(\"article\".\"click_num\") FROM \"article\" WHERE \"state\" =? ;", - "match_single_field": true, + "match_single_field": false, "name": "Sum 求和 (table.column 格式)", "result": true, "sum": 0 }, { "name": "聚合函数一致性验证", - "result": true, - "summary": "Sum: 0=0, Avg: 0=0, Max: 0=0, Min: 0=0" + "result": false, + "summary": "Sum: 110799=0, Avg: 1367.888889=0, Max: 9999=0, Min: 1=0" }, { "lastQuery": "SELECT SUM(\"article\".\"click_num\") FROM \"article\" LEFT JOIN \"ctg\" ON \"article\".\"ctg_id\" = \"ctg\".\"id\" WHERE \"article\".\"state\" =? ;", @@ -1243,7 +1819,7 @@ "sum": 0 }, { - "count": 0, + "count": 81, "lastQuery": "SELECT COUNT(*) FROM \"article\" LEFT JOIN \"ctg\" ON \"article\".\"ctg_id\" = \"ctg\".\"id\" WHERE \"article\".\"state\" =? ;", "name": "Count 带 JOIN", "result": true @@ -1251,9 +1827,6 @@ ] }, "status": 0 - }, - "expect": { - "status": 0 } } ], @@ -1268,8 +1841,96 @@ { "cases": [ { - "name": "全部测试", + "expect": { + "result": { + "1_basic_crud": { + "name": "sample", + "success": true, + "tests": [ + { + "name": "sample", + "result": true + } + ] + }, + "2_condition_syntax": { + "name": "sample", + "success": true, + "tests": [ + { + "name": "sample", + "result": true + } + ] + }, + "3_chain_query": { + "name": "sample", + "success": true, + "tests": [ + { + "name": "sample" + } + ] + }, + "4_join_query": { + "name": "sample", + "success": true, + "tests": [ + { + "name": "sample" + } + ] + }, + "5_aggregate": { + "name": "sample", + "success": true, + "tests": [ + { + "name": "sample" + } + ] + }, + "6_pagination": { + "name": "sample", + "success": true, + "tests": [ + { + "name": "sample" + } + ] + }, + "7_batch_insert": { + "name": "sample", + "success": true, + "tests": [ + { + "name": "sample" + } + ] + }, + "8_upsert": { + "name": "sample", + "success": true, + "tests": [ + { + "name": "sample" + } + ] + }, + "9_transaction": { + "name": "sample", + "success": true, + "tests": [ + { + "name": "sample" + } + ] + } + }, + "status": 0 + }, "method": "GET", + "name": "全部测试", "passed": true, "response": { "result": { @@ -1278,19 +1939,19 @@ "success": true, "tests": [ { - "adminId": 88, - "lastQuery": "INSERT INTO \"admin\" (\"phone\",\"state\",\"password\",\"role_id\",\"title\",\"create_time\",\"modify_time\",\"name\") VALUES (?,?,?,?,?,NOW(),NOW(),?);", + "adminId": 97, + "lastQuery": "INSERT INTO \"admin\" (\"create_time\",\"modify_time\",\"name\",\"phone\",\"state\",\"password\",\"role_id\",\"title\") VALUES (NOW(),NOW(),?,?,?,?,?,?);", "name": "Insert 插入测试 (admin表)", "result": true }, { "admin": { - "create_time": "2026-03-21 13:33:57", - "id": 88, - "modify_time": "2026-03-21 13:33:57", - "name": "测试管理员_1774071237", + "create_time": "2026-03-30 01:10:06", + "id": 97, + "modify_time": "2026-03-30 01:10:06", + "name": "测试管理员_1774804206", "password": "test123456", - "phone": "13874071237", + "phone": "13874804206", "role_id": 1, "state": 1, "title": "测试职位" @@ -1452,7 +2113,7 @@ "result": true }, { - "count": 0, + "count": 81, "name": "链式 Count 统计", "result": true }, @@ -1581,12 +2242,12 @@ "success": true, "tests": [ { - "count": 0, + "count": 99, "name": "Count 总数统计", "result": true }, { - "count": 0, + "count": 81, "name": "Count 条件统计", "result": true }, @@ -1594,23 +2255,23 @@ "lastQuery": "SELECT SUM(click_num) FROM \"article\" WHERE \"state\" =? ;", "name": "Sum 求和 (单字段名)", "result": true, - "sum": 0 + "sum": 110799 }, { - "avg": 0, + "avg": 1367.888889, "lastQuery": "SELECT AVG(click_num) FROM \"article\" WHERE \"state\" =? ;", "name": "Avg 平均值 (单字段名)", "result": true }, { "lastQuery": "SELECT MAX(click_num) FROM \"article\" WHERE \"state\" =? ;", - "max": 0, + "max": 9999, "name": "Max 最大值 (单字段名)", "result": true }, { "lastQuery": "SELECT MIN(sort) FROM \"article\" WHERE \"state\" =? ;", - "min": 0, + "min": 1, "name": "Min 最小值 (单字段名)", "result": true }, @@ -1646,15 +2307,15 @@ }, { "lastQuery": "SELECT SUM(\"article\".\"click_num\") FROM \"article\" WHERE \"state\" =? ;", - "match_single_field": true, + "match_single_field": false, "name": "Sum 求和 (table.column 格式)", "result": true, "sum": 0 }, { "name": "聚合函数一致性验证", - "result": true, - "summary": "Sum: 0=0, Avg: 0=0, Max: 0=0, Min: 0=0" + "result": false, + "summary": "Sum: 110799=0, Avg: 1367.888889=0, Max: 9999=0, Min: 1=0" }, { "lastQuery": "SELECT SUM(\"article\".\"click_num\") FROM \"article\" LEFT JOIN \"ctg\" ON \"article\".\"ctg_id\" = \"ctg\".\"id\" WHERE \"article\".\"state\" =? ;", @@ -1663,7 +2324,7 @@ "sum": 0 }, { - "count": 0, + "count": 81, "lastQuery": "SELECT COUNT(*) FROM \"article\" LEFT JOIN \"ctg\" ON \"article\".\"ctg_id\" = \"ctg\".\"id\" WHERE \"article\".\"state\" =? ;", "name": "Count 带 JOIN", "result": true @@ -1720,7 +2381,7 @@ "tests": [ { "affected": 1, - "lastQuery": "MERGE INTO \"admin\" USING (SELECT ? AS \"password\", ? AS \"role_id\", ? AS \"title\", NOW() AS \"create_time\", NOW() AS \"modify_time\", ? AS \"name\", ? AS \"phone\", ? AS \"state\") src ON (\"admin\".\"phone\" = src.\"phone\") WHEN MATCHED THEN UPDATE SET \"name\" = src.\"name\", \"state\" = src.\"state\", \"title\" = src.\"title\", \"modify_time\" = NOW() WHEN NOT MATCHED THEN INSERT (\"password\", \"role_id\", \"title\", \"create_time\", \"modify_time\", \"name\", \"phone\", \"state\") VALUES (src.\"password\", src.\"role_id\", src.\"title\", src.\"create_time\", src.\"modify_time\", src.\"name\", src.\"phone\", src.\"state\")", + "lastQuery": "MERGE INTO \"admin\" USING (SELECT ? AS \"title\", NOW() AS \"create_time\", NOW() AS \"modify_time\", ? AS \"name\", ? AS \"phone\", ? AS \"state\", ? AS \"password\", ? AS \"role_id\") src ON (\"admin\".\"phone\" = src.\"phone\") WHEN MATCHED THEN UPDATE SET \"name\" = src.\"name\", \"state\" = src.\"state\", \"title\" = src.\"title\", \"modify_time\" = NOW() WHEN NOT MATCHED THEN INSERT (\"title\", \"create_time\", \"modify_time\", \"name\", \"phone\", \"state\", \"password\", \"role_id\") VALUES (src.\"title\", src.\"create_time\", src.\"modify_time\", src.\"name\", src.\"phone\", src.\"state\", src.\"password\", src.\"role_id\")", "name": "Upsert 插入新记录 (admin表)", "result": true }, @@ -1729,12 +2390,12 @@ "name": "Upsert 更新已存在记录", "result": true, "updatedAdmin": { - "create_time": "2026-03-21 13:34:05", - "id": 89, - "modify_time": "2026-03-21 13:34:05", + "create_time": "2026-03-30 01:10:10", + "id": 98, + "modify_time": "2026-03-30 01:10:10", "name": "Upsert更新后管理员", "password": "test123", - "phone": "19974071244", + "phone": "19974804210", "role_id": 2, "state": 1, "title": "更新后职位" @@ -1760,9 +2421,6 @@ } }, "status": 0 - }, - "expect": { - "status": 0 } } ], @@ -1777,8 +2435,20 @@ { "cases": [ { - "name": "批量插入", + "expect": { + "result": { + "name": "sample", + "success": true, + "tests": [ + { + "name": "sample" + } + ] + }, + "status": 0 + }, "method": "GET", + "name": "批量插入", "passed": true, "response": { "result": { @@ -1799,9 +2469,6 @@ ] }, "status": 0 - }, - "expect": { - "status": 0 } } ], @@ -1816,8 +2483,20 @@ { "cases": [ { - "name": "链式查询", + "expect": { + "result": { + "name": "sample", + "success": true, + "tests": [ + { + "name": "sample" + } + ] + }, + "status": 0 + }, "method": "GET", + "name": "链式查询", "passed": true, "response": { "result": { @@ -1859,7 +2538,7 @@ "result": true }, { - "count": 0, + "count": 81, "name": "链式 Count 统计", "result": true }, @@ -1898,9 +2577,6 @@ ] }, "status": 0 - }, - "expect": { - "status": 0 } } ], @@ -1915,8 +2591,21 @@ { "cases": [ { - "name": "条件查询", + "expect": { + "result": { + "name": "sample", + "success": true, + "tests": [ + { + "name": "sample", + "result": true + } + ] + }, + "status": 0 + }, "method": "GET", + "name": "条件查询", "passed": true, "response": { "result": { @@ -2012,9 +2701,6 @@ ] }, "status": 0 - }, - "expect": { - "status": 0 } } ], @@ -2029,8 +2715,21 @@ { "cases": [ { - "name": "CRUD测试", + "expect": { + "result": { + "name": "sample", + "success": true, + "tests": [ + { + "name": "sample", + "result": true + } + ] + }, + "status": 0 + }, "method": "GET", + "name": "CRUD测试", "passed": true, "response": { "result": { @@ -2038,19 +2737,19 @@ "success": true, "tests": [ { - "adminId": 90, - "lastQuery": "INSERT INTO \"admin\" (\"modify_time\",\"name\",\"phone\",\"state\",\"password\",\"role_id\",\"title\",\"create_time\") VALUES (NOW(),?,?,?,?,?,?,NOW());", + "adminId": 99, + "lastQuery": "INSERT INTO \"admin\" (\"title\",\"create_time\",\"modify_time\",\"name\",\"phone\",\"state\",\"password\",\"role_id\") VALUES (?,NOW(),NOW(),?,?,?,?,?);", "name": "Insert 插入测试 (admin表)", "result": true }, { "admin": { - "create_time": "2026-03-21 13:34:06", - "id": 90, - "modify_time": "2026-03-21 13:34:06", - "name": "测试管理员_1774071246", + "create_time": "2026-03-30 01:10:11", + "id": 99, + "modify_time": "2026-03-30 01:10:11", + "name": "测试管理员_1774804211", "password": "test123456", - "phone": "13874071246", + "phone": "13874804211", "role_id": 1, "state": 1, "title": "测试职位" @@ -2082,9 +2781,6 @@ ] }, "status": 0 - }, - "expect": { - "status": 0 } } ], @@ -2099,8 +2795,20 @@ { "cases": [ { - "name": "JOIN查询", + "expect": { + "result": { + "name": "sample", + "success": true, + "tests": [ + { + "name": "sample" + } + ] + }, + "status": 0 + }, "method": "GET", + "name": "JOIN查询", "passed": true, "response": { "result": { @@ -2190,9 +2898,6 @@ ] }, "status": 0 - }, - "expect": { - "status": 0 } } ], @@ -2207,8 +2912,20 @@ { "cases": [ { - "name": "分页查询", + "expect": { + "result": { + "name": "sample", + "success": true, + "tests": [ + { + "name": "sample" + } + ] + }, + "status": 0 + }, "method": "GET", + "name": "分页查询", "passed": true, "response": { "result": { @@ -2239,9 +2956,6 @@ ] }, "status": 0 - }, - "expect": { - "status": 0 } } ], @@ -2254,20 +2968,60 @@ "tested": true }, { - "cases": null, + "cases": [ + { + "expect": { + "error": { + "msg": "dsadasd", + "type": "访问权限异常" + }, + "result": { + "msg": "dsadasd", + "type": "访问权限异常" + }, + "status": 2 + }, + "method": "GET", + "name": "固定返回错误码2", + "passed": true, + "response": { + "error": { + "msg": "dsadasd", + "type": "访问权限异常" + }, + "result": { + "msg": "dsadasd", + "type": "访问权限异常" + }, + "status": 2 + } + } + ], "ctr": "test", - "method": "POST", + "method": "GET", "params": [], "path": "/app/test/test", "project": "app", - "summary": "test", - "tested": false + "summary": "固定错误返回", + "tested": true }, { "cases": [ { - "name": "事务测试", + "expect": { + "result": { + "name": "sample", + "success": true, + "tests": [ + { + "name": "sample" + } + ] + }, + "status": 0 + }, "method": "GET", + "name": "事务测试", "passed": true, "response": { "result": { @@ -2287,9 +3041,6 @@ ] }, "status": 0 - }, - "expect": { - "status": 0 } } ], @@ -2304,8 +3055,20 @@ { "cases": [ { - "name": "Upsert测试", + "expect": { + "result": { + "name": "sample", + "success": true, + "tests": [ + { + "name": "sample" + } + ] + }, + "status": 0 + }, "method": "GET", + "name": "Upsert测试", "passed": true, "response": { "result": { @@ -2314,7 +3077,7 @@ "tests": [ { "affected": 1, - "lastQuery": "MERGE INTO \"admin\" USING (SELECT ? AS \"state\", ? AS \"password\", ? AS \"role_id\", ? AS \"title\", NOW() AS \"create_time\", NOW() AS \"modify_time\", ? AS \"name\", ? AS \"phone\") src ON (\"admin\".\"phone\" = src.\"phone\") WHEN MATCHED THEN UPDATE SET \"name\" = src.\"name\", \"state\" = src.\"state\", \"title\" = src.\"title\", \"modify_time\" = NOW() WHEN NOT MATCHED THEN INSERT (\"state\", \"password\", \"role_id\", \"title\", \"create_time\", \"modify_time\", \"name\", \"phone\") VALUES (src.\"state\", src.\"password\", src.\"role_id\", src.\"title\", src.\"create_time\", src.\"modify_time\", src.\"name\", src.\"phone\")", + "lastQuery": "MERGE INTO \"admin\" USING (SELECT ? AS \"title\", NOW() AS \"create_time\", NOW() AS \"modify_time\", ? AS \"name\", ? AS \"phone\", ? AS \"state\", ? AS \"password\", ? AS \"role_id\") src ON (\"admin\".\"phone\" = src.\"phone\") WHEN MATCHED THEN UPDATE SET \"name\" = src.\"name\", \"state\" = src.\"state\", \"title\" = src.\"title\", \"modify_time\" = NOW() WHEN NOT MATCHED THEN INSERT (\"title\", \"create_time\", \"modify_time\", \"name\", \"phone\", \"state\", \"password\", \"role_id\") VALUES (src.\"title\", src.\"create_time\", src.\"modify_time\", src.\"name\", src.\"phone\", src.\"state\", src.\"password\", src.\"role_id\")", "name": "Upsert 插入新记录 (admin表)", "result": true }, @@ -2323,12 +3086,12 @@ "name": "Upsert 更新已存在记录", "result": true, "updatedAdmin": { - "create_time": "2026-03-21 13:34:09", - "id": 91, - "modify_time": "2026-03-21 13:34:10", + "create_time": "2026-03-30 01:10:03", + "id": 96, + "modify_time": "2026-03-30 01:10:03", "name": "Upsert更新后管理员", "password": "test123", - "phone": "19974071249", + "phone": "19974804203", "role_id": 2, "state": 1, "title": "更新后职位" @@ -2337,9 +3100,6 @@ ] }, "status": 0 - }, - "expect": { - "status": 0 } } ], diff --git a/testing_api.go b/testing_api.go index c473c47..ef5ee4f 100644 --- a/testing_api.go +++ b/testing_api.go @@ -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 { diff --git a/testing_helper.go b/testing_helper.go index 1dec2dc..5b5c0f7 100644 --- a/testing_helper.go +++ b/testing_helper.go @@ -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, diff --git a/testing_swagger.go b/testing_swagger.go index 8e797c5..45e4986 100644 --- a/testing_swagger.go +++ b/testing_swagger.go @@ -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 {