feat(testing): Flows/FromCase、增量 swagger 与 Doc-Driven 门禁
补齐多接口流程联调与控制台体验,并落地仓内 Doc-Driven+TDD 总规则与文档。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,32 @@
|
||||
---
|
||||
description: HoTime Doc-Driven + TDD 总门禁
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# HoTime 工作流
|
||||
|
||||
**不可跳过。** Doc-Driven 与 TDD 缺一不可。日常五步:①定位 → ②修改 → ③补/改行为测试并针对性跑至绿 → ④边界 → ⑤回写文档。未测绿或未回写 = **未完成**。中文回复。
|
||||
|
||||
## 定位
|
||||
|
||||
- 先锁约 **3~4 个文件**(文档 1~2 + 源码 1~2);跨模块扩到合计约 **4~6**,禁止散改全仓。本次新增/修改的**测试文件计入锁定**。先读相关 docs 再改。口径不清 **先问**。
|
||||
- 入口:`docs/README.md`、仓根 `README.md`;测试细则 → `docs/Testing_API测试框架.md` / skill `hotime-tdd-testing`。
|
||||
|
||||
## 改与测
|
||||
|
||||
- 改接口、ctr、业务逻辑或测试框架:对应 `*_test` **无则补、有则改**,跑通至绿;**禁**只改实现不补测;**禁全量**;编译不算测完。
|
||||
- 样板(`example` 根):`go test ./app/ -count=1 -run 'TestApi/app/<ctr>/<action>'`。可收窄到子用例;**改哪测哪**。用户要求全量或以编译代替行为测时,**明确拒绝**并仍按本条。
|
||||
|
||||
## 文档
|
||||
|
||||
- **有旧改旧**;无篇目且确有缺口才新建。禁不查就建、禁同一入口重复建档。旧文只补受影响节;只写最终态;对照源码,禁臆测。
|
||||
- 路径用相对 Markdown 链接;**禁** `D:\`、`file:///`、`~/`。新建须回写 `docs/README.md`。
|
||||
|
||||
## 回复
|
||||
|
||||
- 先结论;摘要 + 文档路径 + **改动定位(文件·位置·原因)**;禁大段复述 docs。
|
||||
- 凡改代码或报完成:末尾必须一行 `【门禁】锁定:… | 补测并跑绿:是/否 | 文档回写:是/否/无 | 新建or旧文:…`;缺一不可。闲聊不要求。
|
||||
|
||||
## 批量补文档(仅整包)
|
||||
|
||||
一文一题;最少 2 轮自检。日常小改不适用。
|
||||
+4
-2
@@ -1,5 +1,7 @@
|
||||
# HoTime 文档索引
|
||||
|
||||
本仓采用 **Doc-Driven + TDD**:行为一变必须回写 docs;改接口必须用 `-run` 精确测试验证(见 [Testing_API测试框架.md](Testing_API测试框架.md) 文首铁律与仓规则 `hotime-doc-tdd`)。
|
||||
|
||||
推荐按路径阅读,避免同一概念在多份文档里来回找。
|
||||
|
||||
## 阅读路径
|
||||
@@ -8,7 +10,7 @@
|
||||
2. **类型与工具** → [Common_工具类.md](Common_工具类.md)
|
||||
3. **数据库 ORM** → [HoTimeDB_使用说明.md](HoTimeDB_使用说明.md)(教程)→ [HoTimeDB_API参考.md](HoTimeDB_API参考.md)(速查)
|
||||
4. **开表 / 代码生成** → [DatabaseDesign_数据库设计规范.md](DatabaseDesign_数据库设计规范.md)(前置)→ [CodeGen_代码生成.md](CodeGen_代码生成.md)
|
||||
5. **接口测试** → [Testing_API测试框架.md](Testing_API测试框架.md)(日常用 `-run` 单测;全量见文末)
|
||||
5. **接口测试** → [Testing_API测试框架.md](Testing_API测试框架.md)(日常用 `-run` 单测;局限见文末;全量见文末)
|
||||
6. **运维** → [GracefulShutdown_优雅停机.md](GracefulShutdown_优雅停机.md)、[Seq_日志集成.md](Seq_日志集成.md)
|
||||
7. **规划** → [ROADMAP_改进规划.md](ROADMAP_改进规划.md)
|
||||
|
||||
@@ -22,7 +24,7 @@
|
||||
| HoTimeDB_API参考 | ORM 方法与条件语法速查 |
|
||||
| DatabaseDesign_数据库设计规范 | 表/字段/COMMENT 规范(CodeGen 输入契约) |
|
||||
| CodeGen_代码生成 | 生成器用法 + codeConfig / rule 配置 |
|
||||
| Testing_API测试框架 | API 测试;优先 `-run`,全量沉底 |
|
||||
| Testing_API测试框架 | API 测试、Flows、控制台;优先 `-run`;已知局限 |
|
||||
| GracefulShutdown_优雅停机 | 优雅停机与滚动重启 |
|
||||
| Seq_日志集成 | Seq 结构化日志 |
|
||||
| ROADMAP_改进规划 | 待改进项 |
|
||||
|
||||
@@ -266,6 +266,7 @@ a.JSON(Map{"phone": "138..."}).Post("正常注册", 0, Map{"id": notEmpty}) //
|
||||
|--------|--------|------|
|
||||
| 高 | 备注语法优化(`\|` 分隔符) | 待设计 |
|
||||
| 高 | 测试框架:响应字段部分匹配断言 | 待实现 |
|
||||
| 高 | 测试框架其它缺口(DB 断言、FromCase 无模板失败、Header API 等) | 见 [Testing 已知局限](Testing_API测试框架.md#已知局限与后续) |
|
||||
| 中 | 软删除/日志表支持 | 待设计 |
|
||||
| 低 | 多对多关联表增强 | 待评估 |
|
||||
| 低 | 自动填充字段扩展 | 待评估 |
|
||||
@@ -276,6 +277,7 @@ a.JSON(Map{"phone": "138..."}).Post("正常注册", 0, Map{"id": notEmpty}) //
|
||||
|
||||
| 日期 | 内容 |
|
||||
|------|------|
|
||||
| 2026-07-13 | 测试框架文档补齐 Flows/控制台;缺口清单链到 Testing |
|
||||
| 2026-01-24 | 初始版本,记录分析结果和待改进项 |
|
||||
| 2026-03-15 | 新增测试框架响应字段断言改进规划(七) |
|
||||
| 2026-03-20 | 完成达梦(DM8)数据库完整支持:Dialect 适配(MERGE INTO Upsert、双引号标识符)、LastInsertId、保留字自动引号、schema 正确处理、代码生成器 DM 兼容、缓存表自动建表、测试框架 DM 适配 |
|
||||
|
||||
+130
-57
@@ -14,18 +14,16 @@
|
||||
- [快速开始](#快速开始)
|
||||
- [运行测试(推荐:-run 单测)](#运行测试推荐-run-单测)
|
||||
- [用例编写范式](#用例编写范式)
|
||||
- [错误用例编写规范](#1-错误用例编写规范)
|
||||
- [测试数据准备](#2-测试数据准备)
|
||||
- [正确请求与响应结构校验](#3-正确请求与响应结构校验)
|
||||
- [Verify 统一校验](#4-verify-统一校验)
|
||||
- [简写支持说明](#简写支持说明)
|
||||
- [核心类型](#核心类型)
|
||||
- [业务流程联调(Flows)](#业务流程联调flows)
|
||||
- [优雅停测](#优雅停测)
|
||||
- [链式 API 参考](#链式-api-参考)
|
||||
- [事务隔离机制](#事务隔离机制)
|
||||
- [API 调试控制台](#api-调试控制台)
|
||||
- [并发保护与缓存隔离](#并发保护与缓存隔离)
|
||||
- [二进制与非 JSON 响应校验](#二进制与非-json-响应校验)
|
||||
- [全量回归与覆盖率(仅 CI / 发版前)](#全量回归与覆盖率仅-ci--发版前)
|
||||
- [已知局限与后续](#已知局限与后续)
|
||||
|
||||
---
|
||||
|
||||
@@ -41,8 +39,10 @@ HoTime 内置 API 测试框架,核心能力:
|
||||
| **并发保护** | `testMu` 互斥锁自动保护 `testTx` 单连接,业务代码中的 `go func()` 协程不会导致 `busy buffer` |
|
||||
| **缓存隔离** | 测试启动时自动禁用 DB/Redis 缓存,避免缓存操作与测试事务锁冲突 |
|
||||
| **链式 API** | `a.JSON(...).Post("描述", 期望status)` 一行完成:数据组装 + 请求 + 断言 |
|
||||
| **覆盖率** | 自动对比路由注册表与测试定义,输出哪些接口已覆盖、哪些未覆盖 |
|
||||
| **调试控制台** | 按模块生成独立的交互式 API 调试控制台,支持在线测试、认证管理、参数编辑 |
|
||||
| **Flows** | 多接口业务流程联调;`Group`/`Desc`/`Prep`;`FromCase` 绑定单接口用例并回写 |
|
||||
| **覆盖率** | 自动对比路由注册表与测试定义(**不含 Flow 步骤**,只统计单接口 path) |
|
||||
| **调试控制台** | 增量写入 swagger;流程分组 UI;发送结果 cURL 实时同步左侧参数 |
|
||||
| **优雅停测** | SIGINT/SIGTERM / `RequestStop`:停收新测、跑完当前、回滚事务、保留已落盘文档 |
|
||||
|
||||
---
|
||||
|
||||
@@ -184,15 +184,15 @@ var ProjectTest = ProjTest{
|
||||
var testApp *TestApp
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
os.Chdir("..") // 确保工作目录为项目根目录
|
||||
testApp = NewTestApp("config/config.json", TestProj{
|
||||
testApp = Test("config/config.json").
|
||||
WorkDir("..").
|
||||
Swagger("My API", "1.0.0").
|
||||
Proj(TestProj{
|
||||
"app": {Proj: Project, Tests: ProjectTest},
|
||||
})
|
||||
code := m.Run()
|
||||
// 以下两行适合 CI / 全量回归;日常用 -run 单测时也会执行,但覆盖率主要在全量时有意义
|
||||
testApp.PrintCoverage()
|
||||
testApp.GenerateSwagger("My API", "1.0.0", testApp.Config.GetString("tpt"))
|
||||
os.Exit(code)
|
||||
// 可选:.Flows(FlowTest{ "checkout": {Desc: "下单支付跑通", Func: checkoutFlow} })
|
||||
|
||||
os.Exit(testApp.Run(m))
|
||||
}
|
||||
|
||||
func TestApi(t *testing.T) {
|
||||
@@ -200,6 +200,8 @@ func TestApi(t *testing.T) {
|
||||
}
|
||||
```
|
||||
|
||||
`Run(m)` 内部会:注册优雅停测信号 → `m.Run()` → 覆盖率 → Swagger 收尾(接口级增量写入)。
|
||||
`WorkDir` / `Swagger` / `Flows` 均可省略;不写 `Swagger` 则不生成调试控制台。
|
||||
### 推荐目录结构
|
||||
|
||||
```
|
||||
@@ -233,7 +235,7 @@ go test ./app/ -v -run "TestApi/app/(user|order)"
|
||||
go test ./app/ -v -run "TestApi/.*/.*未登录"
|
||||
```
|
||||
|
||||
> **注意**:`TestMain` 中的 `os.Chdir("..")` 确保 `go test ./app/` 的工作目录为项目根目录,使配置文件路径和模板输出路径正确。
|
||||
> **注意**:`WorkDir("..")` 在 Init 前切换工作目录,确保 `go test ./app/` 时配置与模板输出路径正确。
|
||||
>
|
||||
> 全量 `go test ./app/... -v`(不带 `-run`)会跑完所有用例并触发覆盖率扫描,项目变大后很慢且容易牵连无关失败。**仅在 CI / 发版前使用**,说明见文末。
|
||||
|
||||
@@ -511,25 +513,82 @@ type ApiTestDef struct {
|
||||
### TestApp
|
||||
|
||||
```go
|
||||
// NewTestApp 创建测试应用
|
||||
// configPath: 配置文件路径
|
||||
// projects: TestProj 注册所有项目的路由和测试
|
||||
// listeners: 可选,与 SetConnectListener 相同的请求拦截器
|
||||
func NewTestApp(configPath string, projects TestProj, listeners ...func(*Context) bool) *TestApp
|
||||
// Test 链式入口(延迟 Init,便于先 WorkDir)
|
||||
func Test(configPath string, listeners ...func(*Context) bool) *TestApp
|
||||
|
||||
func (app *TestApp) WorkDir(dir string) *TestApp
|
||||
func (app *TestApp) Swagger(args ...string) *TestApp // title / version / outputDir 均可选
|
||||
func (app *TestApp) Proj(projects TestProj) *TestApp
|
||||
func (app *TestApp) Flows(flows FlowTest) *TestApp // 可选:多接口业务流程
|
||||
func (app *TestApp) Run(m *testing.M) int // 信号停测 + m.Run + 覆盖率 + Swagger 收尾
|
||||
|
||||
// RunTests 运行所有注册的测试(每个方法级别自动事务隔离)
|
||||
func (app *TestApp) RunTests(t *testing.T)
|
||||
|
||||
// PrintCoverage 输出覆盖率报告,返回报告结构体
|
||||
func (app *TestApp) PrintCoverage() CoverageReport
|
||||
|
||||
// GenerateSwagger 生成 API 调试控制台(api-spec.json + index.html)
|
||||
func (app *TestApp) GenerateSwagger(title, version, outputDir string) error
|
||||
|
||||
// DB 获取测试应用的数据库实例(在事务内)
|
||||
func (app *TestApp) DB() *HoTimeDB
|
||||
```
|
||||
|
||||
### 业务流程联调(Flows)
|
||||
|
||||
与单接口 `CtrTest` 并列,用于多接口串起来的业务跑通。整条 Flow 共用一次测试事务,结束回滚。
|
||||
|
||||
**FlowDef 字段**
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `Group` | 控制台侧栏分组(如 `order`) |
|
||||
| `Desc` | 流程短标题 |
|
||||
| `Prep` | 业务说明 / 前置与数据准备(控制台「业务说明」) |
|
||||
| `Func` | `func(f *Flow)` |
|
||||
|
||||
**Flow API**
|
||||
|
||||
| 方法 | 说明 |
|
||||
|------|------|
|
||||
| `f.Step(name, path)` | 新步骤,返回 `*Api`(记录归入 flows) |
|
||||
| `f.AtPath(path)` | 同流程内换 path,不增步骤序号 |
|
||||
| `f.WithSession(s)` | 后续步骤携带 session |
|
||||
| `f.DB()` / `f.Resp()` | 流程事务库 / 最近一步响应 |
|
||||
|
||||
```go
|
||||
Flows(FlowTest{
|
||||
"checkout": {
|
||||
Group: "order",
|
||||
Desc: "下单支付跑通",
|
||||
Prep: "先创建订单再支付;步骤可用 FromCase 绑定单接口验收用例。",
|
||||
Func: func(f *Flow) {
|
||||
f.Step("创建订单", "/app/order/create").
|
||||
FromCase("正常创建").
|
||||
Note("创建后取 order id 供支付步骤使用").
|
||||
WithSession(Map{"admin_id": 1}).
|
||||
Post("创建", 0, Map{"id": int64(1)})
|
||||
orderID := f.Resp().GetBody().GetMap("result").GetCeilInt64("id")
|
||||
f.Step("支付", "/app/order/pay").
|
||||
WithSession(Map{"admin_id": 1}).
|
||||
JSON(Map{"order_id": orderID}).
|
||||
Post("支付成功", 0)
|
||||
},
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
**FromCase**
|
||||
|
||||
- 按 path + 用例名加载请求模板:优先本轮已跑的单接口记录,否则读已有 `api-spec.json`
|
||||
- 步骤跑完后回写该单接口 case 的 response / passed
|
||||
- 无模板时当前为**静默不填**(见 [已知局限](#已知局限与后续));建议全量或先跑绑定接口再跑 flow
|
||||
- 示例:`example/app/flow_test.go`
|
||||
|
||||
控制台:侧栏「业务流程 → group → flow 名」;步骤左栏含关联验收用例 / 备注 / 请求,右栏响应;点 path 弹窗看接口只读详情。
|
||||
`-run`:`go test ./app/ -run "TestApi/flows/checkout" -v`
|
||||
|
||||
### 优雅停测
|
||||
|
||||
`Run(m)` 内监听 SIGINT/SIGTERM;测试代码可调 `testApp.RequestStop()` 模拟。
|
||||
|
||||
行为:停收后续接口/流程 → 当前子测跑完 → 回滚测试事务 → 已 flush 的 swagger **保留**(不全量 prune)。
|
||||
示例:`example/app/app_test.go` 的 `TestInterruptStop`。
|
||||
|
||||
---
|
||||
|
||||
## 链式 API 参考
|
||||
@@ -546,7 +605,9 @@ func (app *TestApp) DB() *HoTimeDB
|
||||
| `a.File(field, name, content)` | 设置上传文件 | `*ApiCase` |
|
||||
| `a.Note(note)` | 为下一条用例设置备注,备注显示在调试控制台 | `*ApiCase` |
|
||||
| `a.Verify(fn)` | 设置请求后的校验函数(等同于先创建 ApiCase 再 Verify,适合纯 GET 无数据场景) | `*ApiCase` |
|
||||
| `a.FromCase(name)` | 绑定单接口验收用例:加载其请求模板;跑通后回写 response/passed | `*ApiCase` |
|
||||
| `a.WithSession(s)` | 返回携带 session 的新 Api | `*Api` |
|
||||
| `a.RunSub(desc, fn)` | 接口 Func 内再分子场景,便于 `-run` 收窄 | — |
|
||||
| `a.Get(desc, status, expect...)` | 直接 GET 请求(无数据场景) | `*ApiResponse` |
|
||||
| `a.Post(desc, status, expect...)` | 直接 POST 请求(无数据场景) | `*ApiResponse` |
|
||||
| `a.Put(desc, status, expect...)` | 直接 PUT 请求 | `*ApiResponse` |
|
||||
@@ -566,6 +627,7 @@ func (app *TestApp) DB() *HoTimeDB
|
||||
| `c.File(field, name, content)` | 设置上传文件 | `*ApiCase` |
|
||||
| `c.Note(note)` | 为本条用例添加可选备注,备注显示在调试控制台用例详情中 | `*ApiCase` |
|
||||
| `c.WithSession(s)` | 覆盖本次请求的 session | `*ApiCase` |
|
||||
| `c.FromCase(name)` | 绑定单接口验收用例名并加载请求模板(若尚未加载) | `*ApiCase` |
|
||||
| `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` |
|
||||
@@ -720,39 +782,33 @@ that.Db.Action(func(db HoTimeDB) bool {
|
||||
|
||||
## API 调试控制台
|
||||
|
||||
`GenerateSwagger` 按模块生成独立的交互式 API 调试控制台(暗色高对比度主题),每个模块一个子目录,支持独立分发。
|
||||
通过链式 `.Swagger(title, version)` 启用。
|
||||
|
||||
```go
|
||||
testApp.GenerateSwagger(
|
||||
"My API", // 文档标题
|
||||
"2.1.0", // 版本号
|
||||
testApp.Config.GetString("tpt"), // 输出目录(如 "tpt")
|
||||
)
|
||||
```
|
||||
**增量写入**:每个接口 / 流程跑完即 flush;本轮无记录则不覆盖成「未测」;未全量跑完不 prune 已删接口;仅全量完成才清理孤儿模块。Ctrl+C / SIGTERM 走优雅停测后再收尾。
|
||||
|
||||
输出目录默认 `config` 中的 `tpt`。
|
||||
|
||||
### 生成目录结构
|
||||
|
||||
每个模块(`TestProj` 中的项目名)生成独立子目录,同时自动生成根导航页:
|
||||
|
||||
```
|
||||
{outputDir}/swagger/
|
||||
├── index.html ← 根导航页(API 文档中心,卡片式链接到各模块)
|
||||
├── api/
|
||||
│ ├── api-spec.json ← api 模块的 API 规范(路由、测试用例、请求/响应数据)
|
||||
│ └── index.html ← api 模块的调试控制台
|
||||
├── admin/
|
||||
│ ├── api-spec.json
|
||||
├── index.html ← 根导航页
|
||||
├── app/
|
||||
│ ├── api-spec.json ← endpoints + flows
|
||||
│ └── index.html
|
||||
└── portal/
|
||||
├── api-spec.json
|
||||
└── index.html
|
||||
└── ...
|
||||
```
|
||||
|
||||
访问地址:`http://localhost:8081/swagger/`(根导航页),`http://localhost:8081/swagger/api/`(api 模块)
|
||||
访问:`http://localhost:8081/swagger/`(根导航),`http://localhost:8081/swagger/app/`(模块)。
|
||||
|
||||
> **独立分发**:每个模块目录可以单独拷贝和部署,不依赖其他模块。根导航页自动扫描子目录生成链接。
|
||||
### 控制台能力
|
||||
|
||||
控制台支持三级导航、关键字搜索、用例预填、全局认证(Header/URL/Cookie)、cURL 生成、覆盖率可视化等功能。
|
||||
| 区域 | 行为 |
|
||||
|------|------|
|
||||
| 侧栏 | 业务流程 → group → flow 名;接口仍按 project/ctr 三级 |
|
||||
| 流程页 | Prep 业务说明;步骤默认展开;左备注/关联验收用例/请求(cURL),右响应;点 path 弹窗只读详情(Esc 关闭) |
|
||||
| 单接口 | 用例预填、全局认证;**发送结果 cURL 随左侧 Header/Query/JSON/Form 实时同步** |
|
||||
| 发送 | 浏览器 `fetch` 打当前源站,**需已启动服务**(与 httptest 零启动测试不同) |
|
||||
|
||||
---
|
||||
|
||||
@@ -761,7 +817,7 @@ testApp.GenerateSwagger(
|
||||
框架自动处理以下问题,编写测试时无需关心:
|
||||
|
||||
- **并发保护**:`testTx` 是单连接,框架通过 `testMu` 互斥锁自动序列化所有数据库操作,业务代码中的 `go func()` 协程不会导致 `busy buffer` 错误
|
||||
- **缓存隔离**:`NewTestApp` 启动时自动禁用 DB/Redis 缓存,避免与测试事务产生锁冲突;Session 通过 `WithSession()` 直接注入 Memory 缓存
|
||||
- **缓存隔离**:启动时自动禁用 DB/Redis 缓存,避免与测试事务产生锁冲突;Session 通过 `WithSession()` 直接注入 Memory 缓存
|
||||
|
||||
### 一次性数据的处理
|
||||
|
||||
@@ -865,21 +921,21 @@ a.Verify(func(a *Api) error {
|
||||
go test ./app/... -v
|
||||
```
|
||||
|
||||
`TestMain` 里常见的收尾逻辑(全量跑完后才有意义):
|
||||
`TestMain` 推荐写法(`Run` 已含覆盖率与 Swagger 收尾):
|
||||
|
||||
```go
|
||||
func TestMain(m *testing.M) {
|
||||
// ...
|
||||
code := m.Run()
|
||||
testApp.PrintCoverage() // 全量跑完后输出覆盖率
|
||||
testApp.GenerateSwagger("My API", "1.0.0", testApp.Config.GetString("tpt"))
|
||||
os.Exit(code)
|
||||
testApp = Test("config/config.json").
|
||||
WorkDir("..").
|
||||
Swagger("My API", "1.0.0").
|
||||
Proj(TestProj{"app": {Proj: Project, Tests: ProjectTest}})
|
||||
os.Exit(testApp.Run(m))
|
||||
}
|
||||
```
|
||||
|
||||
### 覆盖率报告
|
||||
|
||||
`PrintCoverage()` 自动对比 `Proj`(所有路由)和 `ProjTest`(有测试的路由):
|
||||
`PrintCoverage()` 自动对比 `Proj`(所有路由)和 `ProjTest`(有测试的路由)。**Flow 步骤不计入覆盖率**(`FlowName != ""` 的记录会被跳过)。
|
||||
|
||||
**输出示例(全部通过):**
|
||||
|
||||
@@ -937,3 +993,20 @@ func TestMain(m *testing.M) {
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 已知局限与后续
|
||||
|
||||
| 优先级 | 缺口 | 说明 |
|
||||
|--------|------|------|
|
||||
| 高 | 值级断言助手 | 结构校验有,值断言靠 Verify 手写 |
|
||||
| 高 | DB 断言助手 | 无 AssertCount / AssertRow 一类 |
|
||||
| 高 | FromCase 无模板显式失败 | 现静默不填,易空 body 误测 |
|
||||
| 中 | 自定义 Header / Cookie API | 控制台有,测试链无 `WithHeader` |
|
||||
| 中 | HTTP 状态码 / 响应头断言 | StatusCode 有写入,无公开断言 |
|
||||
| 中 | 覆盖率含 Flow | PrintCoverage 跳过 Flow 记录 |
|
||||
| 低 | PATCH、多文件上传 | 当前边界 |
|
||||
| 低 | 框架级 Seed/Fixture | 依赖业务自写 `a.DB()` 准备 |
|
||||
|
||||
规划表见 [ROADMAP_改进规划.md](ROADMAP_改进规划.md)。
|
||||
|
||||
+79
-12
@@ -1,7 +1,10 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
. "code.hoteas.com/golang/hotime"
|
||||
@@ -18,27 +21,91 @@ var ProjectTest = ProjTest{
|
||||
var testApp *TestApp
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
// Go test 的 CWD 是包目录 example/app/,切回 example/ 使相对路径正确
|
||||
_ = os.Chdir("..")
|
||||
|
||||
testApp = NewTestApp("config/config.json",
|
||||
TestProj{
|
||||
testApp = Test("config/config.json").
|
||||
WorkDir("..").
|
||||
Swagger("My API", "1.0.0").
|
||||
Proj(TestProj{
|
||||
"app": {
|
||||
Proj: Project,
|
||||
Tests: ProjectTest,
|
||||
},
|
||||
},
|
||||
)
|
||||
}).
|
||||
Flows(DemoFlows)
|
||||
|
||||
// 在测试前初始化各数据库的测试表和种子数据
|
||||
// 仅本 example:空库时灌演示数据。须连专用测试库,勿对生产库调用。
|
||||
SetupDatabase(&testApp.Db)
|
||||
|
||||
code := m.Run()
|
||||
testApp.PrintCoverage()
|
||||
testApp.GenerateSwagger("My API", "1.0.0", testApp.Config.GetString("tpt"))
|
||||
os.Exit(code)
|
||||
os.Exit(testApp.Run(m))
|
||||
}
|
||||
|
||||
func TestApi(t *testing.T) {
|
||||
testApp.RunTests(t)
|
||||
}
|
||||
|
||||
// TestInterruptStop 模拟提前中断:string_result 跑完后 RequestStop,zzz_should_skip 应 Skip;
|
||||
// swagger 中 string_result 的 cases 应保留。注意:TestMain 已 Chdir 到 example/,此处不再 WorkDir。
|
||||
func TestInterruptStop(t *testing.T) {
|
||||
var app *TestApp
|
||||
secondRan := false
|
||||
|
||||
app = Test("config/config.json").
|
||||
Swagger("Interrupt Demo", "1.0.0").
|
||||
Proj(TestProj{
|
||||
"app": {
|
||||
Proj: Project,
|
||||
Tests: ProjTest{
|
||||
"expect_demo": CtrTest{
|
||||
"string_result": {Desc: "先跑并停测", Func: func(a *Api) {
|
||||
ExpectDemoTest["string_result"].Func(a)
|
||||
app.RequestStop()
|
||||
}},
|
||||
"zzz_should_skip": {Desc: "应被跳过", Func: func(a *Api) {
|
||||
secondRan = true
|
||||
t.Error("zzz_should_skip 在 RequestStop 后不应执行")
|
||||
}},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
SetupDatabase(&app.Db)
|
||||
app.RunTests(t)
|
||||
|
||||
if secondRan {
|
||||
t.Fatal("停测后仍执行了后续接口")
|
||||
}
|
||||
_ = app.Db.RollbackTestTx()
|
||||
|
||||
// 收尾写盘(中断路径下可能未跑满 GenerateSwagger,显式补一次增量合并)
|
||||
if err := app.GenerateSwagger("Interrupt Demo", "1.0.0", app.Config.GetString("tpt")); err != nil {
|
||||
t.Fatalf("GenerateSwagger: %v", err)
|
||||
}
|
||||
|
||||
specPath := filepath.Join(app.Config.GetString("tpt"), "swagger", "app", "api-spec.json")
|
||||
data, err := os.ReadFile(specPath)
|
||||
if err != nil {
|
||||
t.Fatalf("应已生成 swagger %s: %v", specPath, err)
|
||||
}
|
||||
var spec struct {
|
||||
Endpoints []struct {
|
||||
Path string `json:"path"`
|
||||
Cases json.RawMessage `json:"cases"`
|
||||
} `json:"endpoints"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &spec); err != nil {
|
||||
t.Fatalf("解析 api-spec 失败: %v", err)
|
||||
}
|
||||
found := false
|
||||
for _, ep := range spec.Endpoints {
|
||||
if ep.Path != "/app/expect_demo/string_result" {
|
||||
continue
|
||||
}
|
||||
found = true
|
||||
if !strings.Contains(string(ep.Cases), `"name"`) {
|
||||
t.Fatalf("string_result 应保留测试 cases,实际: %s", string(ep.Cases))
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("swagger 中应包含已跑完的 /app/expect_demo/string_result")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -231,25 +231,6 @@ var ExpectDemoTest = CtrTest{
|
||||
}).
|
||||
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 nil
|
||||
}).
|
||||
Post("写入并校验DB-故意失败", 0, Map{"id": int64(1), "name": "sample"})
|
||||
// Verify 返回 error 会使该用例失败并写入 failReason(见覆盖率报告「未通过的接口」)
|
||||
}},
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
. "code.hoteas.com/golang/hotime"
|
||||
. "code.hoteas.com/golang/hotime/common"
|
||||
)
|
||||
|
||||
// DemoFlows 多接口业务流程联调示例(整条 Flow 共用一次测试事务,结束回滚)
|
||||
var DemoFlows = FlowTest{
|
||||
"expect_chain": {
|
||||
Group: "expect_demo",
|
||||
Desc: "expect_demo 多接口联调",
|
||||
Prep: "依次调用 string_result 与 map_result。\n步骤通过 FromCase 复用单接口验收用例的请求模板,跑通后回写该用例的响应与通过状态。",
|
||||
Func: func(f *Flow) {
|
||||
f.Step("字符串结果", "/app/expect_demo/string_result").
|
||||
FromCase("result是字符串").
|
||||
Note("校验字符串 result:期望返回「操作成功」。").
|
||||
Verify(func(a *Api) error {
|
||||
if a.Resp().GetBody().GetString("result") != "操作成功" {
|
||||
return fmt.Errorf("result 期望 操作成功")
|
||||
}
|
||||
return nil
|
||||
}).
|
||||
Get("步骤1-字符串", 0, "样本")
|
||||
|
||||
f.Step("Map结果", "/app/expect_demo/map_result").
|
||||
FromCase("result是Map-校验字段名+类型+值").
|
||||
Note("校验对象 result:至少包含 id=1,用于串联后续步骤前的结构确认。").
|
||||
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")
|
||||
}
|
||||
return nil
|
||||
}).
|
||||
Get("步骤2-Map", 0, Map{"id": int64(1), "name": "sample", "price": float64(1.0), "in_stock": true})
|
||||
},
|
||||
},
|
||||
}
|
||||
+3
-2
@@ -145,9 +145,10 @@ func testBasicCRUD(that *Context) Map {
|
||||
now := nowFunc(that)
|
||||
|
||||
insertTest := Map{"name": "Insert 插入测试 (admin表)"}
|
||||
uniq := time.Now().UnixNano()
|
||||
adminId := that.Db.Insert("admin", Map{
|
||||
"name": "测试管理员_" + fmt.Sprintf("%d", time.Now().Unix()),
|
||||
"phone": fmt.Sprintf("138%d", time.Now().Unix()%100000000),
|
||||
"name": "测试管理员_" + fmt.Sprintf("%d", uniq),
|
||||
"phone": fmt.Sprintf("139%08d", uniq%100000000),
|
||||
"state": 1,
|
||||
"password": "test123456",
|
||||
"role_id": 1,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -47,11 +47,24 @@ input,select,textarea,button{font:inherit}pre{margin:0}
|
||||
.cs{border:1px solid var(--border);border-radius:var(--r);margin-bottom:6px;overflow:hidden}
|
||||
.csh{padding:6px 10px;display:flex;align-items:center;gap:6px;cursor:pointer;font-size:12px;background:var(--bg2)}
|
||||
.csh:hover{background:var(--bg3)}.dot{display:inline-block;width:8px;height:8px;border-radius:50%;flex-shrink:0}
|
||||
.dot.ok{background:var(--green)}.dot.er{background:var(--red)}.csh .ml{margin-left:auto;color:#555;font-size:11px}
|
||||
.dot.ok{background:var(--green)}.dot.er{background:var(--red)}.csh .ml{margin-left:auto;color:#555;font-size:11px;display:flex;align-items:center;gap:6px;flex-shrink:0;max-width:55%}
|
||||
.path-link{color:var(--accent);cursor:pointer;text-decoration:none;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:280px;display:inline-block;vertical-align:bottom}
|
||||
.path-link:hover{text-decoration:underline}
|
||||
.bind-tag{font-size:10px;color:#888;padding:1px 6px;border:1px solid var(--border);border-radius:10px;white-space:nowrap;max-width:160px;overflow:hidden;text-overflow:ellipsis}
|
||||
.flow-prep{margin-top:8px;padding:8px 10px;background:var(--bg2);border:1px solid var(--border);border-radius:var(--r);color:var(--fg2);font-size:12px;line-height:1.6;white-space:pre-wrap}
|
||||
.flow-prep .lbl{display:inline-block;color:var(--accent);font-size:10px;margin-bottom:4px;letter-spacing:.5px}
|
||||
.flow-note{color:#aaa;font-size:12px;line-height:1.5;margin-bottom:8px;white-space:pre-wrap}
|
||||
.flow-note.empty{color:#555}
|
||||
.csb{display:none;border-top:1px solid var(--border);font-size:12px}.csb.o{display:block}
|
||||
.csr{display:flex}.csr>.csc{flex:1;padding:8px 10px;min-width:0}.csr>.csc+.csc{border-left:1px solid var(--border)}
|
||||
.csc h5{color:var(--accent);margin:0 0 4px;font-size:11px;letter-spacing:.5px;display:flex;align-items:center}
|
||||
.csc h5 .cp-sm{margin-left:auto}.csc h5:not(:first-child){margin-top:8px}
|
||||
.modal-mask{position:fixed;inset:0;background:rgba(0,0,0,.55);z-index:1000;display:flex;align-items:center;justify-content:center;padding:24px}
|
||||
.modal-box{background:var(--bg);border:1px solid var(--border);border-radius:8px;width:min(920px,96vw);max-height:88vh;display:flex;flex-direction:column;overflow:hidden;box-shadow:0 12px 40px rgba(0,0,0,.45)}
|
||||
.modal-hd{padding:10px 14px;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:8px;flex-shrink:0}
|
||||
.modal-hd .x{margin-left:auto;background:var(--bg3);border:1px solid var(--border);color:var(--fg2);border-radius:var(--r);cursor:pointer;padding:2px 10px;font-size:14px}
|
||||
.modal-hd .x:hover{color:var(--accent);border-color:var(--accent)}
|
||||
.modal-bd{overflow:auto;padding:12px;flex:1}
|
||||
pre.j{background:#0a0a1a;padding:6px 8px;border-radius:var(--r);font-size:12px;line-height:1.4;color:#aaa;white-space:pre-wrap;word-break:break-all;max-height:260px;overflow:auto;font-family:Consolas,Monaco,monospace}
|
||||
.resp-wrap{display:flex;gap:0}.resp-main{flex:1;min-width:0}
|
||||
.resp-main .rsp-hd{display:flex;align-items:center;margin-bottom:3px;font-size:10px;color:var(--fg2);letter-spacing:.5px}
|
||||
@@ -136,7 +149,7 @@ function epPass(e){if(!e.cases?.length)return false;return e.cases.every(x=>x.pa
|
||||
function epFail(e){if(!e.cases?.length)return e.tested;return e.cases.some(x=>!x.passed);}
|
||||
function render(){
|
||||
if(!D)return;const q=document.getElementById('q').value.toLowerCase();const T={};
|
||||
for(const e of D.endpoints){
|
||||
for(const e of D.endpoints||[]){
|
||||
if(F==='yes'&&!e.tested)continue;
|
||||
if(F==='no'&&e.tested)continue;
|
||||
if(F==='pass'&&!epPass(e))continue;
|
||||
@@ -145,13 +158,35 @@ function render(){
|
||||
if(!T[e.project])T[e.project]={};if(!T[e.project][e.ctr])T[e.project][e.ctr]=[];T[e.project][e.ctr].push(e);
|
||||
}
|
||||
let h='';
|
||||
const flows=(D.flows||[]).filter(f=>{
|
||||
if(q&&!f.name.toLowerCase().includes(q)&&!(f.desc||'').toLowerCase().includes(q))return false;
|
||||
if(F==='pass'&&!f.passed)return false;
|
||||
if(F==='fail'&&f.passed)return false;
|
||||
if(F==='no')return false;
|
||||
return true;
|
||||
});
|
||||
if(flows.length){
|
||||
const byG={};
|
||||
for(const f of flows){const g=f.group||'default';if(!byG[g])byG[g]=[];byG[g].push(f);}
|
||||
let fh='';
|
||||
for(const g of Object.keys(byG).sort()){
|
||||
let gh='';
|
||||
for(const f of byG[g]){
|
||||
const sc=f.steps?.length||0;
|
||||
const tg=f.passed?'<span class="tg tg-ok">通过</span>':'<span class="tg tg-err">失败</span>';
|
||||
gh+='<div class="ep'+(C?.flowName===f.name?' act':'')+'" data-flow="'+esc(f.name)+'" onclick="goFlow(this)"><span class="mt mt-post">FLOW</span><span class="nm" title="'+esc(f.desc||f.name)+'">'+esc(f.name)+'</span>'+tg+'<span class="cn">'+sc+'</span></div>';
|
||||
}
|
||||
fh+='<div class="l2h" onclick="tog(this)"><span class="ar o">▶</span>'+esc(g)+'<span class="cn">'+byG[g].length+'</span></div><div class="sub o">'+gh+'</div>';
|
||||
}
|
||||
h+='<div class="l1"><div class="l1h" onclick="tog(this)"><span class="ar o">▶</span>业务流程<span class="cn">'+flows.length+'</span></div><div class="sub o">'+fh+'</div></div>';
|
||||
}
|
||||
for(const p of Object.keys(T).sort()){let ph='';
|
||||
for(const c of Object.keys(T[p]).sort()){const es=T[p][c];let eh='';
|
||||
for(const e of es){const m=e.method.toLowerCase();
|
||||
let tg='';if(!e.tested)tg='<span class="tg tg-no">未测试</span>';
|
||||
else if(e.cases?.length){const pc=e.cases.filter(x=>x.passed).length;tg=pc===e.cases.length?'<span class="tg tg-ok">'+pc+'/'+e.cases.length+'</span>':'<span class="tg tg-err">'+pc+'/'+e.cases.length+'</span>';}
|
||||
const mn=e.path.split('/').pop();const lbl=e.tested&&e.summary!==mn?mn+' '+e.summary:e.summary;
|
||||
eh+='<div class="ep'+(C?.path===e.path?' act':'')+'" data-p="'+e.path+'" onclick="go(this)"><span class="mt mt-'+m+'">'+e.method+'</span><span class="nm" title="'+e.path+'">'+lbl+'</span>'+tg+'</div>';
|
||||
eh+='<div class="ep'+(C?.path===e.path&&!C?.flowName?' act':'')+'" data-p="'+e.path+'" onclick="go(this)"><span class="mt mt-'+m+'">'+e.method+'</span><span class="nm" title="'+e.path+'">'+lbl+'</span>'+tg+'</div>';
|
||||
}
|
||||
ph+='<div class="l2h" onclick="tog(this)"><span class="ar">▶</span>'+c+'<span class="cn">'+es.length+'</span></div><div class="sub">'+eh+'</div>';
|
||||
}
|
||||
@@ -162,7 +197,92 @@ function render(){
|
||||
}
|
||||
function sf(f,b){F=f;document.querySelectorAll('.sft button').forEach(x=>x.classList.remove('on'));b.classList.add('on');render();}
|
||||
function tog(e){e.querySelector('.ar').classList.toggle('o');e.nextElementSibling.classList.toggle('o');}
|
||||
function go(el){C=D.endpoints.find(e=>e.path===el.dataset.p);if(!C)return;document.querySelectorAll('.ep').forEach(e=>e.classList.remove('act'));el.classList.add('act');show();}
|
||||
function go(el){C=D.endpoints.find(e=>e.path===el.dataset.p);if(!C)return;C.flowName=undefined;document.querySelectorAll('.ep').forEach(e=>e.classList.remove('act'));el.classList.add('act');show();}
|
||||
function goFlow(el){const f=(D.flows||[]).find(x=>x.name===el.dataset.flow);if(!f)return;C={flowName:f.name,flow:f};document.querySelectorAll('.ep').forEach(e=>e.classList.remove('act'));el.classList.add('act');showFlow(f);}
|
||||
function stepCurl(s){
|
||||
const base=window.location.origin;
|
||||
let url=base+(s.path||'');
|
||||
const m=s.method||'POST';
|
||||
if(s.query){const qs=Object.keys(s.query).filter(k=>s.query[k]!==undefined&&s.query[k]!=='').map(k=>encodeURIComponent(k)+'='+encodeURIComponent(s.query[k])).join('&');if(qs)url+='?'+qs;}
|
||||
let cmd="curl -X "+m+" '"+url+"'";
|
||||
if(s.json){cmd+="\\\n -H 'Content-Type: application/json'";cmd+="\\\n -d '"+JSON.stringify(s.json).replace(/'/g,"'\\''")+"'";}
|
||||
else if(s.form){const fd=Object.keys(s.form).map(k=>"-F '"+k+"="+s.form[k]+"'").join("\\\n ");if(fd)cmd+="\\\n "+fd;}
|
||||
return cmd;
|
||||
}
|
||||
function hasReqData(s){return !!(s&&((s.query&&Object.keys(s.query).length)||s.json||(s.form&&Object.keys(s.form).length)));}
|
||||
function closeEpModal(){const m=document.getElementById('flow-ep-modal');if(m)m.remove();}
|
||||
function openEpModal(path,bindCase){
|
||||
const e=(D.endpoints||[]).find(x=>x.path===path);
|
||||
closeEpModal();
|
||||
const mask=document.createElement('div');mask.id='flow-ep-modal';mask.className='modal-mask';
|
||||
mask.onclick=ev=>{if(ev.target===mask)closeEpModal();};
|
||||
let hd='<div class="modal-hd">';
|
||||
if(e){const m=(e.method||'POST').toLowerCase();hd+='<span class="mt mt-'+m+'" style="font-size:12px;padding:2px 8px">'+esc(e.method||'')+'</span><span class="pa" style="font-size:14px">'+esc(e.path)+'</span><span class="ds">'+esc(e.summary||'')+'</span><span class="case-cnt">只读详情</span>';}
|
||||
else{hd+='<span class="pa" style="font-size:14px">'+esc(path)+'</span><span class="ds">未找到接口定义</span>';}
|
||||
hd+='<button class="x" onclick="closeEpModal()">关闭</button></div>';
|
||||
let bd='<div class="modal-bd">';
|
||||
if(!e){bd+='<div style="color:#555;padding:20px">swagger 中无此接口</div>';}
|
||||
else{
|
||||
let view=e;
|
||||
if(bindCase&&e.cases?.length){
|
||||
const idx=e.cases.findIndex(c=>c.name===bindCase);
|
||||
if(idx>=0){view=Object.assign({},e,{cases:[e.cases[idx]].concat(e.cases.filter((_,i)=>i!==idx))});}
|
||||
}
|
||||
bd+=cases(view);
|
||||
}
|
||||
bd+='</div>';
|
||||
mask.innerHTML='<div class="modal-box" onclick="event.stopPropagation()">'+hd+bd+'</div>';
|
||||
document.body.appendChild(mask);
|
||||
}
|
||||
document.addEventListener('keydown',e=>{if(e.key==='Escape')closeEpModal();});
|
||||
function showFlow(f){
|
||||
let h='<div class="ct-hd"><div class="ehd"><span class="mt mt-post" style="font-size:13px;padding:3px 8px">FLOW</span><span class="pa">'+esc(f.name)+'</span>';
|
||||
if(f.desc)h+='<span class="ds">'+esc(f.desc)+'</span>';
|
||||
h+='<span class="'+(f.passed?'tg tg-ok':'tg tg-err')+'">'+(f.passed?'通过':'失败')+'</span>';
|
||||
h+='<span class="case-cnt">'+(f.steps?.length||0)+'个步骤</span></div>';
|
||||
if(f.prep)h+='<div class="flow-prep"><div class="lbl">业务说明</div>'+esc(f.prep)+'</div>';
|
||||
h+='</div>';
|
||||
h+='<div class="flow-steps" style="padding:12px;overflow:auto;flex:1">';
|
||||
if(!f.steps?.length){h+='<div style="color:#555;padding:20px">暂无步骤</div>';}
|
||||
else{for(let i=0;i<f.steps.length;i++){const s=f.steps[i];
|
||||
const m=(s.method||'POST').toLowerCase();
|
||||
const curlId='fc'+i;const respId='fr'+i;
|
||||
const failCls=(!s.passed)?' flow-step-fail':'';
|
||||
h+='<div class="cs'+failCls+'" data-step="'+i+'"><div class="csh" onclick="flowStepToggle(this)"><span class="ar o">▶</span><span class="dot '+(s.passed?'ok':'er')+'"></span><span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap">'+esc((s.index||i+1)+'. '+(s.name||s.caseName||''))+'</span>';
|
||||
h+='<span class="ml"><span class="mt mt-'+m+'" style="font-size:10px;padding:1px 5px">'+(s.method||'')+'</span>';
|
||||
if(s.path)h+='<a class="path-link" title="'+esc(s.path)+'" href="javascript:void(0)" onclick="event.stopPropagation();openEpModal(\''+esc(s.path)+'\''+(s.bindCase?',\''+esc(s.bindCase)+'\'':'')+')">'+esc(s.path)+'</a>';
|
||||
h+='</span></div>';
|
||||
h+='<div class="csb o"><div class="csr">';
|
||||
h+='<div class="csc">';
|
||||
if(s.bindCase)h+='<h5>关联验收用例</h5><div class="flow-note"><a class="path-link" href="javascript:void(0)" onclick="event.stopPropagation();openEpModal(\''+esc(s.path||'')+'\',\''+esc(s.bindCase)+'\')">'+esc(s.bindCase)+'</a></div>';
|
||||
h+='<h5>备注</h5>';
|
||||
if(s.note)h+='<div class="flow-note">'+esc(s.note)+'</div>';
|
||||
else h+='<div class="flow-note empty">无备注</div>';
|
||||
if(!s.passed&&s.failReason)h+='<div class="verify-err"><span class="verify-label">失败原因</span>'+esc(s.failReason)+'</div>';
|
||||
else if(s.verifyError)h+='<div class="verify-err"><span class="verify-label">Verify</span>'+esc(s.verifyError)+'</div>';
|
||||
h+='<h5>请求<button class="cp-sm" onclick="event.stopPropagation();copyEl(\''+curlId+'\')">复制</button></h5>';
|
||||
h+='<div class="curl" id="'+curlId+'">'+esc(stepCurl(s))+'</div>';
|
||||
if(hasReqData(s)){
|
||||
if(s.query)h+='<h5>Query</h5><pre class="j">'+fj(s.query)+'</pre>';
|
||||
if(s.json)h+='<h5>JSON</h5><pre class="j">'+fj(s.json)+'</pre>';
|
||||
if(s.form)h+='<h5>Form</h5><pre class="j">'+fj(s.form)+'</pre>';
|
||||
}else{h+='<div style="color:#555;font-size:11px;margin-top:6px">无请求参数</div>';}
|
||||
h+='</div>';
|
||||
h+='<div class="csc"><h5>响应<span class="'+(s.passed?'tg tg-ok':'tg tg-err')+'" style="margin-left:6px">'+(s.passed?'通过':'失败')+'</span><button class="cp-sm" onclick="event.stopPropagation();copyEl(\''+respId+'\')">复制</button></h5>';
|
||||
if(s.response)h+='<pre class="j" id="'+respId+'" style="max-height:320px">'+fj(s.response)+'</pre>';
|
||||
else h+='<span style="color:#555" id="'+respId+'">无响应</span>';
|
||||
h+='</div></div></div></div>';
|
||||
}}
|
||||
h+='</div>';
|
||||
document.getElementById('ct').innerHTML=h;
|
||||
const failEl=document.querySelector('#ct .flow-step-fail');
|
||||
if(failEl)failEl.scrollIntoView({block:'nearest',behavior:'smooth'});
|
||||
}
|
||||
function flowStepToggle(el){
|
||||
const bd=el.nextElementSibling;if(!bd)return;
|
||||
bd.classList.toggle('o');
|
||||
el.querySelector('.ar')?.classList.toggle('o');
|
||||
}
|
||||
|
||||
function show(){
|
||||
const e=C;if(!e)return;const m=e.method.toLowerCase();const cnt=e.cases?.length||0;
|
||||
@@ -184,6 +304,8 @@ function show(){
|
||||
let defIdx=-1;
|
||||
if(cnt>0){for(let i=e.cases.length-1;i>=0;i--){if(e.cases[i].passed){defIdx=i;break;}}if(defIdx<0)defIdx=e.cases.length-1;}
|
||||
pfSelect(defIdx>=0?defIdx:'');
|
||||
bindLiveCurl();
|
||||
refreshLiveCurl();
|
||||
}
|
||||
function rswitch(i){
|
||||
document.querySelectorAll('.rtab').forEach((t,j)=>t.classList.toggle('on',j===i));
|
||||
@@ -225,8 +347,8 @@ function cases(e){
|
||||
return h;
|
||||
}
|
||||
function accordion(el){
|
||||
const bd=el.nextElementSibling;const wasOpen=bd.classList.contains('o');
|
||||
const panel=el.closest('.rpn');
|
||||
const bd=el.nextElementSibling;if(!bd)return;const wasOpen=bd.classList.contains('o');
|
||||
const panel=el.closest('.modal-bd')||el.closest('.rpn')||el.closest('#ct')||document;
|
||||
panel.querySelectorAll('.csb').forEach(b=>b.classList.remove('o'));
|
||||
panel.querySelectorAll('.csh .ar').forEach(a=>a.classList.remove('o'));
|
||||
if(!wasOpen){bd.classList.add('o');el.querySelector('.ar').classList.add('o');}
|
||||
@@ -236,7 +358,7 @@ function respPanel(){
|
||||
let r='';
|
||||
r+='<div id="note-box" style="display:none;color:#aaa;font-size:12px;margin-bottom:8px;line-height:1.5"></div>';
|
||||
r+='<div class="row-hd" style="margin-bottom:4px"><span class="sec-title">cURL</span><button class="cp-sm" onclick="copyEl(\'curl-box\')">复制</button></div>';
|
||||
r+='<div class="curl" id="curl-box">发送请求后生成</div>';
|
||||
r+='<div class="curl" id="curl-box">根据左侧参数实时生成</div>';
|
||||
r+='<div class="row-hd" style="margin:10px 0 4px"><span class="sec-title">响应 <span id="resp-st"></span></span><button class="cp-sm" onclick="copyEl(\'resp-body\')">复制</button></div>';
|
||||
r+='<pre class="j" id="resp-body" style="min-height:60px;max-height:calc(100vh - 200px)">发送请求后显示</pre>';
|
||||
return r;
|
||||
@@ -253,15 +375,15 @@ function buildLeft(e){
|
||||
l+='<div class="pf-wrap"><button class="pf-btn" id="pf-btn" onclick="togglePfDd(event)"><span class="dot" id="pf-dot" style="display:none;flex-shrink:0"></span><span id="pf-label" style="flex:1;text-align:left;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--fg2)">选择用例...</span><span style="color:var(--fg2);font-size:10px;flex-shrink:0">▾</span></button><ul class="pf-dd" id="pf-dd"></ul></div>';
|
||||
l+='<button class="sbtn" onclick="send()" id="sbtn">发送</button>';
|
||||
l+='</div>';
|
||||
let defH='<div class="kv"><input value="Content-Type"><input id="ct-val" value="'+(defBody==='json'?'application/json':'application/x-www-form-urlencoded')+'"><button onclick="this.parentElement.remove()">×</button></div>';
|
||||
if(tk&&at==='header')defH+='<div class="kv" data-auth="1"><input value="Authorization"><input value="'+esc(tk)+'"><button onclick="this.parentElement.remove()">×</button></div>';
|
||||
if(tk&&at==='cookie')defH+='<div class="kv" data-auth="1"><input value="Cookie"><input value="HOTIME='+esc(tk)+'"><button onclick="this.parentElement.remove()">×</button></div>';
|
||||
let defH='<div class="kv"><input value="Content-Type"><input id="ct-val" value="'+(defBody==='json'?'application/json':'application/x-www-form-urlencoded')+'"><button onclick="this.parentElement.remove();refreshLiveCurl()">×</button></div>';
|
||||
if(tk&&at==='header')defH+='<div class="kv" data-auth="1"><input value="Authorization"><input value="'+esc(tk)+'"><button onclick="this.parentElement.remove();refreshLiveCurl()">×</button></div>';
|
||||
if(tk&&at==='cookie')defH+='<div class="kv" data-auth="1"><input value="Cookie"><input value="HOTIME='+esc(tk)+'"><button onclick="this.parentElement.remove();refreshLiveCurl()">×</button></div>';
|
||||
l+='<div class="sec" style="margin-top:0"><div class="row-hd"><span class="sec-title">请求头</span><button class="addbtn" onclick="addKV(\'h-rows\')">+ 添加</button></div><div id="h-rows">'+defH+'</div></div>';
|
||||
let qDef='';
|
||||
if(tk&&at==='query')qDef='<div class="kv" data-auth="1"><input value="token"><input value="'+esc(tk)+'"><button onclick="this.parentElement.remove()">×</button></div>';
|
||||
qDef+='<div class="kv"><input placeholder="key"><input placeholder="value"><button onclick="this.parentElement.remove()">×</button></div>';
|
||||
if(tk&&at==='query')qDef='<div class="kv" data-auth="1"><input value="token"><input value="'+esc(tk)+'"><button onclick="this.parentElement.remove();refreshLiveCurl()">×</button></div>';
|
||||
qDef+='<div class="kv"><input placeholder="key"><input placeholder="value"><button onclick="this.parentElement.remove();refreshLiveCurl()">×</button></div>';
|
||||
l+='<div class="sec"><div class="row-hd"><span class="sec-title">Query 参数</span><button class="addbtn" onclick="addKV(\'q-rows\')">+ 添加</button></div><div id="q-rows">'+qDef+'</div></div>';
|
||||
l+='<div class="sec"><div class="row-hd"><span class="sec-title">文件上传</span><button class="addbtn" onclick="addFileRow()">+ 添加</button></div><div id="file-rows"><div class="kv"><input placeholder="字段名" value="" style="max-width:120px"><input type="file"><button onclick="this.parentElement.remove()">×</button></div></div></div>';
|
||||
l+='<div class="sec"><div class="row-hd"><span class="sec-title">文件上传</span><button class="addbtn" onclick="addFileRow();refreshLiveCurl()">+ 添加</button></div><div id="file-rows"><div class="kv"><input placeholder="字段名" value="" style="max-width:120px"><input type="file"><button onclick="this.parentElement.remove();refreshLiveCurl()">×</button></div></div></div>';
|
||||
let jsonPH='';
|
||||
if(e.params){
|
||||
const jp=e.params.filter(p=>p.in==='json');
|
||||
@@ -269,7 +391,7 @@ function buildLeft(e){
|
||||
}
|
||||
l+='<div class="sec"><div class="sec-title" style="margin-bottom:4px">请求体</div><div class="bd-acc">';
|
||||
l+='<div class="bd-hd'+(defBody==='form'?' on':'')+'" data-t="form" onclick="togBody(\'form\')"><span class="ar'+(defBody==='form'?' o':'')+'">▶</span>表单 (Form)<button class="addbtn" onclick="event.stopPropagation();setAcc(\'form\');addKV(\'f-rows\')">+ 添加</button></div>';
|
||||
l+='<div id="bt-form" class="bd-bd'+(defBody==='form'?' on':'')+'"><div id="f-rows"><div class="kv"><input placeholder="key"><input placeholder="value"><button onclick="this.parentElement.remove()">×</button></div></div></div>';
|
||||
l+='<div id="bt-form" class="bd-bd'+(defBody==='form'?' on':'')+'"><div id="f-rows"><div class="kv"><input placeholder="key"><input placeholder="value"><button onclick="this.parentElement.remove();refreshLiveCurl()">×</button></div></div></div>';
|
||||
l+='<div class="bd-hd'+(defBody==='json'?' on':'')+'" data-t="json" onclick="togBody(\'json\')"><span class="ar'+(defBody==='json'?' o':'')+'">▶</span>JSON</div>';
|
||||
l+='<div id="bt-json" class="bd-bd'+(defBody==='json'?' on':'')+'">';
|
||||
if(e.params){
|
||||
@@ -295,18 +417,18 @@ function buildLeft(e){
|
||||
|
||||
function addKV(id,k,v){
|
||||
const row=document.createElement('div');row.className='kv';
|
||||
row.innerHTML='<input placeholder="key" value="'+esc(k||'')+'"><input placeholder="value" value="'+esc(v||'')+'"><button onclick="this.parentElement.remove()">×</button>';
|
||||
row.innerHTML='<input placeholder="key" value="'+esc(k||'')+'"><input placeholder="value" value="'+esc(v||'')+'"><button onclick="this.parentElement.remove();refreshLiveCurl()">×</button>';
|
||||
document.getElementById(id).appendChild(row);
|
||||
}
|
||||
function addKVReq(id,k,v,req){
|
||||
const row=document.createElement('div');row.className='kv';
|
||||
const star=req?'<span class="req-star">*</span>':'';
|
||||
row.innerHTML=star+'<input placeholder="key" value="'+esc(k||'')+'"><input placeholder="value" value="'+esc(v||'')+'"><button onclick="this.parentElement.remove()">×</button>';
|
||||
row.innerHTML=star+'<input placeholder="key" value="'+esc(k||'')+'"><input placeholder="value" value="'+esc(v||'')+'"><button onclick="this.parentElement.remove();refreshLiveCurl()">×</button>';
|
||||
document.getElementById(id).appendChild(row);
|
||||
}
|
||||
function addFileRow(field){
|
||||
const row=document.createElement('div');row.className='kv';
|
||||
row.innerHTML='<input placeholder="字段名" value="'+esc(field||'')+'" style="max-width:120px"><input type="file"><button onclick="this.parentElement.remove()">×</button>';
|
||||
row.innerHTML='<input placeholder="字段名" value="'+esc(field||'')+'" style="max-width:120px"><input type="file"><button onclick="this.parentElement.remove();refreshLiveCurl()">×</button>';
|
||||
document.getElementById('file-rows').appendChild(row);
|
||||
}
|
||||
function setAcc(t){
|
||||
@@ -315,24 +437,27 @@ function setAcc(t){
|
||||
document.getElementById('bt-form').classList.toggle('on',t==='form');
|
||||
const ctVal=document.getElementById('ct-val');
|
||||
if(ctVal)ctVal.value=t==='json'?'application/json':'application/x-www-form-urlencoded';
|
||||
refreshLiveCurl();
|
||||
}
|
||||
function togBody(t){
|
||||
const cur=document.getElementById(t==='json'?'bt-json':'bt-form').classList.contains('on');
|
||||
setAcc(cur?(t==='json'?'form':'json'):t);
|
||||
}
|
||||
function autoH(el){el.style.height='36px';el.style.height=Math.min(el.scrollHeight,window.innerHeight*0.4)+'px';}
|
||||
function autoH(el){el.style.height='36px';el.style.height=Math.min(el.scrollHeight,window.innerHeight*0.4)+'px';refreshLiveCurl();}
|
||||
function syncAuth(){
|
||||
const tk=document.getElementById('tk').value;const at=document.getElementById('at').value;
|
||||
const hr=document.getElementById('h-rows');const qr=document.getElementById('q-rows');
|
||||
if(!hr||!qr)return;
|
||||
if(!hr||!qr){refreshLiveCurl();return;}
|
||||
hr.querySelectorAll('[data-auth]').forEach(e=>e.remove());
|
||||
qr.querySelectorAll('[data-auth]').forEach(e=>e.remove());
|
||||
if(!tk)return;
|
||||
function mkAuth(k,v,parent){const d=document.createElement('div');d.className='kv';d.dataset.auth='1';d.innerHTML='<input value="'+esc(k)+'"><input value="'+esc(v)+'"><button onclick="this.parentElement.remove()">×</button>';parent.insertBefore(d,parent.querySelector('.kv:last-child'));}
|
||||
if(tk){
|
||||
function mkAuth(k,v,parent){const d=document.createElement('div');d.className='kv';d.dataset.auth='1';d.innerHTML='<input value="'+esc(k)+'"><input value="'+esc(v)+'"><button onclick="this.parentElement.remove();refreshLiveCurl()">×</button>';parent.insertBefore(d,parent.querySelector('.kv:last-child'));}
|
||||
if(at==='header')mkAuth('Authorization',tk,hr);
|
||||
if(at==='cookie')mkAuth('Cookie','HOTIME='+tk,hr);
|
||||
if(at==='query')mkAuth('token',tk,qr);
|
||||
}
|
||||
refreshLiveCurl();
|
||||
}
|
||||
|
||||
function buildPfList(e){
|
||||
const dd=document.getElementById('pf-dd');if(!dd)return;
|
||||
@@ -449,6 +574,7 @@ function pfFill(v){
|
||||
document.getElementById('file-rows').innerHTML='';
|
||||
if(c.hasFile&&c.fileField)addFileRow(c.fileField);
|
||||
addFileRow();syncAuth();
|
||||
refreshLiveCurl();
|
||||
}
|
||||
|
||||
function getKV(id){
|
||||
@@ -457,6 +583,52 @@ function getKV(id){
|
||||
return Object.keys(obj).length?obj:null;
|
||||
}
|
||||
|
||||
function buildLiveCurl(){
|
||||
if(!C)return '';
|
||||
let url=C.path;
|
||||
const qp=getKV('q-rows');
|
||||
if(qp){const ps=new URLSearchParams();for(const[k,v]of Object.entries(qp))ps.set(k,v);url+='?'+ps.toString();}
|
||||
const token=document.getElementById('tk')?.value||'';
|
||||
const authType=document.getElementById('at')?.value||'none';
|
||||
const headers={};
|
||||
const ch=getKV('h-rows');if(ch)for(const[k,v]of Object.entries(ch)){
|
||||
if(k.toLowerCase()==='authorization'&&token&&authType==='header')continue;
|
||||
if(k.toLowerCase()==='cookie'&&token&&authType==='cookie')continue;
|
||||
headers[k]=v;
|
||||
}
|
||||
if(token&&authType==='header')headers['Authorization']=token;
|
||||
const fileEls=document.querySelectorAll('#file-rows .kv');
|
||||
const files=[];fileEls.forEach(r=>{const fnEl=r.querySelector('input:not([type="file"])');const fi=r.querySelector('input[type="file"]');if(!fnEl||!fi)return;const fn=fnEl.value.trim();if(!fn)return;if(fi.files.length)files.push({field:fn,name:fi.files[0].name});else files.push({field:fn,name:'filename'});});
|
||||
const isJson=document.getElementById('bt-json')?.classList.contains('on');
|
||||
const bodyEl=document.getElementById('xb');const formKV=getKV('f-rows');let curlParts='';
|
||||
if(files.length){
|
||||
for(const f of files)curlParts+=' -F "'+f.field+'=@'+f.name+'"';
|
||||
if(!isJson&&formKV)for(const[k,v]of Object.entries(formKV))curlParts+=' -F "'+k+'='+v+'"';
|
||||
}else if(isJson){
|
||||
const b=bodyEl?.value?.trim();
|
||||
if(b&&C.method!=='GET')curlParts=" -d '"+b.replace(/'/g,"'\\''")+"'";
|
||||
}else if(formKV){
|
||||
for(const[k,v]of Object.entries(formKV))curlParts+=' -F "'+k+'='+v+'"';
|
||||
}
|
||||
let curl='curl -X '+C.method;
|
||||
for(const[k,v]of Object.entries(headers)){if(k&&v)curl+=" -H '"+k+": "+v+"'";}
|
||||
if(token&&authType==='cookie')curl+=" --cookie 'HOTIME="+token+"'";
|
||||
curl+=curlParts+' '+location.origin+url;
|
||||
return curl;
|
||||
}
|
||||
function refreshLiveCurl(){
|
||||
const curlBox=document.getElementById('curl-box');
|
||||
if(!curlBox||!C)return;
|
||||
curlBox.textContent=buildLiveCurl();
|
||||
}
|
||||
function bindLiveCurl(){
|
||||
const left=document.querySelector('#ct .split-l');
|
||||
if(!left||left.dataset.liveCurl==='1')return;
|
||||
left.dataset.liveCurl='1';
|
||||
left.addEventListener('input',refreshLiveCurl);
|
||||
left.addEventListener('change',refreshLiveCurl);
|
||||
}
|
||||
|
||||
async function send(){
|
||||
if(!C)return;const btn=document.getElementById('sbtn');btn.disabled=true;btn.textContent='请求中...';
|
||||
let url=C.path;
|
||||
@@ -476,25 +648,18 @@ async function send(){
|
||||
const fileEls=document.querySelectorAll('#file-rows .kv');
|
||||
const files=[];fileEls.forEach(r=>{const fnEl=r.querySelector('input:not([type="file"])');const fi=r.querySelector('input[type="file"]');if(!fnEl||!fi)return;const fn=fnEl.value.trim();if(fn&&fi.files.length)files.push({field:fn,file:fi.files[0]});});
|
||||
const isJson=document.getElementById('bt-json')?.classList.contains('on');
|
||||
const bodyEl=document.getElementById('xb');const formKV=getKV('f-rows');let curlParts='';
|
||||
const bodyEl=document.getElementById('xb');const formKV=getKV('f-rows');
|
||||
if(files.length){
|
||||
const fd=new FormData();for(const f of files)fd.append(f.field,f.file);
|
||||
if(!isJson&&formKV)for(const[k,v]of Object.entries(formKV))fd.append(k,v);
|
||||
opts.body=fd;delete opts.headers['Content-Type'];
|
||||
for(const f of files)curlParts+=' -F "'+f.field+'=@'+f.file.name+'"';
|
||||
if(!isJson&&formKV)for(const[k,v]of Object.entries(formKV))curlParts+=' -F "'+k+'='+v+'"';
|
||||
}else if(isJson){
|
||||
const b=bodyEl?.value?.trim();
|
||||
if(b&&C.method!=='GET'){opts.body=b;curlParts=" -d '"+b+"'";}
|
||||
if(b&&C.method!=='GET')opts.body=b;
|
||||
}else if(formKV){
|
||||
const fd=new FormData();for(const[k,v]of Object.entries(formKV))fd.append(k,v);opts.body=fd;delete opts.headers['Content-Type'];
|
||||
for(const[k,v]of Object.entries(formKV))curlParts+=' -F "'+k+'='+v+'"';
|
||||
}
|
||||
let curl='curl -X '+C.method;
|
||||
for(const[k,v]of Object.entries(opts.headers)){if(k&&v)curl+=" -H '"+k+": "+v+"'";}
|
||||
if(token&&authType==='cookie')curl+=" --cookie 'HOTIME="+token+"'";
|
||||
curl+=curlParts+' '+location.origin+url;
|
||||
const curlBox=document.getElementById('curl-box');if(curlBox)curlBox.textContent=curl;
|
||||
refreshLiveCurl();
|
||||
rswitch(1);
|
||||
const t0=Date.now();
|
||||
try{
|
||||
|
||||
@@ -49,6 +49,13 @@ type Api struct {
|
||||
t *testing.T
|
||||
session Map
|
||||
lastResp *ApiResponse
|
||||
flowName string
|
||||
flowGroup string
|
||||
flowDesc string
|
||||
flowPrep string
|
||||
flowStep string
|
||||
flowStepIndex int
|
||||
flowParent *Flow // 业务流程内执行时回写 lastResp
|
||||
}
|
||||
|
||||
// WithSession 返回一个携带 session 的新 Api 实例
|
||||
@@ -58,6 +65,13 @@ func (a *Api) WithSession(s Map) *Api {
|
||||
path: a.path,
|
||||
t: a.t,
|
||||
session: s,
|
||||
flowName: a.flowName,
|
||||
flowGroup: a.flowGroup,
|
||||
flowDesc: a.flowDesc,
|
||||
flowPrep: a.flowPrep,
|
||||
flowStep: a.flowStep,
|
||||
flowStepIndex: a.flowStepIndex,
|
||||
flowParent: a.flowParent,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,6 +82,13 @@ func (a *Api) AtPath(path string) *Api {
|
||||
path: path,
|
||||
t: a.t,
|
||||
session: a.session,
|
||||
flowName: a.flowName,
|
||||
flowGroup: a.flowGroup,
|
||||
flowDesc: a.flowDesc,
|
||||
flowPrep: a.flowPrep,
|
||||
flowStep: a.flowStep,
|
||||
flowStepIndex: a.flowStepIndex,
|
||||
flowParent: a.flowParent,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,10 +104,25 @@ func (a *Api) RunSub(desc string, fn func(a *Api)) {
|
||||
path: a.path,
|
||||
t: t,
|
||||
session: a.session,
|
||||
flowName: a.flowName,
|
||||
flowGroup: a.flowGroup,
|
||||
flowDesc: a.flowDesc,
|
||||
flowPrep: a.flowPrep,
|
||||
flowStep: a.flowStep,
|
||||
flowStepIndex: a.flowStepIndex,
|
||||
flowParent: a.flowParent,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// FromCase 绑定单接口已有验收用例:加载其请求模板;跑通后回写该 case 的响应
|
||||
func (a *Api) FromCase(caseName string) *ApiCase {
|
||||
c := a.newCase()
|
||||
c.bindCase = caseName
|
||||
c.applyCaseTemplate(a.app.findCaseTemplate(a.path, caseName))
|
||||
return c
|
||||
}
|
||||
|
||||
// JSON 设置 JSON body,返回 ApiCase 构建器
|
||||
func (a *Api) JSON(body interface{}) *ApiCase {
|
||||
c := a.newCase()
|
||||
@@ -184,6 +220,34 @@ type ApiCase struct {
|
||||
fileContent []byte
|
||||
note string
|
||||
verifyFn func(a *Api) error
|
||||
bindCase string // 绑定的单接口验收用例名(FromCase)
|
||||
}
|
||||
|
||||
// FromCase 在已有 ApiCase 上绑定验收用例名(若尚未加载模板则加载)
|
||||
func (c *ApiCase) FromCase(caseName string) *ApiCase {
|
||||
c.bindCase = caseName
|
||||
if c.query == nil && c.jsonBody == nil && c.formBody == nil {
|
||||
c.applyCaseTemplate(c.api.app.findCaseTemplate(c.api.path, caseName))
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *ApiCase) applyCaseTemplate(tmpl *caseTemplate) {
|
||||
if tmpl == nil {
|
||||
return
|
||||
}
|
||||
if c.query == nil && tmpl.Query != nil {
|
||||
c.query = tmpl.Query
|
||||
}
|
||||
if c.jsonBody == nil && tmpl.Json != nil {
|
||||
c.jsonBody = tmpl.Json
|
||||
}
|
||||
if c.formBody == nil && tmpl.Form != nil {
|
||||
c.formBody = tmpl.Form
|
||||
}
|
||||
if c.note == "" && tmpl.Note != "" {
|
||||
c.note = tmpl.Note
|
||||
}
|
||||
}
|
||||
|
||||
// JSON 叠加 JSON body
|
||||
@@ -303,6 +367,13 @@ func (c *ApiCase) execute(method, desc string, expectStatus int, expectArgs ...i
|
||||
Note: c.note,
|
||||
VerifyError: verifyError,
|
||||
FailReason: failReason,
|
||||
FlowName: c.api.flowName,
|
||||
FlowGroup: c.api.flowGroup,
|
||||
FlowDesc: c.api.flowDesc,
|
||||
FlowPrep: c.api.flowPrep,
|
||||
FlowStep: c.api.flowStep,
|
||||
FlowStepIndex: c.api.flowStepIndex,
|
||||
BindCase: c.bindCase,
|
||||
}
|
||||
if c.query != nil {
|
||||
record.Query = c.query
|
||||
@@ -336,6 +407,9 @@ func (c *ApiCase) execute(method, desc string, expectStatus int, expectArgs ...i
|
||||
}
|
||||
record.ExpectResult = expectResp
|
||||
c.api.app.collector.Add(record)
|
||||
if c.bindCase != "" && c.api.flowName != "" {
|
||||
c.api.app.syncBoundCaseToEndpoint(record)
|
||||
}
|
||||
}()
|
||||
|
||||
c.api.app.Db.ResetTestSQLErrorCount()
|
||||
@@ -390,6 +464,9 @@ func (c *ApiCase) execute(method, desc string, expectStatus int, expectArgs ...i
|
||||
}
|
||||
|
||||
c.api.lastResp = resp
|
||||
if c.api.flowParent != nil {
|
||||
c.api.flowParent.SetLastResp(resp)
|
||||
}
|
||||
if passed && c.verifyFn != nil {
|
||||
if err := c.verifyFn(c.api); err != nil {
|
||||
t.Errorf("Verify 校验失败: %s", err.Error())
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
package hotime
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
. "code.hoteas.com/golang/hotime/common"
|
||||
. "code.hoteas.com/golang/hotime/db"
|
||||
)
|
||||
|
||||
// FlowTest 业务流程测试集合(流程名 → 定义)
|
||||
type FlowTest map[string]FlowDef
|
||||
|
||||
// FlowDef 单条业务流程定义
|
||||
type FlowDef struct {
|
||||
Group string // 左侧分组,如 "user" / "expect_demo"
|
||||
Desc string // 流程短标题(右侧标题)
|
||||
Prep string // 业务说明与前置/数据准备(右侧描述)
|
||||
Func func(f *Flow)
|
||||
}
|
||||
|
||||
// Flow 业务流程测试入口,整条流程共用一次测试事务
|
||||
type Flow struct {
|
||||
app *TestApp
|
||||
t *testing.T
|
||||
name string
|
||||
group string
|
||||
desc string
|
||||
prep string
|
||||
session Map
|
||||
stepIdx int
|
||||
lastResp *ApiResponse
|
||||
}
|
||||
|
||||
// WithSession 返回携带 session 的 Flow
|
||||
func (f *Flow) WithSession(s Map) *Flow {
|
||||
return &Flow{
|
||||
app: f.app,
|
||||
t: f.t,
|
||||
name: f.name,
|
||||
group: f.group,
|
||||
desc: f.desc,
|
||||
prep: f.prep,
|
||||
session: s,
|
||||
stepIdx: f.stepIdx,
|
||||
}
|
||||
}
|
||||
|
||||
// Step 开始流程中的一步,返回指向指定路径的 Api(记录会归入 flows)
|
||||
func (f *Flow) Step(stepName, path string) *Api {
|
||||
f.stepIdx++
|
||||
return &Api{
|
||||
app: f.app,
|
||||
path: path,
|
||||
t: f.t,
|
||||
session: f.session,
|
||||
flowName: f.name,
|
||||
flowGroup: f.group,
|
||||
flowDesc: f.desc,
|
||||
flowPrep: f.prep,
|
||||
flowStep: stepName,
|
||||
flowStepIndex: f.stepIdx,
|
||||
flowParent: f,
|
||||
}
|
||||
}
|
||||
|
||||
// AtPath 在当前流程上下文中切换路径(不增加步骤序号)
|
||||
func (f *Flow) AtPath(path string) *Api {
|
||||
return &Api{
|
||||
app: f.app,
|
||||
path: path,
|
||||
t: f.t,
|
||||
session: f.session,
|
||||
flowName: f.name,
|
||||
flowGroup: f.group,
|
||||
flowDesc: f.desc,
|
||||
flowPrep: f.prep,
|
||||
flowStep: "",
|
||||
flowStepIndex: f.stepIdx,
|
||||
flowParent: f,
|
||||
}
|
||||
}
|
||||
|
||||
// DB 获取测试库(流程级事务内)
|
||||
func (f *Flow) DB() *HoTimeDB {
|
||||
return f.app.DB()
|
||||
}
|
||||
|
||||
// Resp 最近一次步骤响应
|
||||
func (f *Flow) Resp() *ApiResponse {
|
||||
return f.lastResp
|
||||
}
|
||||
|
||||
// SetLastResp 由框架在步骤执行后更新
|
||||
func (f *Flow) SetLastResp(r *ApiResponse) {
|
||||
f.lastResp = r
|
||||
}
|
||||
+277
-55
@@ -7,8 +7,12 @@ import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"os/signal"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -18,8 +22,171 @@ import (
|
||||
// TestApp 测试应用,封装 Application 提供测试能力
|
||||
type TestApp struct {
|
||||
*Application
|
||||
configPath string
|
||||
listeners []func(*Context) bool
|
||||
workDir string
|
||||
projs TestProj
|
||||
flows FlowTest
|
||||
collector *TestCollector
|
||||
|
||||
swaggerEnabled bool
|
||||
swaggerTitle string
|
||||
swaggerVersion string
|
||||
swaggerDir string
|
||||
|
||||
stopNewTests atomic.Bool
|
||||
swaggerMu sync.Mutex
|
||||
inited bool
|
||||
initOnce sync.Once
|
||||
}
|
||||
|
||||
// Test 创建测试应用(链式入口,延迟 Init 以便先 WorkDir)
|
||||
func Test(configPath string, listeners ...func(*Context) bool) *TestApp {
|
||||
return &TestApp{
|
||||
configPath: configPath,
|
||||
listeners: listeners,
|
||||
collector: &TestCollector{},
|
||||
}
|
||||
}
|
||||
|
||||
// WorkDir 设置工作目录(在 Init 前 chdir)
|
||||
func (that *TestApp) WorkDir(dir string) *TestApp {
|
||||
that.workDir = dir
|
||||
return that
|
||||
}
|
||||
|
||||
// Swagger 启用接口级增量 Swagger 写入。
|
||||
// 参数可选:Swagger() / Swagger(title) / Swagger(title, version) / Swagger(title, version, outputDir)
|
||||
func (that *TestApp) Swagger(args ...string) *TestApp {
|
||||
that.swaggerEnabled = true
|
||||
that.swaggerTitle = "API"
|
||||
that.swaggerVersion = "1.0.0"
|
||||
if len(args) > 0 && args[0] != "" {
|
||||
that.swaggerTitle = args[0]
|
||||
}
|
||||
if len(args) > 1 && args[1] != "" {
|
||||
that.swaggerVersion = args[1]
|
||||
}
|
||||
if len(args) > 2 && args[2] != "" {
|
||||
that.swaggerDir = args[2]
|
||||
}
|
||||
return that
|
||||
}
|
||||
|
||||
// Proj 注册路由与单接口测试,并完成 Init
|
||||
func (that *TestApp) Proj(projects TestProj) *TestApp {
|
||||
that.projs = projects
|
||||
that.ensureInit()
|
||||
return that
|
||||
}
|
||||
|
||||
// Flows 注册多接口业务流程测试(可选)
|
||||
func (that *TestApp) Flows(flows FlowTest) *TestApp {
|
||||
that.flows = flows
|
||||
return that
|
||||
}
|
||||
|
||||
// Run 执行测试:注册优雅停测信号 → m.Run → 覆盖率 → Swagger 收尾,返回 exit code
|
||||
func (that *TestApp) Run(m *testing.M) int {
|
||||
that.ensureInit()
|
||||
that.installStopSignals()
|
||||
|
||||
code := m.Run()
|
||||
|
||||
if that.Application != nil {
|
||||
_ = that.Db.RollbackTestTx()
|
||||
}
|
||||
|
||||
that.PrintCoverage()
|
||||
if that.swaggerEnabled {
|
||||
dir := that.swaggerOutputDir()
|
||||
if err := that.GenerateSwagger(that.swaggerTitle, that.swaggerVersion, dir); err != nil {
|
||||
fmt.Printf("Swagger 收尾失败: %v\n", err)
|
||||
}
|
||||
}
|
||||
return code
|
||||
}
|
||||
|
||||
func (that *TestApp) swaggerOutputDir() string {
|
||||
if that.swaggerDir != "" {
|
||||
return that.swaggerDir
|
||||
}
|
||||
if that.Application != nil && that.Config != nil {
|
||||
if d := that.Config.GetString("tpt"); d != "" {
|
||||
return d
|
||||
}
|
||||
}
|
||||
return "tpt"
|
||||
}
|
||||
|
||||
func (that *TestApp) installStopSignals() {
|
||||
go func() {
|
||||
sigCh := make(chan os.Signal, 1)
|
||||
signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
|
||||
<-sigCh
|
||||
fmt.Println("\n收到停测信号,将在当前接口/流程结束后停止并回滚测试事务…")
|
||||
that.RequestStop()
|
||||
}()
|
||||
}
|
||||
|
||||
// RequestStop 模拟优雅停测(等同收到 SIGINT):不再开启新的接口/流程,当前跑完后回滚并收尾
|
||||
func (that *TestApp) RequestStop() {
|
||||
that.stopNewTests.Store(true)
|
||||
}
|
||||
|
||||
func (that *TestApp) shouldStop() bool {
|
||||
return that.stopNewTests.Load()
|
||||
}
|
||||
|
||||
func (that *TestApp) ensureInit() {
|
||||
that.initOnce.Do(func() {
|
||||
if that.workDir != "" {
|
||||
if err := os.Chdir(that.workDir); err != nil {
|
||||
fmt.Printf("WorkDir(%s) 失败: %v\n", that.workDir, err)
|
||||
}
|
||||
}
|
||||
if that.configPath == "" {
|
||||
return
|
||||
}
|
||||
|
||||
origStdout := os.Stdout
|
||||
origStderr := os.Stderr
|
||||
devNull, _ := os.Open(os.DevNull)
|
||||
os.Stdout = devNull
|
||||
os.Stderr = devNull
|
||||
|
||||
app := Init(that.configPath)
|
||||
|
||||
os.Stdout = origStdout
|
||||
os.Stderr = origStderr
|
||||
devNull.Close()
|
||||
|
||||
app.WebConnectLog = nil
|
||||
app.Log.SetOutput(io.Discard)
|
||||
|
||||
for _, lis := range that.listeners {
|
||||
app.SetConnectListener(lis)
|
||||
}
|
||||
|
||||
that.Application = app
|
||||
if that.collector == nil {
|
||||
that.collector = &TestCollector{}
|
||||
}
|
||||
|
||||
if that.projs != nil {
|
||||
router := Router{}
|
||||
for projName, projDef := range that.projs {
|
||||
router[projName] = projDef.Proj
|
||||
}
|
||||
app.SetupForTest(router)
|
||||
}
|
||||
|
||||
if app.HoTimeCache != nil {
|
||||
app.HoTimeCache.DisableDbCache()
|
||||
}
|
||||
app.Log.SetOutput(os.Stderr)
|
||||
that.inited = true
|
||||
})
|
||||
}
|
||||
|
||||
// TestResponse httptest 的原始响应封装
|
||||
@@ -75,13 +242,21 @@ type TestRecord struct {
|
||||
ExpectResult interface{}
|
||||
VerifyError string
|
||||
FailReason string
|
||||
FlowName string
|
||||
FlowGroup string
|
||||
FlowDesc string
|
||||
FlowPrep string
|
||||
FlowStep string
|
||||
FlowStepIndex int
|
||||
BindCase string // FromCase 绑定的单接口验收用例名
|
||||
}
|
||||
|
||||
// TestCollector 线程安全的测试记录收集器
|
||||
type TestCollector struct {
|
||||
mu sync.Mutex
|
||||
Records []TestRecord
|
||||
Visited map[string]bool // 被 RunTests 实际调用过的路径(不论是否产生记录)
|
||||
Visited map[string]bool
|
||||
VisitedFlows map[string]bool
|
||||
}
|
||||
|
||||
func (c *TestCollector) Add(r TestRecord) {
|
||||
@@ -90,7 +265,7 @@ func (c *TestCollector) Add(r TestRecord) {
|
||||
c.Records = append(c.Records, r)
|
||||
}
|
||||
|
||||
// MarkVisited 标记路径已被测试框架调用(由 RunTests 在进入方法级 t.Run 时调用)
|
||||
// MarkVisited 标记路径已被测试框架调用
|
||||
func (c *TestCollector) MarkVisited(path string) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
@@ -100,47 +275,19 @@ func (c *TestCollector) MarkVisited(path string) {
|
||||
c.Visited[path] = true
|
||||
}
|
||||
|
||||
// NewTestApp 创建测试应用实例
|
||||
// configPath: 配置文件路径(如 "../config/config.json")
|
||||
// projects: 项目定义(路由 + 测试)
|
||||
// listeners: 可选的请求拦截器(与 SetConnectListener 相同)
|
||||
// MarkFlowVisited 标记业务流程已运行
|
||||
func (c *TestCollector) MarkFlowVisited(name string) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.VisitedFlows == nil {
|
||||
c.VisitedFlows = map[string]bool{}
|
||||
}
|
||||
c.VisitedFlows[name] = true
|
||||
}
|
||||
|
||||
// NewTestApp 立即 Init 的便捷构造(内部委托 Test().Proj())
|
||||
func NewTestApp(configPath string, projects TestProj, listeners ...func(*Context) bool) *TestApp {
|
||||
origStdout := os.Stdout
|
||||
origStderr := os.Stderr
|
||||
devNull, _ := os.Open(os.DevNull)
|
||||
os.Stdout = devNull
|
||||
os.Stderr = devNull
|
||||
|
||||
app := Init(configPath)
|
||||
|
||||
os.Stdout = origStdout
|
||||
os.Stderr = origStderr
|
||||
devNull.Close()
|
||||
|
||||
app.WebConnectLog = nil
|
||||
app.Log.SetOutput(io.Discard)
|
||||
|
||||
for _, lis := range listeners {
|
||||
app.SetConnectListener(lis)
|
||||
}
|
||||
|
||||
router := Router{}
|
||||
for projName, projDef := range projects {
|
||||
router[projName] = projDef.Proj
|
||||
}
|
||||
app.SetupForTest(router)
|
||||
|
||||
if app.HoTimeCache != nil {
|
||||
app.HoTimeCache.DisableDbCache()
|
||||
}
|
||||
|
||||
app.Log.SetOutput(os.Stderr)
|
||||
|
||||
return &TestApp{
|
||||
Application: app,
|
||||
projs: projects,
|
||||
collector: &TestCollector{},
|
||||
}
|
||||
return Test(configPath, listeners...).Proj(projects)
|
||||
}
|
||||
|
||||
// SetupForTest 初始化路由(复用 Run 的路由注册逻辑,但不启动 HTTP 服务)
|
||||
@@ -199,19 +346,33 @@ func (that *TestApp) TestRequest(method, path string, body *http.Request) TestRe
|
||||
}
|
||||
}
|
||||
|
||||
// RunTests 运行所有注册的测试,每个方法级别使用事务隔离
|
||||
// RunTests 运行所有注册的测试;方法级事务隔离;另运行 Flows
|
||||
func (that *TestApp) RunTests(t *testing.T) {
|
||||
for projName, projDef := range that.projs {
|
||||
projName := projName
|
||||
projDef := projDef
|
||||
that.ensureInit()
|
||||
|
||||
projNames := sortedKeys(that.projs)
|
||||
for _, projName := range projNames {
|
||||
projDef := that.projs[projName]
|
||||
if that.shouldStop() {
|
||||
t.Skip("收到停测信号,跳过剩余测试")
|
||||
return
|
||||
}
|
||||
t.Run(projName, func(t *testing.T) {
|
||||
for ctrName, ctrTest := range projDef.Tests {
|
||||
ctrName := ctrName
|
||||
ctrTest := ctrTest
|
||||
ctrNames := sortedKeys(projDef.Tests)
|
||||
for _, ctrName := range ctrNames {
|
||||
ctrTest := projDef.Tests[ctrName]
|
||||
if that.shouldStop() {
|
||||
t.Skip("收到停测信号,跳过剩余测试")
|
||||
return
|
||||
}
|
||||
t.Run(ctrName, func(t *testing.T) {
|
||||
for methodName, apiTest := range ctrTest {
|
||||
methodName := methodName
|
||||
apiTest := apiTest
|
||||
methodNames := sortedKeys(ctrTest)
|
||||
for _, methodName := range methodNames {
|
||||
apiTest := ctrTest[methodName]
|
||||
if that.shouldStop() {
|
||||
t.Skip("收到停测信号,跳过剩余接口")
|
||||
return
|
||||
}
|
||||
t.Run(methodName, func(t *testing.T) {
|
||||
path := "/" + projName + "/" + ctrName + "/" + methodName
|
||||
that.collector.MarkVisited(path)
|
||||
@@ -235,12 +396,73 @@ func (that *TestApp) RunTests(t *testing.T) {
|
||||
api.t = t
|
||||
apiTest.Func(api)
|
||||
})
|
||||
|
||||
if that.swaggerEnabled {
|
||||
that.flushEndpointSwagger(projName, path)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if len(that.flows) == 0 {
|
||||
return
|
||||
}
|
||||
t.Run("flows", func(t *testing.T) {
|
||||
flowNames := sortedKeys(that.flows)
|
||||
for _, flowName := range flowNames {
|
||||
flowDef := that.flows[flowName]
|
||||
if that.shouldStop() {
|
||||
t.Skip("收到停测信号,跳过剩余流程")
|
||||
return
|
||||
}
|
||||
t.Run(flowName, func(t *testing.T) {
|
||||
that.collector.MarkFlowVisited(flowName)
|
||||
|
||||
err := that.Db.BeginTestTx()
|
||||
if err != nil {
|
||||
t.Fatal("开启流程测试事务失败:", err)
|
||||
}
|
||||
defer that.Db.RollbackTestTx()
|
||||
|
||||
methodBuf := &bytes.Buffer{}
|
||||
that.Db.SetTestLogBuffer(methodBuf)
|
||||
defer that.Db.SetTestLogBuffer(nil)
|
||||
|
||||
flow := &Flow{
|
||||
app: that,
|
||||
t: t,
|
||||
name: flowName,
|
||||
group: flowDef.Group,
|
||||
desc: flowDef.Desc,
|
||||
prep: flowDef.Prep,
|
||||
}
|
||||
desc := flowDef.Desc
|
||||
if desc == "" {
|
||||
desc = flowName
|
||||
}
|
||||
t.Run(desc, func(t *testing.T) {
|
||||
flow.t = t
|
||||
flowDef.Func(flow)
|
||||
})
|
||||
|
||||
if that.swaggerEnabled {
|
||||
that.flushFlowSwagger(flowName)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func sortedKeys[M ~map[string]V, V any](m M) []string {
|
||||
keys := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return keys
|
||||
}
|
||||
|
||||
// PrintCoverage 输出 API 测试覆盖率报告
|
||||
@@ -250,6 +472,9 @@ func (that *TestApp) PrintCoverage() CoverageReport {
|
||||
pathRecords := map[string][]TestRecord{}
|
||||
that.collector.mu.Lock()
|
||||
for _, r := range that.collector.Records {
|
||||
if r.FlowName != "" {
|
||||
continue
|
||||
}
|
||||
pathRecords[r.Path] = append(pathRecords[r.Path], r)
|
||||
}
|
||||
visited := that.collector.Visited
|
||||
@@ -296,7 +521,6 @@ func (that *TestApp) PrintCoverage() CoverageReport {
|
||||
}
|
||||
}
|
||||
|
||||
// 汇总全部用例统计
|
||||
for _, d := range report.Details {
|
||||
report.TotalCases += d.CaseCount
|
||||
report.PassedCases += d.Passed
|
||||
@@ -306,7 +530,6 @@ func (that *TestApp) PrintCoverage() CoverageReport {
|
||||
ranCount := len(visited)
|
||||
isPartialRun := ranCount > 0 && ranCount < report.Covered
|
||||
|
||||
// 分拣本次运行中通过/失败的接口
|
||||
var passedDetails, failedDetails []MethodCoverage
|
||||
for _, d := range report.Details {
|
||||
if !d.HasTest || d.CaseCount == 0 {
|
||||
@@ -322,7 +545,6 @@ func (that *TestApp) PrintCoverage() CoverageReport {
|
||||
}
|
||||
}
|
||||
|
||||
// 计算本次运行的用例统计(部分运行时只统计实际运行的接口)
|
||||
ranInterfaces := len(failedDetails) + len(passedDetails)
|
||||
var ranTotalCases, ranPassedCases, ranFailedCases int
|
||||
if isPartialRun {
|
||||
|
||||
+766
-90
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user