--- 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//specs//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/_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/__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/_acceptance_test.go` | | Spec Business Flows | `tests/flows/__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 ```