feat(api): 增强 API 测试框架功能与文档
- 在 Api 结构中新增 lastResp 字段以存储最近请求的响应 - 添加 Verify 方法,支持自定义校验函数并返回 ApiCase 构建器 - 新增 Resp 方法,获取最近一次请求的响应以便于断言 - 在 TestCollector 中添加 Visited 字段,记录已调用的路径 - 更新 GenerateSwagger 方法,支持部分运行时保留未运行端点的已有数据 - 完善文档,增加用例编写范式和示例,提升测试框架的可用性与易用性
This commit is contained in:
@@ -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()` 的说明但标注为兼容用法
|
||||
|
||||
@@ -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 校验的简单场景):`
|
||||
@@ -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 示例)
|
||||
- 并发保护与缓存隔离(精简版)
|
||||
- 二进制响应校验(精简版)
|
||||
|
||||
@@ -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 源码
|
||||
- 不涉及其他文档
|
||||
|
||||
+517
-295
@@ -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,生产代码路径不受任何影响。
|
||||
|
||||
@@ -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",
|
||||
})
|
||||
}},
|
||||
}
|
||||
|
||||
@@ -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}},
|
||||
})
|
||||
}},
|
||||
}
|
||||
|
||||
+201
-27
@@ -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
|
||||
}).
|
||||
|
||||
@@ -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}},
|
||||
})
|
||||
}},
|
||||
}
|
||||
|
||||
+173
-10
@@ -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
|
||||
}
|
||||
|
||||
+1045
-285
File diff suppressed because it is too large
Load Diff
+25
-5
@@ -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 {
|
||||
|
||||
+21
-8
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user