# 业务流程测试 (Flow Tests) 流程测试验证多个 API 组合的完整业务场景,确保端到端流程正确。 ## 核心原则 1. **来源于 Spec Business Flow**:每个测试对应 Spec 中的一个 Business Flow 2. **跨 API 验证**:验证多个 API 调用的组合行为 3. **状态共享**:流程中的数据(如 ID)在 steps 之间传递 4. **角色切换**:不同 step 可能由不同角色执行 5. **必须有破坏点和依赖声明** ## 目录结构 ``` tests/flows/ ├── README.md # 本文件 ├── package_lifecycle_flow_test.go # 套餐完整生命周期 ├── order_purchase_flow_test.go # 订单购买流程 ├── commission_settlement_flow_test.go # 佣金结算流程 ├── iot_card_import_activate_flow_test.go # IoT 卡导入激活流程 └── ... ``` ## 测试模板 ```go package flows import ( "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "junhong_cmp_fiber/tests/testutils" ) // ============================================================ // 流程测试:{流程名称} // 来源:openspec/changes/{change-name}/specs/{capability}/spec.md // 参与者:{角色1}, {角色2}, ... // ============================================================ func TestFlow_{FlowName}(t *testing.T) { env := testutils.NewIntegrationTestEnv(t) // ======================================================== // 流程级共享状态 // 在 steps 之间传递的数据 // ======================================================== var ( resourceID uint orderID uint // 其他需要共享的状态... ) // ------------------------------------------------------------ // Step 1: {步骤名称} // 角色: {执行角色} // 调用: {HTTP Method} {Path} // 预期: {预期结果} // // 依赖: 无(首个步骤) // 破坏点:{描述什么代码变更会导致此测试失败} // ------------------------------------------------------------ t.Run("Step1_{步骤名称}", func(t *testing.T) { client := env.AsSuperAdmin() // 或其他角色 body := map[string]interface{}{ // 请求体 } resp, err := client.Request("POST", "/api/admin/xxx", body) 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{}) resourceID = uint(data["id"].(float64)) require.NotZero(t, resourceID, "资源 ID 不能为空") }) // ------------------------------------------------------------ // Step 2: {步骤名称} // 角色: {执行角色} // 调用: {HTTP Method} {Path} // 预期: {预期结果} // // 依赖: Step 1 的 resourceID // 破坏点:{描述什么代码变更会导致此测试失败} // ------------------------------------------------------------ t.Run("Step2_{步骤名称}", func(t *testing.T) { if resourceID == 0 { t.Skip("依赖 Step 1 创建的 resourceID") } client := env.AsShopAgent(1) // 切换到代理商角色 resp, err := client.Request("GET", fmt.Sprintf("/api/admin/xxx/%d", resourceID), nil) require.NoError(t, err) require.Equal(t, 200, resp.StatusCode) // 验证和提取数据... }) // 更多 steps... } ``` ## 流程测试 vs 验收测试 | 方面 | 验收测试 | 流程测试 | |------|---------|---------| | 来源 | Spec Scenario | Spec Business Flow | | 粒度 | 单 API | 多 API 组合 | | 状态 | 独立 | steps 之间共享 | | 角色 | 通常单一 | 可能多角色 | | 目的 | 验证 API 契约 | 验证业务场景 | ## 状态共享模式 ### 使用包级变量 ```go func TestFlow_PackageLifecycle(t *testing.T) { env := testutils.NewIntegrationTestEnv(t) // 流程级共享状态 var ( packageID uint allocationID uint orderID uint ) t.Run("Step1_创建套餐", func(t *testing.T) { // ... 创建套餐 packageID = extractedID }) t.Run("Step2_分配套餐", func(t *testing.T) { if packageID == 0 { t.Skip("依赖 Step 1") } // 使用 packageID allocationID = extractedID }) t.Run("Step3_创建订单", func(t *testing.T) { if allocationID == 0 { t.Skip("依赖 Step 2") } // 使用 allocationID orderID = extractedID }) } ``` ### 依赖声明规范 每个 step 必须声明依赖: ```go // ------------------------------------------------------------ // Step 3: 代理商查看可售套餐 // 角色: 代理商 // 调用: GET /api/admin/shop-packages // 预期: 列表包含刚分配的套餐 // // 依赖: Step 1 的 packageID, Step 2 的分配操作 // 破坏点:如果查询不按 shop_id 过滤,代理商会看到其他店铺的套餐 // ------------------------------------------------------------ t.Run("Step3_代理商查看可售套餐", func(t *testing.T) { if packageID == 0 { t.Skip("依赖 Step 1 创建的 packageID") } // ... }) ``` ## 多角色流程 ```go func TestFlow_CrossRoleWorkflow(t *testing.T) { env := testutils.NewIntegrationTestEnv(t) var ( resourceID uint shopID uint = 1 ) // Step 1: 平台管理员创建资源 t.Run("Step1_平台创建资源", func(t *testing.T) { client := env.AsSuperAdmin() // ... resourceID = extractedID }) // Step 2: 平台管理员分配给代理商 t.Run("Step2_分配给代理商", func(t *testing.T) { client := env.AsSuperAdmin() // ... }) // Step 3: 代理商查看资源(角色切换!) t.Run("Step3_代理商查看", func(t *testing.T) { client := env.AsShopAgent(shopID) // 切换到代理商 // ... }) // Step 4: 代理商创建订单 t.Run("Step4_代理商创建订单", func(t *testing.T) { client := env.AsShopAgent(shopID) // ... }) // Step 5: 平台管理员查看统计(再次切换) t.Run("Step5_平台查看统计", func(t *testing.T) { client := env.AsSuperAdmin() // ... }) } ``` ## 破坏点注释规范 流程测试的破坏点更侧重于跨 API 的影响: ```go // 破坏点:如果套餐创建 API 不返回 ID,后续步骤无法执行 // 破坏点:如果分配 API 不检查套餐是否存在,可能分配无效套餐 // 破坏点:如果代理商查询不过滤 shop_id,会看到其他店铺的数据 // 破坏点:如果订单创建不验证套餐有效期,可能购买过期套餐 // 破坏点:如果佣金计算不在事务中,可能导致数据不一致 ``` ## 异常流程测试 流程测试也应覆盖异常场景: ```go func TestFlow_PackageLifecycle_Exceptions(t *testing.T) { env := testutils.NewIntegrationTestEnv(t) // ------------------------------------------------------------ // 异常流程:尝试删除已分配的套餐 // 预期:删除失败,返回业务错误 // ------------------------------------------------------------ t.Run("Exception_删除已分配套餐", func(t *testing.T) { // Step 1: 创建套餐 // Step 2: 分配给店铺 // Step 3: 尝试删除(预期失败) }) // ------------------------------------------------------------ // 异常流程:代理商访问其他店铺的套餐 // 预期:访问被拒绝 // ------------------------------------------------------------ t.Run("Exception_跨店铺访问", func(t *testing.T) { // Step 1: 平台创建并分配给店铺 A // Step 2: 店铺 B 的代理商尝试访问(预期 403) }) } ``` ## 运行测试 ```bash # 运行所有流程测试 source .env.local && go test -v ./tests/flows/... # 运行特定流程 source .env.local && go test -v ./tests/flows/... -run TestFlow_PackageLifecycle # 运行特定步骤 source .env.local && go test -v ./tests/flows/... -run "Step3" ``` ## 与 Spec Business Flow 的对应关系 ```markdown # Spec 中的 Business Flow ### Flow: 套餐完整生命周期 **参与者**: 平台管理员, 代理商 **流程步骤**: 1. **创建套餐** - 角色: 平台管理员 - 调用: POST /api/admin/packages - 预期: 返回套餐 ID 2. **分配给代理商** - 角色: 平台管理员 - 调用: POST /api/admin/shop-packages - 输入: 套餐 ID + 店铺 ID - 预期: 分配成功 3. **代理商查看可售套餐** - 角色: 代理商 - 调用: GET /api/admin/shop-packages - 预期: 列表包含刚分配的套餐 ``` 直接转换为测试代码: ```go func TestFlow_PackageLifecycle(t *testing.T) { env := testutils.NewIntegrationTestEnv(t) var packageID uint t.Run("Step1_平台管理员创建套餐", func(t *testing.T) { // POST /api/admin/packages }) t.Run("Step2_分配给代理商", func(t *testing.T) { // POST /api/admin/shop-packages }) t.Run("Step3_代理商查看可售套餐", func(t *testing.T) { // GET /api/admin/shop-packages }) } ``` ## 完整示例 ```go package flows import ( "fmt" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "junhong_cmp_fiber/tests/testutils" ) // ============================================================ // 流程测试:IoT 卡导入到激活完整流程 // 来源:openspec/changes/iot-card-management/specs/iot-card/spec.md // 参与者:平台管理员, 系统 // ============================================================ func TestFlow_IotCardImportActivate(t *testing.T) { env := testutils.NewIntegrationTestEnv(t) // 流程级共享状态 var ( taskID string cardICCIDs []string ) // ------------------------------------------------------------ // Step 1: 上传 CSV 文件 // 角色: 平台管理员 // 调用: POST /api/admin/iot-cards/import // 预期: 返回导入任务 ID // // 依赖: 无 // 破坏点:如果文件上传不创建异步任务,后续无法追踪进度 // ------------------------------------------------------------ t.Run("Step1_上传CSV文件", func(t *testing.T) { client := env.AsSuperAdmin() // 创建测试 CSV 内容 csvContent := "iccid,msisdn,operator\n" + "89860000000000000001,13800000001,中国移动\n" + "89860000000000000002,13800000002,中国移动\n" resp, err := client.UploadFile("POST", "/api/admin/iot-cards/import", "file", "cards.csv", []byte(csvContent)) 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{}) taskID = data["task_id"].(string) require.NotEmpty(t, taskID, "任务 ID 不能为空") }) // ------------------------------------------------------------ // Step 2: 查询导入任务状态 // 角色: 平台管理员 // 调用: GET /api/admin/iot-cards/import/{taskID} // 预期: 任务状态为 completed,导入成功数量 = 2 // // 依赖: Step 1 的 taskID // 破坏点:如果异步任务不更新状态,查询会一直返回 pending // ------------------------------------------------------------ t.Run("Step2_查询导入状态", func(t *testing.T) { if taskID == "" { t.Skip("依赖 Step 1 创建的 taskID") } client := env.AsSuperAdmin() // 轮询等待任务完成(最多等待 30 秒) var status string for i := 0; i < 30; i++ { resp, err := client.Request("GET", fmt.Sprintf("/api/admin/iot-cards/import/%s", taskID), nil) require.NoError(t, err) require.Equal(t, 200, resp.StatusCode) var result map[string]interface{} resp.JSON(&result) data := result["data"].(map[string]interface{}) status = data["status"].(string) if status == "completed" || status == "failed" { break } time.Sleep(time.Second) } require.Equal(t, "completed", status, "导入任务应该成功完成") }) // ------------------------------------------------------------ // Step 3: 验证卡片已入库 // 角色: 平台管理员 // 调用: GET /api/admin/iot-cards // 预期: 能查询到导入的卡片 // // 依赖: Step 2 确认任务完成 // 破坏点:如果导入任务不写入数据库,查询不到卡片 // ------------------------------------------------------------ t.Run("Step3_验证卡片入库", func(t *testing.T) { if taskID == "" { t.Skip("依赖前置步骤") } client := env.AsSuperAdmin() resp, err := client.Request("GET", "/api/admin/iot-cards", map[string]interface{}{ "iccid": "89860000000000000001", }) require.NoError(t, err) require.Equal(t, 200, resp.StatusCode) var result map[string]interface{} resp.JSON(&result) data := result["data"].(map[string]interface{}) list := data["list"].([]interface{}) require.Len(t, list, 1, "应该能查询到导入的卡片") card := list[0].(map[string]interface{}) cardICCIDs = append(cardICCIDs, card["iccid"].(string)) }) // ------------------------------------------------------------ // Step 4: 激活卡片 // 角色: 平台管理员 // 调用: POST /api/admin/iot-cards/{iccid}/activate // 预期: 卡片状态变为 active // // 依赖: Step 3 获取的 cardICCIDs // 破坏点:如果激活 API 不调用运营商接口,状态不会真正变化 // ------------------------------------------------------------ t.Run("Step4_激活卡片", func(t *testing.T) { if len(cardICCIDs) == 0 { t.Skip("依赖 Step 3 获取的卡片 ICCID") } client := env.AsSuperAdmin() resp, err := client.Request("POST", fmt.Sprintf("/api/admin/iot-cards/%s/activate", cardICCIDs[0]), nil) require.NoError(t, err) require.Equal(t, 200, resp.StatusCode) }) // ------------------------------------------------------------ // Step 5: 验证卡片状态 // 角色: 平台管理员 // 调用: GET /api/admin/iot-cards/{iccid} // 预期: 卡片状态为 active // // 依赖: Step 4 激活操作 // 破坏点:如果激活后不更新数据库状态,查询还是旧状态 // ------------------------------------------------------------ t.Run("Step5_验证激活状态", func(t *testing.T) { if len(cardICCIDs) == 0 { t.Skip("依赖前置步骤") } client := env.AsSuperAdmin() resp, err := client.Request("GET", fmt.Sprintf("/api/admin/iot-cards/%s", cardICCIDs[0]), nil) require.NoError(t, err) require.Equal(t, 200, resp.StatusCode) var result map[string]interface{} resp.JSON(&result) data := result["data"].(map[string]interface{}) assert.Equal(t, "active", data["status"], "卡片状态应该是 active") }) } ``` ## 常见问题 ### Q: Step 之间必须按顺序执行吗? 是的。Go 的 t.Run 保证同一个父测试内的子测试按顺序执行。如果前置 step 失败,后续 step 会因为依赖检查而 skip。 ### Q: 如何处理异步操作? 使用轮询等待: ```go // 等待异步任务完成 for i := 0; i < maxRetries; i++ { status := checkStatus() if status == "completed" { break } time.Sleep(interval) } ``` ### Q: 流程测试太慢怎么办? 1. 使用 `t.Parallel()` 让不同流程并行(注意数据隔离) 2. 减少 sleep 时间,增加轮询频率 3. 考虑将部分验证移到验收测试