All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 6m29s
主要变更: - 新增 tb_shop_series_allocation 表,存储系列级别的一次性佣金配置 - ShopPackageAllocation 移除 one_time_commission_amount 字段 - PackageSeries 新增 enable_one_time_commission 字段控制是否启用一次性佣金 - 新增 /api/admin/shop-series-allocations CRUD 接口 - 佣金计算逻辑改为从 ShopSeriesAllocation 获取一次性佣金金额 - 删除废弃的 ShopSeriesOneTimeCommissionTier 模型 - OpenAPI Tag '系列分配' 和 '单套餐分配' 合并为 '套餐分配' 迁移脚本: - 000042: 重构佣金套餐模型 - 000043: 简化佣金分配 - 000044: 一次性佣金分配重构 - 000045: PackageSeries 添加 enable_one_time_commission 字段 测试: - 新增验收测试 (shop_series_allocation, commission_calculation) - 新增流程测试 (one_time_commission_chain) - 删除过时的单元测试(已被验收测试覆盖)
12 KiB
12 KiB
name, description, license, compatibility, metadata
| name | description | license | compatibility | metadata | ||||
|---|---|---|---|---|---|---|---|---|
| openspec-generate-acceptance-tests | 从 Spec 的 Scenarios 和 Business Flows 自动生成验收测试和流程测试。测试在实现前生成,预期全部 FAIL,证明测试有效。 | MIT | Requires openspec CLI. |
|
测试生成 Skill
从 Spec 文档自动生成两类测试:
- 验收测试(Acceptance Tests):从 Scenarios 生成,验证单 API 契约
- 流程测试(Flow Tests):从 Business Flows 生成,验证多 API 业务场景
触发方式
/opsx:gen-tests [change-name]
如果不指定 change-name,自动检测当前活跃的 change。
前置条件
- Change 必须存在且包含 spec 文件
- Spec 必须包含
## Scenarios部分 - Spec 建议包含
## Business Flows部分(如果有跨 API 场景)
检查命令:
openspec list --json
# 确认 change 存在且有 specs
工作流程
Step 1: 读取 Spec 文件
# 读取 change 的所有 spec 文件
cat openspec/changes/<change-name>/specs/<capability>/spec.md
Step 2: 解析 Scenarios
从 Spec 中提取所有 Scenario:
#### Scenario: 成功创建套餐
- **GIVEN** 用户已登录且有创建权限
- **WHEN** POST /api/admin/packages with valid data
- **THEN** 返回 201 和套餐详情
- **AND** 数据库中存在该套餐记录
解析为结构:
{
"name": "成功创建套餐",
"given": ["用户已登录且有创建权限"],
"when": {"method": "POST", "path": "/api/admin/packages", "condition": "valid data"},
"then": ["返回 201 和套餐详情"],
"and": ["数据库中存在该套餐记录"]
}
Step 3: 解析 Business Flows
从 Spec 中提取 Business Flow:
### 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
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
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: 运行测试验证
生成测试后,立即运行验证:
# 预期全部 FAIL(因为功能尚未实现)
source .env.local && go test -v ./tests/acceptance/... ./tests/flows/... 2>&1 | head -50
如果测试 PASS:
- 说明测试写得太弱,没有真正验证功能
- 需要加强测试或检查是否功能已存在
测试模板规范
验收测试必须包含
- 来源注释:标明从哪个 spec 文件生成
- Scenario 注释:完整的 GIVEN/WHEN/THEN/AND
- 破坏点注释:说明什么代码变更会导致测试失败
- 清晰的结构:GIVEN → WHEN → THEN → AND 分块
流程测试必须包含
- 来源注释:标明从哪个 spec 文件生成
- 参与者注释:涉及哪些角色
- 共享状态声明:流程中需要传递的数据
- 依赖声明:每个 step 依赖哪些前置 step
- 破坏点注释:说明什么代码变更会导致测试失败
破坏点注释示例
// 破坏点:如果删除 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