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) - 删除过时的单元测试(已被验收测试覆盖)
15 KiB
15 KiB
业务流程测试 (Flow Tests)
流程测试验证多个 API 组合的完整业务场景,确保端到端流程正确。
核心原则
- 来源于 Spec Business Flow:每个测试对应 Spec 中的一个 Business Flow
- 跨 API 验证:验证多个 API 调用的组合行为
- 状态共享:流程中的数据(如 ID)在 steps 之间传递
- 角色切换:不同 step 可能由不同角色执行
- 必须有破坏点和依赖声明
目录结构
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 卡导入激活流程
└── ...
测试模板
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 契约 | 验证业务场景 |
状态共享模式
使用包级变量
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 必须声明依赖:
// ------------------------------------------------------------
// 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")
}
// ...
})
多角色流程
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 的影响:
// 破坏点:如果套餐创建 API 不返回 ID,后续步骤无法执行
// 破坏点:如果分配 API 不检查套餐是否存在,可能分配无效套餐
// 破坏点:如果代理商查询不过滤 shop_id,会看到其他店铺的数据
// 破坏点:如果订单创建不验证套餐有效期,可能购买过期套餐
// 破坏点:如果佣金计算不在事务中,可能导致数据不一致
异常流程测试
流程测试也应覆盖异常场景:
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)
})
}
运行测试
# 运行所有流程测试
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 的对应关系
# Spec 中的 Business Flow
### Flow: 套餐完整生命周期
**参与者**: 平台管理员, 代理商
**流程步骤**:
1. **创建套餐**
- 角色: 平台管理员
- 调用: POST /api/admin/packages
- 预期: 返回套餐 ID
2. **分配给代理商**
- 角色: 平台管理员
- 调用: POST /api/admin/shop-packages
- 输入: 套餐 ID + 店铺 ID
- 预期: 分配成功
3. **代理商查看可售套餐**
- 角色: 代理商
- 调用: GET /api/admin/shop-packages
- 预期: 列表包含刚分配的套餐
直接转换为测试代码:
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
})
}
完整示例
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: 如何处理异步操作?
使用轮询等待:
// 等待异步任务完成
for i := 0; i < maxRetries; i++ {
status := checkStatus()
if status == "completed" {
break
}
time.Sleep(interval)
}
Q: 流程测试太慢怎么办?
- 使用
t.Parallel()让不同流程并行(注意数据隔离) - 减少 sleep 时间,增加轮询频率
- 考虑将部分验证移到验收测试