Files
junhong_cmp_fiber/.claude/skills/openspec-generate-acceptance-tests/SKILL.md
huang 8ab5ebc3af
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 6m2s
feat: 在 IoT 卡和设备列表响应中添加套餐系列名称字段
主要变更:
- 在 StandaloneIotCardResponse 和 DeviceResponse 中添加 series_name 字段
- 在 iot_card 和 device service 中添加 loadSeriesNames 方法批量加载系列名称
- 更新相关方法以支持 series_name 的填充

其他变更:
- 新增 OpenSpec 测试生成和共识锁定 skill
- 新增 MCP 配置文件
- 更新 CLAUDE.md 项目规范文档

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-04 15:28:41 +08:00

443 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
name: openspec-generate-acceptance-tests
description: 从 Spec 的 Scenarios 和 Business Flows 自动生成验收测试和流程测试。测试在实现前生成,预期全部 FAIL证明测试有效。
license: MIT
compatibility: Requires openspec CLI.
metadata:
author: junhong
version: "1.0"
---
# 测试生成 Skill
从 Spec 文档自动生成两类测试:
1. **验收测试**Acceptance Tests从 Scenarios 生成,验证单 API 契约
2. **流程测试**Flow Tests从 Business Flows 生成,验证多 API 业务场景
## 触发方式
```
/opsx:gen-tests [change-name]
```
如果不指定 change-name自动检测当前活跃的 change。
---
## 前置条件
1. Change 必须存在且包含 spec 文件
2. Spec 必须包含 `## Scenarios` 部分
3. Spec 建议包含 `## Business Flows` 部分(如果有跨 API 场景)
检查命令:
```bash
openspec list --json
# 确认 change 存在且有 specs
```
---
## 工作流程
### Step 1: 读取 Spec 文件
```bash
# 读取 change 的所有 spec 文件
cat openspec/changes/<change-name>/specs/<capability>/spec.md
```
### Step 2: 解析 Scenarios
从 Spec 中提取所有 Scenario
```markdown
#### Scenario: 成功创建套餐
- **GIVEN** 用户已登录且有创建权限
- **WHEN** POST /api/admin/packages with valid data
- **THEN** 返回 201 和套餐详情
- **AND** 数据库中存在该套餐记录
```
解析为结构:
```json
{
"name": "成功创建套餐",
"given": ["用户已登录且有创建权限"],
"when": {"method": "POST", "path": "/api/admin/packages", "condition": "valid data"},
"then": ["返回 201 和套餐详情"],
"and": ["数据库中存在该套餐记录"]
}
```
### Step 3: 解析 Business Flows
从 Spec 中提取 Business Flow
```markdown
### Flow: 套餐完整生命周期
**参与者**: 平台管理员, 代理商
**流程步骤**:
1. **创建套餐**
- 角色: 平台管理员
- 调用: POST /api/admin/packages
- 预期: 返回套餐 ID
2. **分配给代理商**
- 角色: 平台管理员
- 调用: POST /api/admin/shop-packages
- 输入: 套餐 ID + 店铺 ID
- 预期: 分配成功
3. **代理商查看可售套餐**
- 角色: 代理商
- 调用: GET /api/admin/shop-packages
- 预期: 列表包含刚分配的套餐
```
### Step 4: 生成验收测试
**输出路径**: `tests/acceptance/<capability>_acceptance_test.go`
```go
package acceptance
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"junhong_cmp_fiber/tests/testutils"
)
// ============================================================
// 验收测试:套餐管理
// 来源openspec/changes/package-management/specs/package/spec.md
// ============================================================
func TestPackage_Acceptance(t *testing.T) {
env := testutils.NewIntegrationTestEnv(t)
// ------------------------------------------------------------
// Scenario: 成功创建套餐
// GIVEN: 用户已登录且有创建权限
// WHEN: POST /api/admin/packages with valid data
// THEN: 返回 201 和套餐详情
// AND: 数据库中存在该套餐记录
//
// 破坏点:如果删除 handler.Create 中的 store.Create 调用,此测试将失败
// ------------------------------------------------------------
t.Run("Scenario_成功创建套餐", func(t *testing.T) {
// GIVEN: 用户已登录且有创建权限
client := env.AsSuperAdmin()
// WHEN: POST /api/admin/packages with valid data
body := map[string]interface{}{
"name": "测试套餐",
"description": "测试描述",
"price": 9900,
"duration": 30,
}
resp, err := client.Request("POST", "/api/admin/packages", body)
require.NoError(t, err)
// THEN: 返回 201 和套餐详情
assert.Equal(t, 201, resp.StatusCode)
var result map[string]interface{}
err = resp.JSON(&result)
require.NoError(t, err)
assert.Equal(t, 0, int(result["code"].(float64)))
data := result["data"].(map[string]interface{})
packageID := uint(data["id"].(float64))
assert.NotZero(t, packageID)
// AND: 数据库中存在该套餐记录
// TODO: 实现后取消注释
// pkg, err := env.DB().Package.FindByID(ctx, packageID)
// require.NoError(t, err)
// assert.Equal(t, "测试套餐", pkg.Name)
})
// ------------------------------------------------------------
// Scenario: 创建套餐参数校验失败
// GIVEN: 用户已登录
// WHEN: POST /api/admin/packages with invalid data (name empty)
// THEN: 返回 400 和错误信息
//
// 破坏点:如果删除 handler 中的参数校验,此测试将失败
// ------------------------------------------------------------
t.Run("Scenario_创建套餐参数校验失败", func(t *testing.T) {
// GIVEN: 用户已登录
client := env.AsSuperAdmin()
// WHEN: POST /api/admin/packages with invalid data
body := map[string]interface{}{
"name": "", // 空名称
"price": -1, // 负价格
}
resp, err := client.Request("POST", "/api/admin/packages", body)
require.NoError(t, err)
// THEN: 返回 400 和错误信息
assert.Equal(t, 400, resp.StatusCode)
})
}
```
### Step 5: 生成流程测试
**输出路径**: `tests/flows/<capability>_<flow-name>_flow_test.go`
```go
package flows
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"junhong_cmp_fiber/tests/testutils"
)
// ============================================================
// 流程测试:套餐完整生命周期
// 来源openspec/changes/package-management/specs/package/spec.md
// 参与者:平台管理员, 代理商
// ============================================================
func TestFlow_PackageLifecycle(t *testing.T) {
env := testutils.NewIntegrationTestEnv(t)
// 流程级共享状态
var (
packageID uint
shopID uint = 1 // 测试店铺 ID
)
// ------------------------------------------------------------
// Step 1: 创建套餐
// 角色: 平台管理员
// 调用: POST /api/admin/packages
// 预期: 返回套餐 ID
//
// 破坏点:如果套餐创建 API 不返回 ID后续步骤无法执行
// ------------------------------------------------------------
t.Run("Step1_平台管理员创建套餐", func(t *testing.T) {
client := env.AsSuperAdmin()
body := map[string]interface{}{
"name": "流程测试套餐",
"description": "用于流程测试",
"price": 19900,
"duration": 30,
}
resp, err := client.Request("POST", "/api/admin/packages", body)
require.NoError(t, err)
require.Equal(t, 201, resp.StatusCode)
var result map[string]interface{}
err = resp.JSON(&result)
require.NoError(t, err)
data := result["data"].(map[string]interface{})
packageID = uint(data["id"].(float64))
require.NotZero(t, packageID, "套餐 ID 不能为空")
})
// ------------------------------------------------------------
// Step 2: 分配给代理商
// 角色: 平台管理员
// 调用: POST /api/admin/shop-packages
// 输入: 套餐 ID + 店铺 ID
// 预期: 分配成功
//
// 依赖: Step 1 的 packageID
// 破坏点:如果分配 API 不检查套餐是否存在,可能分配无效套餐
// ------------------------------------------------------------
t.Run("Step2_分配套餐给代理商", func(t *testing.T) {
if packageID == 0 {
t.Skip("依赖 Step 1 创建的 packageID")
}
client := env.AsSuperAdmin()
body := map[string]interface{}{
"package_id": packageID,
"shop_id": shopID,
}
resp, err := client.Request("POST", "/api/admin/shop-packages", body)
require.NoError(t, err)
require.Equal(t, 200, resp.StatusCode)
})
// ------------------------------------------------------------
// Step 3: 代理商查看可售套餐
// 角色: 代理商
// 调用: GET /api/admin/shop-packages
// 预期: 列表包含刚分配的套餐
//
// 依赖: Step 2 的分配操作
// 破坏点:如果查询不按店铺过滤,代理商会看到其他店铺的套餐
// ------------------------------------------------------------
t.Run("Step3_代理商查看可售套餐", func(t *testing.T) {
if packageID == 0 {
t.Skip("依赖 Step 1 创建的 packageID")
}
// 以代理商身份请求
client := env.AsShopAgent(shopID)
resp, err := client.Request("GET", "/api/admin/shop-packages", nil)
require.NoError(t, err)
require.Equal(t, 200, resp.StatusCode)
var result map[string]interface{}
err = resp.JSON(&result)
require.NoError(t, err)
// 验证列表包含刚分配的套餐
data := result["data"].(map[string]interface{})
list := data["list"].([]interface{})
found := false
for _, item := range list {
pkg := item.(map[string]interface{})
if uint(pkg["package_id"].(float64)) == packageID {
found = true
break
}
}
assert.True(t, found, "代理商应该能看到刚分配的套餐")
})
}
```
### Step 6: 运行测试验证
生成测试后,立即运行验证:
```bash
# 预期全部 FAIL因为功能尚未实现
source .env.local && go test -v ./tests/acceptance/... ./tests/flows/... 2>&1 | head -50
```
**如果测试 PASS**
- 说明测试写得太弱,没有真正验证功能
- 需要加强测试或检查是否功能已存在
---
## 测试模板规范
### 验收测试必须包含
1. **来源注释**:标明从哪个 spec 文件生成
2. **Scenario 注释**:完整的 GIVEN/WHEN/THEN/AND
3. **破坏点注释**:说明什么代码变更会导致测试失败
4. **清晰的结构**GIVEN → WHEN → THEN → AND 分块
### 流程测试必须包含
1. **来源注释**:标明从哪个 spec 文件生成
2. **参与者注释**:涉及哪些角色
3. **共享状态声明**:流程中需要传递的数据
4. **依赖声明**:每个 step 依赖哪些前置 step
5. **破坏点注释**:说明什么代码变更会导致测试失败
### 破坏点注释示例
```go
// 破坏点:如果删除 handler.Create 中的 store.Create 调用,此测试将失败
// 破坏点:如果移除参数校验中的 name 必填检查,此测试将失败
// 破坏点:如果查询不按 shop_id 过滤,此测试将失败(会返回其他店铺数据)
// 破坏点:如果状态机允许 "已使用" → "未分配" 的转换,此测试将失败
```
---
## 输出文件
| 来源 | 输出 |
|------|------|
| Spec Scenarios | `tests/acceptance/<capability>_acceptance_test.go` |
| Spec Business Flows | `tests/flows/<capability>_<flow>_flow_test.go` |
---
## Guardrails
- **不要跳过 Scenario** - 每个 Scenario 必须对应一个测试用例
- **不要省略破坏点** - 每个测试必须有破坏点注释
- **不要硬编码 ID** - 使用共享变量传递流程中的 ID
- **不要忽略依赖** - 流程测试的 step 必须声明依赖
- **不要写永远 PASS 的测试** - 测试必须在功能缺失时 FAIL
---
## 与其他 Skills 的关系
| Skill | 关系 |
|-------|------|
| `openspec-continue-change` | 生成 spec 后可触发 gen-tests |
| `openspec-apply-change` | 实现时参考生成的测试 |
| `openspec-verify-change` | 验证时运行生成的测试 |
---
## 示例交互
```
User: /opsx:gen-tests package-management
AI: 正在分析 change: package-management
找到 spec 文件: openspec/changes/package-management/specs/package/spec.md
解析结果:
- Scenarios: 5 个
- Business Flows: 2 个
生成测试文件:
1. tests/acceptance/package_acceptance_test.go
- Scenario_成功创建套餐
- Scenario_创建套餐参数校验失败
- Scenario_成功更新套餐
- Scenario_成功删除套餐
- Scenario_删除已分配套餐失败
2. tests/flows/package_lifecycle_flow_test.go
- Step1_平台管理员创建套餐
- Step2_分配套餐给代理商
- Step3_代理商查看可售套餐
- Step4_代理商创建订单
- Step5_套餐使用统计更新
3. tests/flows/package_allocation_flow_test.go
- Step1_批量分配套餐
- Step2_验证分配结果
- Step3_撤销部分分配
运行验证:
$ source .env.local && go test -v ./tests/acceptance/package_... ./tests/flows/package_...
--- FAIL: TestPackage_Acceptance (0.00s)
--- FAIL: TestPackage_Acceptance/Scenario_成功创建套餐 (0.00s)
package_acceptance_test.go:45: 404 != 201
--- FAIL: TestFlow_PackageLifecycle (0.00s)
--- FAIL: TestFlow_PackageLifecycle/Step1_平台管理员创建套餐 (0.00s)
package_lifecycle_flow_test.go:38: 404 != 201
✓ 所有测试预期 FAIL功能未实现
✓ 测试生成完成
下一步: /opsx:continue 生成 design 和 tasks
```