From 2b408230a6bba918e6e7ca08be67487a660cc8c3 Mon Sep 17 00:00:00 2001 From: huang Date: Tue, 31 Mar 2026 10:00:01 +0800 Subject: [PATCH] skill --- .claude/skills/hurl-test/SKILL.md | 777 +++++++++++++++++++++++ .gitignore | 10 +- .opencode/skills/hurl-test/SKILL.md | 952 ++++++++++++++++++++++++++++ 3 files changed, 1738 insertions(+), 1 deletion(-) create mode 100644 .claude/skills/hurl-test/SKILL.md create mode 100644 .opencode/skills/hurl-test/SKILL.md diff --git a/.claude/skills/hurl-test/SKILL.md b/.claude/skills/hurl-test/SKILL.md new file mode 100644 index 0000000..36882b2 --- /dev/null +++ b/.claude/skills/hurl-test/SKILL.md @@ -0,0 +1,777 @@ +--- +name: hurl-test +description: Hurl 接口测试生成器。用户描述要测试的接口或业务流程,自动探索代码、确认需求、生成完整的 .hurl 测试文件(含 DTO 驱动的字段完整性断言)。触发词:测试、hurl、写测试、接口测试。 +--- + +# Hurl 接口测试生成器 + +**用户描述要测试什么,你来读代码、问确认、生成 .hurl 文件。** + +适用于任何后端项目(Go / Python / Node / Java 等),不预设框架和目录结构。 + +--- + +## 触发条件 + +以下情况必须使用本 Skill: +- 用户说"测试 XX 接口"、"写 hurl 测试"、"给 XX 加测试" +- 用户说"测试 XX 流程"、"测试 XX 的业务逻辑" +- 用户说"验证 XX 接口的字段"、"测试接口契约" +- 用户提到 hurl、.hurl、接口测试、集成测试、冒烟测试 + +--- + +## 四阶段工作流(必须按顺序执行) + +``` +Phase 1: 探索 → Phase 2: 确认 → Phase 3: 生成 → Phase 4: 验证 +读代码搞清楚 展示给用户确认 输出 .hurl 文件 语法检查 + 试跑 +``` + +--- + +### Phase 1: 探索(Explore) + +**目标:读代码,搞清楚项目约定 + 涉及的接口 + 字段 + 依赖。** + +#### 1.1 项目画像(首次使用时必须执行,后续复用) + +首次为项目生成 Hurl 测试时,先回答以下问题(通过读代码,不要猜): + +| 问题 | 怎么找 | +|------|--------| +| **语言/框架** | 看 go.mod / package.json / requirements.txt / pom.xml | +| **路由注册在哪** | 搜索 `router`、`app.Get`、`@GetMapping`、`@app.route` 等关键词 | +| **请求/响应 schema 定义在哪** | 搜索 DTO / schema / serializer / model 目录,看 json tag 或装饰器 | +| **统一响应格式是什么** | 找 response helper 文件(如 `response.go`、`response.py`),记录 JSON 结构 | +| **认证方式是什么** | 找 auth middleware,确定是 Bearer Token / Cookie / API Key / Basic Auth | +| **登录接口是什么** | 找登录 handler,记录路径、请求体、响应中 token 的位置 | +| **分页格式是什么** | 找列表接口的响应结构,记录 items/total/page 等字段名 | +| **已有 hurl 测试吗** | 搜索 `*.hurl` 文件,复用已有的约定 | + +将画像结果**写入 `tests/hurl/.project-profile.md` 文件持久化保存**。 + +#### 画像持久化(关键机制) + +**首次使用时**:完成 1.1 探索后,将画像写入 `tests/hurl/.project-profile.md`,格式如下: + +```markdown +# 项目画像(Hurl 测试自动生成用) + + + +## 技术栈 +- 语言: Go 1.25 +- 框架: Fiber v2 +- ORM: GORM + +## 路由定义位置 +- 路由注册入口: internal/routes/routes.go +- 按模块拆分: internal/routes/{module}.go +- 路由注册函数: Register(router, doc, basePath, method, path, handler, spec) + +## Schema 定义位置 +- DTO 目录: internal/model/dto/ +- 命名规则: {module}_dto.go +- 字段标签: json / validate / description + +## 统一响应格式 +{code: int, msg: string, data: any, timestamp: string(RFC3339)} +- 成功: code=0, msg="success" +- 错误: code!=0 + +## 分页格式 +{items: [], total: int, page: int, size: int} +- 包裹在 data 字段内: $.data.items / $.data.total + +## 认证方式 +- 后台: POST /api/auth/admin-login → $.data.access_token → Authorization: Bearer {token} +- C端: JWT → Authorization: Bearer {token} + +## 默认测试账号 +- 用户名: admin +- 密码: Admin@123456 + +## 服务端口 +- 默认: 3000 +``` + +**后续使用时**:检查 `tests/hurl/.project-profile.md` 是否存在: +- **存在** → 直接读取,跳过 1.1 的探索步骤,节省时间 +- **不存在** → 执行 1.1 完整探索,然后生成此文件 +- **用户说"刷新画像"** → 删除旧文件,重新执行 1.1 + +#### 1.2 找接口定义 + +根据用户要测的模块,定位路由注册代码,提取: + +- **HTTP 方法**(GET / POST / PUT / DELETE / PATCH) +- **路由路径**(含路径参数格式,如 `/users/:id` 或 `/users/{id}`) +- **接口说明**(注释、Summary、装饰器描述) +- **是否需要认证** +- **请求 schema 类型名**(Input / Request DTO) +- **响应 schema 类型名**(Output / Response DTO) + +#### 1.3 读 schema 定义(DTO / struct / class / type) + +定位请求和响应的 schema 定义文件,提取每个字段的: + +- **字段名**:JSON 序列化后的名称(json tag / @JsonProperty / serializer field) +- **语言类型**:string / int / bool / 数组 / 嵌套对象 / 可空等 +- **是否必填**:validate tag / required 装饰器 / 非空标注 +- **是否可空**:指针类型 / Optional / nullable +- **是否参与序列化**:`json:"-"` / @JsonIgnore / exclude +- **是否 omitempty**:`json:",omitempty"` / 条件序列化 +- **字段描述**:description tag / docstring / 注释 + +#### 1.4 识别业务依赖 + +读 service / business logic 层,识别: + +- 创建操作需要哪些前置数据(如创建订单需要先有商品和用户) +- 是否有唯一性约束(如用户名不能重复) +- 是否依赖外部服务(支付网关、短信、OAuth 等) +- 业务流转逻辑(状态机、级联操作) + +--- + +### Phase 2: 确认(Clarify) + +**目标:向用户展示发现的内容,确认模糊点。不要闷头生成。** + +#### 2.1 必须展示的内容 + +``` +我梳理了相关代码,发现以下信息: + +📋 涉及接口: + - [方法] [路径] - [说明](认证: 是/否) + - ... + +📦 响应字段(基于 {SchemaName}): + - [字段名]: [类型] - [说明] + - ...(共 N 个字段,将全部生成断言) + +🔗 依赖关系: + - [创建 X 需要先创建 Y] + - ... + +⚠️ 特殊情况: + - [涉及外部服务 / 文件上传 / 特殊认证等] +``` + +#### 2.2 按需确认(只问有歧义的) + +| 场景 | 要问的 | +|------|--------| +| 流程范围不明确 | "要测到哪一步?" | +| 多种用户角色 | "用哪种身份测?" | +| 是否测异常 | "需要包含异常 case 吗?(参数校验失败、权限不足等)" | +| 是否测数据隔离 | "需要验证不同用户间数据不可见吗?" | +| 涉及第三方 | "XX 部分怎么处理?绕过 / 模拟回调 / 跳过?" | +| 前置数据来源 | "XX 依赖数据是通过 API 创建还是假设已存在?" | + +**如果用户说"越完整越好"或"都要"→ 默认全部包含,不再追问。** + +--- + +### Phase 3: 生成(Generate) + +**目标:生成完整的 .hurl 文件,字段断言基于 schema 代码,不能编造。** + +#### 3.1 文件头注释 + +```hurl +# ============================================================ +# 测试:{测试名称} +# 生成时间:{日期} +# 涉及模块:{module1, module2, ...} +# 涉及接口:{N} 个 +# 断言数量:{N} 条 +# 前置条件:{服务运行 + 必要的前置条件} +# ============================================================ +# 流程: +# 1. {步骤描述} +# 2. {步骤描述} +# ... +# ============================================================ +``` + +#### 3.2 请求生成规则 + +**认证**: + +- 根据 Phase 1 画像中的登录接口和 token 位置生成 +- token 必须通过 `[Captures]` 捕获,后续请求引用 +- 如果是 Cookie 认证,用 `[Cookies]` 或 cookie capture + +**CRUD 标准模式**: + +| 操作 | 生成要求 | +|------|---------| +| **创建(POST)** | capture 返回的 ID;唯一字段用 `{{newUuid}}` 防冲突 | +| **查询详情(GET)** | **逐字段断言**(类型 + 值,见 3.3) | +| **查询列表(GET)** | 分页结构断言 + items[0] 逐字段断言 | +| **修改(PUT/PATCH)** | 修改后**紧跟一个 GET 验证修改生效** | +| **删除(DELETE)** | 删除后**紧跟一个 GET 验证已删除** | + +**业务流程模式**: + +- 按用户描述的流程顺序编排请求 +- 上一步的输出(ID、状态等)通过 `[Captures]` 传给下一步 +- 关键步骤加中间状态验证(如创建订单后验证状态为"待支付") + +#### 3.3 schema 到断言的映射 + +读到 schema 字段后,按以下规则生成 jsonpath 断言: + +**通用类型映射(所有语言)**: + +| Schema 类型特征 | Hurl 断言 | +|----------------|-----------| +| 字符串(string / str / String) | `isString` | +| 整数(int / integer / long / Int) | `isInteger` | +| 浮点(float / double / decimal / Float) | `isNumber` | +| 布尔(bool / boolean / Boolean) | `isBoolean` | +| 数组 / 列表([] / List / Array) | `isList` | +| 嵌套对象(struct / class / dict / object) | `isObject`,并递归检查子字段 | +| 可空类型(指针 / Optional / nullable) | `exists`(不强制类型,因为可能是 null) | +| 不参与序列化(json:"-" / @JsonIgnore / exclude=True) | **跳过,不生成断言** | +| 条件序列化(omitempty / if not None) | `exists` 或不生成(取决于场景) | + +**Go 特定映射**: + +| Go 类型 | Hurl 断言 | +|---------|-----------| +| `string` | `isString` | +| `int`, `int8/16/32/64`, `uint`, `uint8/16/32/64` | `isInteger` | +| `float32`, `float64` | `isNumber` | +| `bool` | `isBoolean` | +| `[]T` | `isList` | +| `*string`, `*int`, `*uint` 等指针 | `exists` | +| `time.Time` | `isString`(通常序列化为字符串) | +| `map[string]any` | `isObject` | + +**Python 特定映射(Pydantic / Django / FastAPI)**: + +| Python 类型 | Hurl 断言 | +|------------|-----------| +| `str` | `isString` | +| `int` | `isInteger` | +| `float`, `Decimal` | `isNumber` | +| `bool` | `isBoolean` | +| `list[T]`, `List[T]` | `isList` | +| `Optional[T]`, `T | None` | `exists` | +| `dict`, `Dict` | `isObject` | +| `datetime`, `date` | `isString` | + +**TypeScript/JavaScript 特定映射**: + +| TS/JS 类型 | Hurl 断言 | +|-----------|-----------| +| `string` | `isString` | +| `number`(整数上下文) | `isInteger` | +| `number`(通用) | `isNumber` | +| `boolean` | `isBoolean` | +| `T[]`, `Array` | `isList` | +| `T \| null`, `T \| undefined` | `exists` | +| `object`, `Record<>` | `isObject` | +| `Date` | `isString` | + +**Java 特定映射**: + +| Java 类型 | Hurl 断言 | +|----------|-----------| +| `String` | `isString` | +| `Integer`, `Long`, `int`, `long` | `isInteger` | +| `Double`, `Float`, `BigDecimal` | `isNumber` | +| `Boolean`, `boolean` | `isBoolean` | +| `List` | `isList` | +| `@Nullable`, `Optional` | `exists` | +| `Map` | `isObject` | +| `LocalDateTime`, `Instant` | `isString` | + +#### 3.4 统一响应格式断言 + +根据 Phase 1 画像中发现的统一响应格式,为**每个成功响应**添加格式断言。 + +示例:如果项目的统一格式是 `{code, msg, data, timestamp}`: + +```hurl +[Asserts] +jsonpath "$.code" == 0 +jsonpath "$.msg" == "success" +jsonpath "$.timestamp" isIsoDate +``` + +示例:如果项目的格式是 `{status, message, result}`: + +```hurl +[Asserts] +jsonpath "$.status" == "ok" +jsonpath "$.message" isString +``` + +示例:如果项目无统一包装,直接返回数据: + +```hurl +[Asserts] +# 直接断言业务字段 +jsonpath "$.id" isInteger +jsonpath "$.name" isString +``` + +**不要假设响应格式,必须从代码中确认。** + +#### 3.5 分页断言 + +根据 Phase 1 画像中发现的分页结构生成。 + +示例:如果是 `{items, total, page, size}` 格式: + +```hurl +jsonpath "$.data.items" isList +jsonpath "$.data.total" isInteger +jsonpath "$.data.total" >= 1 +jsonpath "$.data.page" isInteger +jsonpath "$.data.size" isInteger +# items 内元素逐字段断言 +jsonpath "$.data.items[0].{field}" {type_assert} +``` + +示例:如果是 `{results, count, next, previous}` 格式(Django 风格): + +```hurl +jsonpath "$.results" isList +jsonpath "$.count" isInteger +jsonpath "$.count" >= 1 +# results 内元素逐字段断言 +jsonpath "$.results[0].{field}" {type_assert} +``` + +**根据实际代码调整字段名,不硬编码。** + +#### 3.6 异常 Case 模板 + +**参数校验失败**: + +```hurl +# ── 异常:参数校验失败 ── +POST {{base_url}}/{path} +Authorization: Bearer {{token}} +Content-Type: application/json +{ + "required_field": "" +} +HTTP {expected_error_status} +[Asserts] +# 断言错误响应格式(根据项目约定调整) +``` + +> HTTP 状态码根据项目实际返回确定:有的项目错误也返回 200 + 业务错误码,有的返回 400/422。 + +**未认证访问**: + +```hurl +# ── 异常:未认证访问 ── +GET {{base_url}}/{protected_path} +HTTP {expected_unauth_status} +``` + +**越权访问**(如果用户要求): + +```hurl +# ── 异常:用户 B 不能访问用户 A 的资源 ── +GET {{base_url}}/{path}/{{user_a_resource_id}} +Authorization: Bearer {{user_b_token}} +HTTP {expected_forbidden_status} +``` + +#### 3.7 特殊场景处理 + +| 场景 | 处理策略 | +|------|---------| +| **短信/邮件验证码** | 建议服务端加 test_mode 开关,固定验证码写入 env 文件;注释提醒用户 | +| **第三方支付** | 优先用项目内部支付方式(如钱包支付);如需测回调,直接 POST 回调接口模拟 | +| **OAuth 登录(微信/Google/GitHub)** | 建议服务端加 test_mode 支持直接传 openid/email;注释提醒用户 | +| **文件上传** | 用 Hurl 的 `[Multipart]` 语法 + testdata 目录下的样本文件 | +| **外部 API 依赖** | 只测参数校验和错误响应格式,不断言业务结果;注释说明依赖 | +| **WebSocket** | Hurl 不支持,注释说明跳过 | +| **异步任务结果** | 用 Hurl 的 `retry` + `retry-interval` 轮询直到状态变更 | + +异步轮询示例: + +```hurl +# 等待异步任务完成(最多重试 10 次,间隔 500ms) +GET {{base_url}}/{path}/{{task_id}} +Authorization: Bearer {{token}} +[Options] +retry: 10 +retry-interval: 500ms +HTTP 200 +[Asserts] +jsonpath "$.data.status" == "completed" +``` + +#### 3.8 文件输出 + +**目录结构**(首次使用时创建,如不存在): + +``` +tests/hurl/ +├── env/ +│ └── dev.env # 环境变量 +├── testdata/ # 测试用的样本文件 +├── flows/ # 业务流程测试 +├── modules/ # 按模块的接口测试 +│ └── {module}/ +│ └── 01-crud.hurl +├── negative/ # 异常/边界测试 +├── contract/ # 接口契约验证 +└── Makefile # 快捷命令 +``` + +文件放置规则: + +| 用户描述 | 输出路径 | +|---------|---------| +| 测试 XX 流程 / 业务流程 | `tests/hurl/flows/{flow-name}.hurl` | +| 测试 XX 模块的 CRUD / 接口 | `tests/hurl/modules/{module}/01-crud.hurl` | +| 测试异常/边界/权限 | `tests/hurl/negative/{name}.hurl` | +| 测试接口契约/字段对齐 | `tests/hurl/contract/{name}.hurl` | + +**如果项目已有 hurl 测试目录结构,沿用已有约定,不要另起炉灶。** + +#### 3.9 env 和 Makefile + +**env/dev.env**(首次创建时生成,内容基于 Phase 1 画像): + +```properties +# 服务地址 +base_url=http://localhost:{port} + +# 认证信息(根据项目实际填写) +admin_username={默认用户名} +admin_password={默认密码} + +# 测试模式变量(如果有特殊场景) +# test_sms_code=888888 +# test_openid=test_openid_001 +``` + +**Makefile**(首次创建时生成): + +```makefile +SHELL := /bin/bash +ENV ?= dev +HURL_OPTS := --variables-file env/$(ENV).env --test + +.PHONY: test test-flows test-modules test-negative report + +test: ## 运行所有测试 + hurl $(HURL_OPTS) . + +test-flows: ## 运行业务流程测试 + hurl $(HURL_OPTS) flows/ + +test-modules: ## 运行模块接口测试 + hurl $(HURL_OPTS) modules/ + +test-negative: ## 运行异常测试 + hurl $(HURL_OPTS) negative/ + +report: ## 生成 HTML 报告 + hurl $(HURL_OPTS) --report-html build/report/ . +``` + +--- + +### Phase 4: 验证(Verify) + +**目标:确保生成的 .hurl 文件语法正确、可运行。** + +#### 4.1 语法自检 + +- [ ] 每个请求之间有空行分隔 +- [ ] `[Captures]` 和 `[Asserts]` 拼写正确(大小写敏感) +- [ ] 所有 `{{变量}}` 引用都有来源(env 文件定义 或 上游 `[Captures]`) +- [ ] JSON body 无尾逗号、格式正确 +- [ ] `HTTP {status}` 在请求之后、`[Captures]` / `[Asserts]` 之前 +- [ ] 请求和断言之间没有多余空行(`HTTP` 行必须紧跟请求) +- [ ] 文件上传路径相对于 hurl 文件位置正确 + +#### 4.2 运行测试 + +```bash +hurl --variables-file tests/hurl/env/dev.env --test tests/hurl/{生成的文件} +``` + +- 服务在跑 → 执行,如有失败分析修正 +- 服务没跑 → 跳过,告知用户手动验证命令 + +--- + +## 红线规则 + +| 规则 | 说明 | +|------|------| +| **不跳过 Phase 1** | 必须读代码确认接口路径和字段,不能凭记忆或猜测 | +| **不跳过 Phase 2** | 必须向用户展示发现的接口和字段,确认后再生成 | +| **不编造字段** | 所有断言的字段名必须来自实际 schema 代码 | +| **不编造路径** | 所有接口路径必须来自实际路由代码 | +| **不遗漏字段** | schema 中每个参与序列化的字段都必须有对应断言 | +| **不硬编码 ID** | 所有依赖的 ID 通过 `[Captures]` 从上游请求获取 | +| **不假设响应格式** | 统一响应结构必须从代码中确认,不同项目格式不同 | +| **唯一值防冲突** | 创建类请求的唯一字段使用 `{{newUuid}}` 或 `{{newDate}}` | +| **自给自足** | 每个 .hurl 文件自己创建测试数据,不依赖外部数据准备 | + +--- + +## AI 助手检查清单 + +生成 .hurl 文件后自检: + +1. ✅ 文件头注释包含流程描述和前置条件 +2. ✅ 认证步骤正确 capture 了 token / cookie +3. ✅ 所有依赖数据通过 API 链式创建(自给自足) +4. ✅ 每个成功响应断言了项目的统一响应格式 +5. ✅ 查询详情接口**逐字段断言**(类型 + 值,基于 schema) +6. ✅ 分页接口断言了分页结构 + 第一条记录的字段 +7. ✅ 修改操作后紧跟 GET 验证修改生效 +8. ✅ 删除操作后紧跟 GET 验证已删除 +9. ✅ 唯一字段使用了 `{{newUuid}}` +10. ✅ `{{变量}}` 引用无悬空(都有 env 或 capture 来源) +11. ✅ 文件放在了正确的目录位置 +12. ✅ 特殊场景有明确的处理策略和注释提醒 + +--- + +## 附录:Hurl 语法速查 + +**生成 .hurl 文件时必须参照本速查,不可凭记忆编造语法。** + +### 文件结构 + +一个 .hurl 文件由多个 entry 组成,每个 entry = 请求 + 可选响应: + +``` +请求1 +响应1(可选) + +请求2 +响应2(可选) +``` + +entry 之间用空行分隔。 + +### 请求格式 + +```hurl +METHOD URL +Header1: value1 +Header2: value2 +[Options] +key: value +[Query] +param1: value1 +[Form] +field1: value1 +[Multipart] +file1: file,path/to/file; +[BasicAuth] +username: password +[Cookies] +name: value +BODY(JSON / XML / multiline string / file) +``` + +**规则**: +- Method + URL 是第一行,必须 +- Headers 紧跟 URL 之后(无 section 标记) +- Sections(`[Query]`、`[Form]`、`[Options]` 等)顺序任意 +- Body 必须在最后 +- JSON body 直接写 `{ }` 即可,自动设置 Content-Type: application/json + +### 响应格式 + +```hurl +HTTP {status_code} +Header1: expected_value1 +[Captures] +var_name: jsonpath "$.path" +[Asserts] +jsonpath "$.field" == "value" +``` + +**规则**: +- `HTTP {status}` 紧跟请求之后(中间不能有空行) +- `HTTP *` 表示不检查状态码 +- Headers 检查紧跟 HTTP 行之后 +- `[Captures]` 和 `[Asserts]` 顺序任意 + +### 变量和模板 + +```hurl +# 引用变量(从 env 文件、命令行或上游 capture 获取) +GET {{base_url}}/api/users/{{user_id}} + +# 内置函数 +POST {{base_url}}/api/users +{ + "email": "{{newUuid}}@test.com", + "created_at": "{{newDate}}" +} +``` + +可用函数: +- `{{newUuid}}` — 生成 UUID v4 +- `{{newDate}}` — 生成 RFC 3339 UTC 时间戳 + +### Capture 语法 + +```hurl +[Captures] +# JSONPath +token: jsonpath "$.data.access_token" +user_id: jsonpath "$.data.id" +first_item: jsonpath "$.items[0].name" + +# Header +location: header "Location" + +# Cookie +session: cookie "SESSIONID" + +# Body(整个响应体作为字符串) +full_body: body + +# Status code +code: status + +# 正则表达式 +csrf: regex "name=\"csrf\" value=\"([^\"]+)\"" + +# 响应时间(毫秒) +response_time: duration +``` + +### Assert 语法 + +```hurl +[Asserts] +# ── 状态码 ── +status == 200 +status >= 200 +status < 300 + +# ── JSONPath 断言 ── +jsonpath "$.name" == "Alice" # 等于 +jsonpath "$.name" != "Bob" # 不等于 +jsonpath "$.age" > 18 # 大于 +jsonpath "$.age" >= 18 # 大于等于 +jsonpath "$.count" < 100 # 小于 +jsonpath "$.items" count == 5 # 集合长度 +jsonpath "$.name" startsWith "Al" # 前缀 +jsonpath "$.name" endsWith "ce" # 后缀 +jsonpath "$.name" contains "lic" # 包含 +jsonpath "$.date" matches /\\d{4}-\\d{2}-\\d{2}/ # 正则 + +# ── 类型断言 ── +jsonpath "$.name" isString +jsonpath "$.age" isInteger +jsonpath "$.score" isFloat +jsonpath "$.count" isNumber # 整数或浮点 +jsonpath "$.active" isBoolean +jsonpath "$.items" isList +jsonpath "$.meta" isObject +jsonpath "$.id" isUuid +jsonpath "$.created_at" isIsoDate # RFC 3339 格式 +jsonpath "$.field" isEmpty # 空集合 + +# ── 存在性 ── +jsonpath "$.field" exists +jsonpath "$.field" not exists + +# ── 否定 ── +jsonpath "$.name" not contains "Bob" +jsonpath "$.status" not == "deleted" + +# ── Header 断言 ── +header "Content-Type" contains "application/json" +header "X-Request-Id" exists + +# ── 性能 ── +duration < 1000 # 响应时间(毫秒) + +# ── Body 断言 ── +body contains "Hello" +bytes count == 1024 +``` + +### Options(逐请求配置) + +```hurl +GET {{base_url}}/api/task/{{task_id}} +[Options] +retry: 10 # 最大重试次数(-1 = 无限) +retry-interval: 500ms # 重试间隔 +delay: 2s # 请求前等待 +location: true # 跟随重定向 +insecure: true # 允许不安全 SSL +verbose: true # 输出详细日志 +very-verbose: true # 输出更详细日志 +skip: true # 跳过此请求 +variable: key=value # 定义变量 +HTTP 200 +``` + +### Multipart 文件上传 + +```hurl +POST {{base_url}}/api/upload +[Multipart] +file: file,testdata/sample.xlsx; +field1: value1 +# 指定 Content-Type +file2: file,testdata/data.bin; application/octet-stream +``` + +### 运行命令 + +```bash +# 运行单个文件 +hurl --test file.hurl + +# 带变量文件 +hurl --variables-file env/dev.env --test file.hurl + +# 运行目录下所有 .hurl +hurl --test tests/hurl/ + +# 生成 HTML 报告 +hurl --test --report-html build/report/ tests/hurl/ + +# 生成 JUnit 报告(CI 用) +hurl --test --report-junit build/report.xml tests/hurl/ + +# 并行执行(--test 默认并行,同文件内串行) +hurl --test --jobs 4 tests/hurl/ + +# 指定单个变量 +hurl --variable base_url=http://localhost:3000 --test file.hurl + +# 失败后继续执行 +hurl --test --continue-on-error tests/hurl/ +``` + +### 常见错误 + +| 错误 | 原因 | 修正 | +|------|------|------| +| `HTTP 200` 和请求之间有空行 | 空行会被当作 entry 分隔符 | 删除空行,HTTP 行紧跟请求 | +| JSON body 有尾逗号 | Hurl 严格解析 JSON | 删除最后一个逗号 | +| `jsonpath` 写成 `json_path` 或 `JsonPath` | 关键字大小写敏感 | 必须小写 `jsonpath` | +| `[Captures]` 写成 `[Capture]` | 必须是复数 | `[Captures]`、`[Asserts]`、`[Options]` | +| 变量 `{{ var }}` 有空格 | 允许,但建议统一 | `{{var}}` 或 `{{ var }}` 都可以 | +| `isIsoDate` 用在非 RFC 3339 格式 | 只认 `YYYY-MM-DDTHH:mm:ss` 格式 | 如果是其他格式用 `matches` | +| `file,path;` 路径含 `..` | Hurl 禁止相对父目录 | 用 `--file-root` 或调整路径 | diff --git a/.gitignore b/.gitignore index 5265191..fce34c0 100644 --- a/.gitignore +++ b/.gitignore @@ -76,4 +76,12 @@ docs/admin-openapi.yaml /api /gendocs .env.local -/worker \ No newline at end of file +/worker +.opencode/skills/json-canvas +.opencode/skills/obsidian-bases +.gitignore +.opencode/skills/obsidian-cli +.opencode/skills/obsidian-markdown +.opencode/skills/kb-sync +.gitignore +.opencode/skills/defuddle diff --git a/.opencode/skills/hurl-test/SKILL.md b/.opencode/skills/hurl-test/SKILL.md new file mode 100644 index 0000000..072af15 --- /dev/null +++ b/.opencode/skills/hurl-test/SKILL.md @@ -0,0 +1,952 @@ +--- +name: hurl-test +description: Hurl 接口测试生成器。用户描述要测试的接口或业务流程,自动探索代码、确认需求、生成完整的 .hurl 测试文件(含 DTO 驱动的字段完整性断言)。触发词:测试、hurl、写测试、接口测试。 +--- + +# Hurl 接口测试生成器 + +**用户描述要测试什么,你来读代码、问确认、生成 .hurl 文件。** + +适用于任何后端项目(Go / Python / Node / Java 等),不预设框架和目录结构。 + +--- + +## 触发条件 + +以下情况必须使用本 Skill: +- 用户说"测试 XX 接口"、"写 hurl 测试"、"给 XX 加测试" +- 用户说"测试 XX 流程"、"测试 XX 的业务逻辑" +- 用户说"验证 XX 接口的字段"、"测试接口契约" +- 用户提到 hurl、.hurl、接口测试、集成测试、冒烟测试 + +--- + +## 五阶段工作流(必须按顺序执行) + +``` +Phase 1: 探索 → Phase 2: 确认 → Phase 3: 生成 → Phase 4: 验证 → Phase 5: 研判 +读代码搞清楚 展示给用户确认 输出 .hurl 文件 语法检查+试跑 分析失败/报告发现 +``` + +> **核心原则:测试是用来发现问题的,不是用来全部通过的。** +> 断言基于代码定义(Handler + DTO)生成,与实际行为的差异是"发现",不是要修掉的"错误"。 + +--- + +### Phase 1: 探索(Explore) + +**目标:读代码,搞清楚项目约定 + 涉及的接口 + 字段 + 依赖。要求准确,不求快。** + +#### 真理源层级(必须遵守) + +断言的字段名和类型基于以下优先级确定,**高优先级推翻低优先级**: + +| 优先级 | 来源 | 说明 | 怎么找 | +|-------|------|------|--------| +| **1(最高)** | Handler 实际实现 | Handler 返回什么类型、调用哪个 response 方法、是否做了 model→DTO 转换 | 读 handler 函数体,看 `return response.Success(c, xxx)` 中 xxx 的类型 | +| **2** | DTO / Response 结构体 | Handler 引用的 DTO 的 json tag、字段类型 | 读 DTO struct 定义 | +| **3(最低)** | 路由注册的 Output 声明 | RouteSpec 中声明的 Output 类型,仅作为文档参考,可能过期 | 读路由注册代码 | + +**关键规则**: +- **不能只读 DTO 就生成断言**。必须追溯到 Handler 确认它确实用了这个 DTO +- 如果 Handler 直接返回 GORM model 而非 DTO,断言用 model 的字段名(可能是大写 `ID` 而非小写 `id`) +- 如果 Handler 用了 `response.SuccessWithPagination()`,分页字段以该函数的结构为准,而非 DTO 定义的分页结构 +- 当 Handler 实现与 DTO 定义不一致时,**以 Handler 为准生成断言,同时在 Phase 5 中将不一致标记为发现** + +#### 1.1 项目画像(首次使用时必须执行,后续复用) + +首次为项目生成 Hurl 测试时,先回答以下问题(通过读代码,不要猜): + +| 问题 | 怎么找 | +|------|--------| +| **语言/框架** | 看 go.mod / package.json / requirements.txt / pom.xml | +| **路由注册在哪** | 搜索 `router`、`app.Get`、`@GetMapping`、`@app.route` 等关键词 | +| **请求/响应 schema 定义在哪** | 搜索 DTO / schema / serializer / model 目录,看 json tag 或装饰器 | +| **统一响应格式是什么** | 找 response helper 文件(如 `response.go`、`response.py`),记录 JSON 结构 | +| **认证方式是什么** | 找 auth middleware,确定是 Bearer Token / Cookie / API Key / Basic Auth | +| **登录接口是什么** | 找登录 handler,记录路径、请求体、响应中 token 的位置 | +| **分页格式是什么** | 找列表接口的响应结构,记录 items/total/page 等字段名 | +| **已有 hurl 测试吗** | 搜索 `*.hurl` 文件,复用已有的约定 | + +将画像结果**写入 `tests/hurl/.project-profile.md` 文件持久化保存**。 + +#### 画像持久化(关键机制) + +**首次使用时**:完成 1.1 探索后,将画像写入 `tests/hurl/.project-profile.md`,格式如下: + +```markdown +# 项目画像(Hurl 测试自动生成用) + + + +## 技术栈 +- 语言: Go 1.25 +- 框架: Fiber v2 +- ORM: GORM + +## 路由定义位置 +- 路由注册入口: internal/routes/routes.go +- 按模块拆分: internal/routes/{module}.go +- 路由注册函数: Register(router, doc, basePath, method, path, handler, spec) + +## Schema 定义位置 +- DTO 目录: internal/model/dto/ +- 命名规则: {module}_dto.go +- 字段标签: json / validate / description + +## 统一响应格式 +{code: int, msg: string, data: any, timestamp: string(RFC3339)} +- 成功: code=0, msg="success" +- 错误: code!=0 + +## 分页格式 +{items: [], total: int, page: int, size: int} +- 包裹在 data 字段内: $.data.items / $.data.total + +## 认证方式 +- 后台: POST /api/auth/admin-login → $.data.access_token → Authorization: Bearer {token} +- C端: JWT → Authorization: Bearer {token} + +## 默认测试账号 +- 用户名: admin +- 密码: Admin@123456 + +## 服务端口 +- 默认: 3000 +``` + +**后续使用时**:检查 `tests/hurl/.project-profile.md` 是否存在: +- **存在** → 直接读取,跳过 1.1 的探索步骤,节省时间 +- **不存在** → 执行 1.1 完整探索,然后生成此文件 +- **用户说"刷新画像"** → 删除旧文件,重新执行 1.1 + +#### 1.2 找接口定义 + +根据用户要测的模块,定位路由注册代码,提取: + +- **HTTP 方法**(GET / POST / PUT / DELETE / PATCH) +- **路由路径**(含路径参数格式,如 `/users/:id` 或 `/users/{id}`) +- **接口说明**(注释、Summary、装饰器描述) +- **是否需要认证** +- **请求 schema 类型名**(Input / Request DTO) +- **响应 schema 类型名**(Output / Response DTO) + +#### 1.3 读 schema 定义(DTO / struct / class / type) + +定位请求和响应的 schema 定义文件,提取每个字段的: + +- **字段名**:JSON 序列化后的名称(json tag / @JsonProperty / serializer field) +- **语言类型**:string / int / bool / 数组 / 嵌套对象 / 可空等 +- **是否必填**:validate tag / required 装饰器 / 非空标注 +- **是否可空**:指针类型 / Optional / nullable +- **是否参与序列化**:`json:"-"` / @JsonIgnore / exclude +- **是否 omitempty**:`json:",omitempty"` / 条件序列化 +- **字段描述**:description tag / docstring / 注释 + +#### 1.4 识别业务依赖 + +读 service / business logic 层,识别: + +- 创建操作需要哪些前置数据(如创建订单需要先有商品和用户) +- 是否有唯一性约束(如用户名不能重复) +- 是否依赖外部服务(支付网关、短信、OAuth 等) +- 业务流转逻辑(状态机、级联操作) + +--- + +### Phase 2: 确认(Clarify) + +**目标:向用户展示发现的内容,确认模糊点。不要闷头生成。** + +#### 2.1 必须展示的内容 + +``` +我梳理了相关代码,发现以下信息: + +📋 涉及接口: + - [方法] [路径] - [说明](认证: 是/否) + - ... + +📦 响应字段(基于 {SchemaName}): + - [字段名]: [类型] - [说明] + - ...(共 N 个字段,将全部生成断言) + +🔗 依赖关系: + - [创建 X 需要先创建 Y] + - ... + +⚠️ 特殊情况: + - [涉及外部服务 / 文件上传 / 特殊认证等] +``` + +#### 2.2 按需确认(只问有歧义的) + +| 场景 | 要问的 | +|------|--------| +| 流程范围不明确 | "要测到哪一步?" | +| 多种用户角色 | "用哪种身份测?" | +| 是否测异常 | "需要包含异常 case 吗?(参数校验失败、权限不足等)" | +| 是否测数据隔离 | "需要验证不同用户间数据不可见吗?" | +| 涉及第三方 | "XX 部分怎么处理?绕过 / 模拟回调 / 跳过?" | +| 前置数据来源 | "XX 依赖数据是通过 API 创建还是假设已存在?" | + +**如果用户说"越完整越好"或"都要"→ 默认全部包含,不再追问。** + +--- + +### Phase 3: 生成(Generate) + +**目标:生成完整的 .hurl 文件,字段断言基于 schema 代码,不能编造。** + +#### 3.1 文件头注释 + +```hurl +# ============================================================ +# 测试:{测试名称} +# 生成时间:{日期} +# 涉及模块:{module1, module2, ...} +# 涉及接口:{N} 个 +# 断言数量:{N} 条 +# 前置条件:{服务运行 + 必要的前置条件} +# ============================================================ +# 流程: +# 1. {步骤描述} +# 2. {步骤描述} +# ... +# ============================================================ +``` + +#### 3.2 请求生成规则 + +**认证**: + +- 根据 Phase 1 画像中的登录接口和 token 位置生成 +- token 必须通过 `[Captures]` 捕获,后续请求引用 +- 如果是 Cookie 认证,用 `[Cookies]` 或 cookie capture + +**CRUD 标准模式**: + +| 操作 | 生成要求 | +|------|---------| +| **创建(POST)** | capture 返回的 ID;**所有**唯一约束字段用 `{{newUuid}}` 防冲突 | +| **查询详情(GET)** | **逐字段断言**(类型 + 值,见 3.3) | +| **查询列表(GET)** | 分页结构断言 + items[0] 逐字段断言 | +| **修改(PUT/PATCH)** | 修改后**紧跟一个 GET 验证修改生效** | +| **删除(DELETE)** | 删除后**紧跟一个 GET 验证已删除** | + +#### 唯一值防冲突规范(强制执行) + +**所有创建类请求(POST)中,凡是有唯一约束的字段,必须使用 `{{newUuid}}` 或包含 UUID 后缀**,确保测试可重复运行。 + +| 场景 | 正确做法 | 错误做法 | +|------|---------|---------| +| 编码/Code 字段 | `"code": "TEST-{{newUuid}}"` | `"code": "TEST-001"` | +| 名称(有唯一约束) | `"name": "测试-{{newUuid}}"` | `"name": "测试角色"` | +| 用户名 | `"username": "hurl_{{newUuid}}"` | `"username": "test_user"` | +| 手机号 | `"phone": "189{{newUuid}}"` 取前11位,或放在 env 文件 | `"phone": "18899990001"` | +| 邮箱 | `"email": "{{newUuid}}@test.com"` | `"email": "test@test.com"` | + +**后续步骤需要引用的值(如登录用的 username/password)**: +- 必须定义在 `env/dev.env` 中作为变量 +- 在创建请求和后续引用处都通过 `{{变量名}}` 引用,保持一致 +- env 文件中的值也应该带有测试标识前缀(如 `hurl_test_`),避免与真实数据冲突 +- **env 中有唯一约束的变量值每次运行前可能需要更新**(文档中注明) + +**业务流程模式**: + +- 按用户描述的流程顺序编排请求 +- 上一步的输出(ID、状态等)通过 `[Captures]` 传给下一步 +- 关键步骤加中间状态验证(如创建订单后验证状态为"待支付") + +#### 3.3 schema 到断言的映射 + +读到 schema 字段后,按以下规则生成 jsonpath 断言: + +**通用类型映射(所有语言)**: + +| Schema 类型特征 | Hurl 断言 | +|----------------|-----------| +| 字符串(string / str / String) | `isString` | +| 整数(int / integer / long / Int) | `isInteger` | +| 浮点(float / double / decimal / Float) | `isNumber` | +| 布尔(bool / boolean / Boolean) | `isBoolean` | +| 数组 / 列表([] / List / Array) | `isList` | +| 嵌套对象(struct / class / dict / object) | `isObject`,并递归检查子字段 | +| 可空类型(指针 / Optional / nullable) | `exists`(不强制类型,因为可能是 null) | +| 不参与序列化(json:"-" / @JsonIgnore / exclude=True) | **跳过,不生成断言** | +| 条件序列化(omitempty / if not None) | `exists` 或不生成(取决于场景) | + +**Go 特定映射**: + +| Go 类型 | Hurl 断言 | +|---------|-----------| +| `string` | `isString` | +| `int`, `int8/16/32/64`, `uint`, `uint8/16/32/64` | `isInteger` | +| `float32`, `float64` | `isNumber` | +| `bool` | `isBoolean` | +| `[]T` | `isList` | +| `*string`, `*int`, `*uint` 等指针 | `exists` | +| `time.Time` | `isString`(通常序列化为字符串) | +| `map[string]any` | `isObject` | + +**Python 特定映射(Pydantic / Django / FastAPI)**: + +| Python 类型 | Hurl 断言 | +|------------|-----------| +| `str` | `isString` | +| `int` | `isInteger` | +| `float`, `Decimal` | `isNumber` | +| `bool` | `isBoolean` | +| `list[T]`, `List[T]` | `isList` | +| `Optional[T]`, `T | None` | `exists` | +| `dict`, `Dict` | `isObject` | +| `datetime`, `date` | `isString` | + +**TypeScript/JavaScript 特定映射**: + +| TS/JS 类型 | Hurl 断言 | +|-----------|-----------| +| `string` | `isString` | +| `number`(整数上下文) | `isInteger` | +| `number`(通用) | `isNumber` | +| `boolean` | `isBoolean` | +| `T[]`, `Array` | `isList` | +| `T \| null`, `T \| undefined` | `exists` | +| `object`, `Record<>` | `isObject` | +| `Date` | `isString` | + +**Java 特定映射**: + +| Java 类型 | Hurl 断言 | +|----------|-----------| +| `String` | `isString` | +| `Integer`, `Long`, `int`, `long` | `isInteger` | +| `Double`, `Float`, `BigDecimal` | `isNumber` | +| `Boolean`, `boolean` | `isBoolean` | +| `List` | `isList` | +| `@Nullable`, `Optional` | `exists` | +| `Map` | `isObject` | +| `LocalDateTime`, `Instant` | `isString` | + +#### 3.4 统一响应格式断言 + +根据 Phase 1 画像中发现的统一响应格式,为**每个成功响应**添加格式断言。 + +示例:如果项目的统一格式是 `{code, msg, data, timestamp}`: + +```hurl +[Asserts] +jsonpath "$.code" == 0 +jsonpath "$.msg" == "success" +jsonpath "$.timestamp" isIsoDate +``` + +示例:如果项目的格式是 `{status, message, result}`: + +```hurl +[Asserts] +jsonpath "$.status" == "ok" +jsonpath "$.message" isString +``` + +示例:如果项目无统一包装,直接返回数据: + +```hurl +[Asserts] +# 直接断言业务字段 +jsonpath "$.id" isInteger +jsonpath "$.name" isString +``` + +**不要假设响应格式,必须从代码中确认。** + +#### 3.5 分页断言 + +根据 Phase 1 画像中发现的分页结构生成。 + +示例:如果是 `{items, total, page, size}` 格式: + +```hurl +jsonpath "$.data.items" isList +jsonpath "$.data.total" isInteger +jsonpath "$.data.total" >= 1 +jsonpath "$.data.page" isInteger +jsonpath "$.data.size" isInteger +# items 内元素逐字段断言 +jsonpath "$.data.items[0].{field}" {type_assert} +``` + +示例:如果是 `{results, count, next, previous}` 格式(Django 风格): + +```hurl +jsonpath "$.results" isList +jsonpath "$.count" isInteger +jsonpath "$.count" >= 1 +# results 内元素逐字段断言 +jsonpath "$.results[0].{field}" {type_assert} +``` + +**根据实际代码调整字段名,不硬编码。** + +#### 3.6 异常 Case 模板 + +**参数校验失败**: + +```hurl +# ── 异常:参数校验失败 ── +POST {{base_url}}/{path} +Authorization: Bearer {{token}} +Content-Type: application/json +{ + "required_field": "" +} +HTTP {expected_error_status} +[Asserts] +# 断言错误响应格式(根据项目约定调整) +``` + +> HTTP 状态码根据项目实际返回确定:有的项目错误也返回 200 + 业务错误码,有的返回 400/422。 + +**未认证访问**: + +```hurl +# ── 异常:未认证访问 ── +GET {{base_url}}/{protected_path} +HTTP {expected_unauth_status} +``` + +**越权访问**(如果用户要求): + +```hurl +# ── 异常:用户 B 不能访问用户 A 的资源 ── +GET {{base_url}}/{path}/{{user_a_resource_id}} +Authorization: Bearer {{user_b_token}} +HTTP {expected_forbidden_status} +``` + +#### 3.7 特殊场景处理 + +| 场景 | 处理策略 | +|------|---------| +| **短信/邮件验证码** | 建议服务端加 test_mode 开关,固定验证码写入 env 文件;注释提醒用户 | +| **第三方支付** | 优先用项目内部支付方式(如钱包支付);如需测回调,直接 POST 回调接口模拟 | +| **OAuth 登录(微信/Google/GitHub)** | 建议服务端加 test_mode 支持直接传 openid/email;注释提醒用户 | +| **文件上传** | 用 Hurl 的 `[Multipart]` 语法 + testdata 目录下的样本文件 | +| **外部 API 依赖** | 只测参数校验和错误响应格式,不断言业务结果;注释说明依赖 | +| **WebSocket** | Hurl 不支持,注释说明跳过 | +| **异步任务结果** | 用 Hurl 的 `retry` + `retry-interval` 轮询直到状态变更 | + +异步轮询示例: + +```hurl +# 等待异步任务完成(最多重试 10 次,间隔 500ms) +GET {{base_url}}/{path}/{{task_id}} +Authorization: Bearer {{token}} +[Options] +retry: 10 +retry-interval: 500ms +HTTP 200 +[Asserts] +jsonpath "$.data.status" == "completed" +``` + +#### 3.8 文件输出 + +**目录结构**(首次使用时创建,如不存在): + +``` +tests/hurl/ +├── env/ +│ └── dev.env # 环境变量 +├── testdata/ # 测试用的样本文件 +├── flows/ # 业务流程测试 +├── modules/ # 按模块的接口测试 +│ └── {module}/ +│ └── 01-crud.hurl +├── negative/ # 异常/边界测试 +├── contract/ # 接口契约验证 +├── reports/ # Phase 5 发现报告(自动生成) +│ └── findings-{name}-{date}.md +└── Makefile # 快捷命令 +``` + +文件放置规则: + +| 用户描述 | 输出路径 | +|---------|---------| +| 测试 XX 流程 / 业务流程 | `tests/hurl/flows/{flow-name}.hurl` | +| 测试 XX 模块的 CRUD / 接口 | `tests/hurl/modules/{module}/01-crud.hurl` | +| 测试异常/边界/权限 | `tests/hurl/negative/{name}.hurl` | +| 测试接口契约/字段对齐 | `tests/hurl/contract/{name}.hurl` | + +**如果项目已有 hurl 测试目录结构,沿用已有约定,不要另起炉灶。** + +#### 3.9 env 和 Makefile + +**env/dev.env**(首次创建时生成,内容基于 Phase 1 画像): + +```properties +# 服务地址 +base_url=http://localhost:{port} + +# 认证信息(根据项目实际填写) +admin_username={默认用户名} +admin_password={默认密码} + +# 测试模式变量(如果有特殊场景) +# test_sms_code=888888 +# test_openid=test_openid_001 +``` + +**Makefile**(首次创建时生成): + +```makefile +SHELL := /bin/bash +ENV ?= dev +HURL_OPTS := --variables-file env/$(ENV).env --test + +.PHONY: test test-flows test-modules test-negative report + +test: ## 运行所有测试 + hurl $(HURL_OPTS) . + +test-flows: ## 运行业务流程测试 + hurl $(HURL_OPTS) flows/ + +test-modules: ## 运行模块接口测试 + hurl $(HURL_OPTS) modules/ + +test-negative: ## 运行异常测试 + hurl $(HURL_OPTS) negative/ + +report: ## 生成 HTML 报告 + hurl $(HURL_OPTS) --report-html build/report/ . +``` + +--- + +### Phase 4: 验证(Verify) + +**目标:确保生成的 .hurl 文件语法正确、可运行。** + +#### 4.1 语法自检 + +- [ ] 每个请求之间有空行分隔 +- [ ] `[Captures]` 和 `[Asserts]` 拼写正确(大小写敏感) +- [ ] 所有 `{{变量}}` 引用都有来源(env 文件定义 或 上游 `[Captures]`) +- [ ] JSON body 无尾逗号、格式正确 +- [ ] `HTTP {status}` 在请求之后、`[Captures]` / `[Asserts]` 之前 +- [ ] 请求和断言之间没有多余空行(`HTTP` 行必须紧跟请求) +- [ ] 文件上传路径相对于 hurl 文件位置正确 + +#### 4.2 运行测试 + +```bash +hurl --variables-file tests/hurl/env/dev.env --test tests/hurl/{生成的文件} +``` + +- 服务在跑 → 执行,如有失败分析修正 +- 服务没跑 → 跳过,告知用户手动验证命令 + +--- + +### Phase 5: 研判(Triage) + +**目标:分析测试失败原因,区分"测试自身问题"和"接口问题",生成发现报告。** + +> **核心原则:不因实际返回与预期不符而盲目修改断言。差异是"发现",需要分类研判。** + +#### 5.1 失败分类(A/B/C 三类) + +每个断言失败必须归入以下三类之一: + +| 类型 | 含义 | 判断标准 | 处置方式 | +|------|------|---------|---------| +| **A 类 — 测试自身错误** | JSON path 写错、请求参数遗漏、env 变量缺失、前置步骤失败导致 ID 为空 | 改正后 DTO/Handler 代码能对应上 | ✅ **自动修正测试**,不打扰用户 | +| **B 类 — 代码一致性问题** | DTO 定义和 Handler 实际返回不一致(如 DTO 写 `json:"id"` 但 Handler 返回 `"ID"`;DTO 定义了分页结构但 Handler 用了另一种) | DTO 说的是 A,Handler 做的是 B,两个都是代码 | ⚠️ **以 Handler 为准修正断言** + **写入发现报告** | +| **C 类 — 接口行为 Bug** | 接口行为不符合合理预期(如详情接口缺少列表接口有的字段;创建接口返回 500;同一个 DTO 在不同接口表现不一致) | Handler 的行为本身不合理,不是 DTO 过期的问题 | 🐛 **保留原始断言(测试会失败)** + **写入发现报告** | + +#### 5.2 分类判断流程 + +``` +断言失败 + │ + ├─ jsonpath 写错 / 变量未定义 / 前置步骤失败? + │ → A 类:修正测试 + │ + ├─ DTO 定义字段名 ≠ 实际返回字段名?(如 id vs ID, list vs items) + │ │ + │ ├─ Handler 代码确实返回了不同的结构? + │ │ → B 类:以 Handler 为准,报告不一致 + │ │ + │ └─ Handler 代码引用了 DTO 但行为不符? + │ → C 类:接口 Bug + │ + ├─ 某个字段在接口 A 有、接口 B 没有?(如列表有但详情没有) + │ → C 类:接口一致性 Bug + │ + ├─ 接口返回 5xx 错误? + │ → C 类:服务端 Bug + │ + └─ 业务逻辑不符预期?(如已传 packages 但响应中 packages 为空) + → C 类:业务逻辑 Bug +``` + +#### 5.3 发现报告格式 + +报告输出到 **`tests/hurl/reports/findings-{测试文件名}-{日期}.md`**,格式如下: + +```markdown +# 🔍 Hurl 测试发现报告 + +- 测试文件:`flows/package-resource-full-flow.hurl` +- 生成时间:2026-03-30 +- 总请求数:25 +- 通过:20 +- 失败:5(A类: 2, B类: 1, C类: 2) + +--- + +## A 类(测试自身错误)— 已自动修正 + +### A-1: 角色名冲突导致创建失败 +- **位置**:第 2 步 POST /api/admin/roles +- **原因**:角色名使用固定值,重复运行时 409 冲突 +- **修正**:角色名改为 `hurl测试角色-{{newUuid}}` + +--- + +## B 类(代码一致性问题)— 已按 Handler 修正断言 + +### B-1: 角色接口返回原始 GORM 模型而非 DTO +- **位置**:POST /api/admin/roles → 响应 +- **DTO 定义**:`RoleResponse` 有 `json:"id"`(小写) +- **Handler 实际**:直接返回 `model.Role`,字段为 `ID`(大写 GORM 默认) +- **影响**:前端按 API 文档对接会取不到 `id` 字段 +- **建议**:Handler 添加 model → DTO 转换 + +--- + +## C 类(接口行为 Bug)— 断言保留,测试会失败 + +### C-1: 套餐详情接口缺少 one_time_commission_amount +- **位置**:GET /api/admin/packages/:id(代理视角) +- **预期**:`PackageResponse` DTO 定义了 `one_time_commission_amount` 字段 +- **实际**:列表接口 GET /packages 返回该字段,详情接口不返回 +- **影响**:代理端查看单个套餐时看不到佣金信息 +- **建议**:Detail handler 增加佣金信息增强逻辑 + +### C-2: 批量分配对已授权店铺返回 500 +- **位置**:POST /api/admin/shop-package-batch-allocations +- **预期**:对已有授权的店铺,应返回业务错误码(4xx),而非 500 +- **实际**:返回 `{code: 2001, msg: "内部服务器错误"}` +- **影响**:前端无法区分是参数错误还是服务异常 +- **建议**:添加"已存在授权"的前置检查,返回明确的业务错误 +``` + +#### 5.4 研判完成后的交付物 + +| 交付物 | 说明 | +|-------|------| +| **修正后的 .hurl 文件** | A 类已自动修正;B 类按 Handler 实际修正;C 类**保留原始断言**(预期失败) | +| **发现报告** | `tests/hurl/reports/findings-{name}-{date}.md` | +| **终端摘要** | 向用户输出发现数量和关键 C 类问题摘要 | + +#### 5.5 用户交互 + +研判完成后,向用户展示摘要并询问: + +``` +## 研判结果 + +- A 类(测试错误):N 个,已自动修正 +- B 类(一致性问题):N 个,已按实际修正并记录 +- C 类(接口 Bug):N 个,断言保留(测试会失败直到代码修复) + +### C 类发现摘要: +1. [C-1] 套餐详情缺少 one_time_commission_amount +2. [C-2] 批量分配对已授权店铺返回 500 + +详细报告已写入: tests/hurl/reports/findings-xxx.md + +是否需要对某个 C 类发现调整处置?(如确认为已知行为,改为 B 类) +``` + +--- + +## 红线规则 + +| 规则 | 说明 | +|------|------| +| **不跳过 Phase 1** | 必须读代码确认接口路径和字段,不能凭记忆或猜测 | +| **不跳过 Phase 2** | 必须向用户展示发现的接口和字段,确认后再生成 | +| **不编造字段** | 所有断言的字段名必须来自实际 schema 代码 | +| **不编造路径** | 所有接口路径必须来自实际路由代码 | +| **不遗漏字段** | schema 中每个参与序列化的字段都必须有对应断言 | +| **不硬编码 ID** | 所有依赖的 ID 通过 `[Captures]` 从上游请求获取 | +| **不假设响应格式** | 统一响应结构必须从代码中确认,不同项目格式不同 | +| **唯一值防冲突** | 创建类请求的**所有**唯一约束字段必须使用 `{{newUuid}}`(详见 3.2 规范) | +| **自给自足** | 每个 .hurl 文件自己创建测试数据,不依赖外部数据准备 | +| **不为通过而改断言** | 断言失败时走 Phase 5 研判流程,C 类 Bug 必须保留断言并写入发现报告 | +| **Handler 追溯** | 断言字段名必须追溯到 Handler 实际返回的类型,不能只看 DTO 定义(详见 Phase 1 真理源层级) | + +--- + +## AI 助手检查清单 + +### 生成阶段自检(Phase 3 完成后) + +1. ✅ 文件头注释包含流程描述和前置条件 +2. ✅ 认证步骤正确 capture 了 token / cookie +3. ✅ 所有依赖数据通过 API 链式创建(自给自足) +4. ✅ 每个成功响应断言了项目的统一响应格式 +5. ✅ 查询详情接口**逐字段断言**(类型 + 值,基于 Handler 实际返回) +6. ✅ 分页接口断言了分页结构 + 第一条记录的字段 +7. ✅ 修改操作后紧跟 GET 验证修改生效 +8. ✅ 删除操作后紧跟 GET 验证已删除 +9. ✅ **所有**创建请求的唯一约束字段使用了 `{{newUuid}}` +10. ✅ `{{变量}}` 引用无悬空(都有 env 或 capture 来源) +11. ✅ 文件放在了正确的目录位置 +12. ✅ 特殊场景有明确的处理策略和注释提醒 +13. ✅ 断言字段名已追溯到 Handler 实际返回类型(非仅依赖 DTO) + +### 研判阶段自检(Phase 5 完成后) + +14. ✅ 每个断言失败都已分类(A/B/C) +15. ✅ A 类错误已自动修正 +16. ✅ B 类不一致已按 Handler 修正断言,并写入发现报告 +17. ✅ C 类 Bug 保留了原始断言(测试预期失败),并写入发现报告 +18. ✅ 发现报告已输出到 `tests/hurl/reports/findings-{name}-{date}.md` +19. ✅ 向用户展示了研判结果摘要 + +--- + +## 附录:Hurl 语法速查 + +**生成 .hurl 文件时必须参照本速查,不可凭记忆编造语法。** + +### 文件结构 + +一个 .hurl 文件由多个 entry 组成,每个 entry = 请求 + 可选响应: + +``` +请求1 +响应1(可选) + +请求2 +响应2(可选) +``` + +entry 之间用空行分隔。 + +### 请求格式 + +```hurl +METHOD URL +Header1: value1 +Header2: value2 +[Options] +key: value +[Query] +param1: value1 +[Form] +field1: value1 +[Multipart] +file1: file,path/to/file; +[BasicAuth] +username: password +[Cookies] +name: value +BODY(JSON / XML / multiline string / file) +``` + +**规则**: +- Method + URL 是第一行,必须 +- Headers 紧跟 URL 之后(无 section 标记) +- Sections(`[Query]`、`[Form]`、`[Options]` 等)顺序任意 +- Body 必须在最后 +- JSON body 直接写 `{ }` 即可,自动设置 Content-Type: application/json + +### 响应格式 + +```hurl +HTTP {status_code} +Header1: expected_value1 +[Captures] +var_name: jsonpath "$.path" +[Asserts] +jsonpath "$.field" == "value" +``` + +**规则**: +- `HTTP {status}` 紧跟请求之后(中间不能有空行) +- `HTTP *` 表示不检查状态码 +- Headers 检查紧跟 HTTP 行之后 +- `[Captures]` 和 `[Asserts]` 顺序任意 + +### 变量和模板 + +```hurl +# 引用变量(从 env 文件、命令行或上游 capture 获取) +GET {{base_url}}/api/users/{{user_id}} + +# 内置函数 +POST {{base_url}}/api/users +{ + "email": "{{newUuid}}@test.com", + "created_at": "{{newDate}}" +} +``` + +可用函数: +- `{{newUuid}}` — 生成 UUID v4 +- `{{newDate}}` — 生成 RFC 3339 UTC 时间戳 + +### Capture 语法 + +```hurl +[Captures] +# JSONPath +token: jsonpath "$.data.access_token" +user_id: jsonpath "$.data.id" +first_item: jsonpath "$.items[0].name" + +# Header +location: header "Location" + +# Cookie +session: cookie "SESSIONID" + +# Body(整个响应体作为字符串) +full_body: body + +# Status code +code: status + +# 正则表达式 +csrf: regex "name=\"csrf\" value=\"([^\"]+)\"" + +# 响应时间(毫秒) +response_time: duration +``` + +### Assert 语法 + +```hurl +[Asserts] +# ── 状态码 ── +status == 200 +status >= 200 +status < 300 + +# ── JSONPath 断言 ── +jsonpath "$.name" == "Alice" # 等于 +jsonpath "$.name" != "Bob" # 不等于 +jsonpath "$.age" > 18 # 大于 +jsonpath "$.age" >= 18 # 大于等于 +jsonpath "$.count" < 100 # 小于 +jsonpath "$.items" count == 5 # 集合长度 +jsonpath "$.name" startsWith "Al" # 前缀 +jsonpath "$.name" endsWith "ce" # 后缀 +jsonpath "$.name" contains "lic" # 包含 +jsonpath "$.date" matches /\\d{4}-\\d{2}-\\d{2}/ # 正则 + +# ── 类型断言 ── +jsonpath "$.name" isString +jsonpath "$.age" isInteger +jsonpath "$.score" isFloat +jsonpath "$.count" isNumber # 整数或浮点 +jsonpath "$.active" isBoolean +jsonpath "$.items" isList +jsonpath "$.meta" isObject +jsonpath "$.id" isUuid +jsonpath "$.created_at" isIsoDate # RFC 3339 格式 +jsonpath "$.field" isEmpty # 空集合 + +# ── 存在性 ── +jsonpath "$.field" exists +jsonpath "$.field" not exists + +# ── 否定 ── +jsonpath "$.name" not contains "Bob" +jsonpath "$.status" not == "deleted" + +# ── Header 断言 ── +header "Content-Type" contains "application/json" +header "X-Request-Id" exists + +# ── 性能 ── +duration < 1000 # 响应时间(毫秒) + +# ── Body 断言 ── +body contains "Hello" +bytes count == 1024 +``` + +### Options(逐请求配置) + +```hurl +GET {{base_url}}/api/task/{{task_id}} +[Options] +retry: 10 # 最大重试次数(-1 = 无限) +retry-interval: 500ms # 重试间隔 +delay: 2s # 请求前等待 +location: true # 跟随重定向 +insecure: true # 允许不安全 SSL +verbose: true # 输出详细日志 +very-verbose: true # 输出更详细日志 +skip: true # 跳过此请求 +variable: key=value # 定义变量 +HTTP 200 +``` + +### Multipart 文件上传 + +```hurl +POST {{base_url}}/api/upload +[Multipart] +file: file,testdata/sample.xlsx; +field1: value1 +# 指定 Content-Type +file2: file,testdata/data.bin; application/octet-stream +``` + +### 运行命令 + +```bash +# 运行单个文件 +hurl --test file.hurl + +# 带变量文件 +hurl --variables-file env/dev.env --test file.hurl + +# 运行目录下所有 .hurl +hurl --test tests/hurl/ + +# 生成 HTML 报告 +hurl --test --report-html build/report/ tests/hurl/ + +# 生成 JUnit 报告(CI 用) +hurl --test --report-junit build/report.xml tests/hurl/ + +# 并行执行(--test 默认并行,同文件内串行) +hurl --test --jobs 4 tests/hurl/ + +# 指定单个变量 +hurl --variable base_url=http://localhost:3000 --test file.hurl + +# 失败后继续执行 +hurl --test --continue-on-error tests/hurl/ +``` + +### 常见错误 + +| 错误 | 原因 | 修正 | +|------|------|------| +| `HTTP 200` 和请求之间有空行 | 空行会被当作 entry 分隔符 | 删除空行,HTTP 行紧跟请求 | +| JSON body 有尾逗号 | Hurl 严格解析 JSON | 删除最后一个逗号 | +| `jsonpath` 写成 `json_path` 或 `JsonPath` | 关键字大小写敏感 | 必须小写 `jsonpath` | +| `[Captures]` 写成 `[Capture]` | 必须是复数 | `[Captures]`、`[Asserts]`、`[Options]` | +| 变量 `{{ var }}` 有空格 | 允许,但建议统一 | `{{var}}` 或 `{{ var }}` 都可以 | +| `isIsoDate` 用在非 RFC 3339 格式 | 只认 `YYYY-MM-DDTHH:mm:ss` 格式 | 如果是其他格式用 `matches` | +| `file,path;` 路径含 `..` | Hurl 禁止相对父目录 | 用 `--file-root` 或调整路径 |