Files
junhong_cmp_fiber/tests/acceptance/README.md
huang b18ecfeb55
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 6m29s
refactor: 一次性佣金配置从套餐级别提升到系列级别
主要变更:
- 新增 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)
- 删除过时的单元测试(已被验收测试覆盖)
2026-02-04 14:28:44 +08:00

323 lines
8.1 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.
# 验收测试 (Acceptance Tests)
验收测试验证单个 API 的契约:给定输入,期望输出。
## 核心原则
1. **来源于 Spec**:每个测试用例对应 Spec 中的一个 Scenario
2. **测试先于实现**:在功能实现前生成,预期全部 FAIL
3. **契约验证**:验证 API 的输入输出契约,不测试内部实现
4. **必须有破坏点**:每个测试必须注释说明什么代码变更会导致失败
## 目录结构
```
tests/acceptance/
├── README.md # 本文件
├── account_acceptance_test.go # 账号管理验收测试
├── package_acceptance_test.go # 套餐管理验收测试
├── shop_package_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/{change-name}/specs/{capability}/spec.md
// ============================================================
func Test{Capability}_Acceptance(t *testing.T) {
env := testutils.NewIntegrationTestEnv(t)
// ------------------------------------------------------------
// Scenario: {场景名称}
// GIVEN: {前置条件}
// WHEN: {触发动作}
// THEN: {预期结果}
// AND: {额外验证}
//
// 破坏点:{描述什么代码变更会导致此测试失败}
// ------------------------------------------------------------
t.Run("Scenario_{场景名称}", func(t *testing.T) {
// GIVEN: 设置前置条件
client := env.AsSuperAdmin()
// WHEN: 执行操作
body := map[string]interface{}{
// 请求体
}
resp, err := client.Request("POST", "/api/admin/xxx", body)
require.NoError(t, err)
// THEN: 验证结果
assert.Equal(t, 200, resp.StatusCode)
var result map[string]interface{}
err = resp.JSON(&result)
require.NoError(t, err)
assert.Equal(t, 0, int(result["code"].(float64)))
// AND: 额外验证(如数据库状态)
// ...
})
}
```
## 测试分类
### 正常场景 (Happy Path)
```go
t.Run("Scenario_成功创建资源", func(t *testing.T) {
// 测试正常流程
})
```
### 参数校验
```go
t.Run("Scenario_参数缺失返回400", func(t *testing.T) {
// 测试缺少必填参数
})
t.Run("Scenario_参数格式错误返回400", func(t *testing.T) {
// 测试参数格式不符合要求
})
```
### 权限校验
```go
t.Run("Scenario_无权限返回403", func(t *testing.T) {
// 测试权限不足的情况
})
t.Run("Scenario_跨店铺访问返回403", func(t *testing.T) {
// 测试越权访问
})
```
### 业务规则
```go
t.Run("Scenario_重复创建返回409", func(t *testing.T) {
// 测试业务规则冲突
})
t.Run("Scenario_删除已使用资源返回400", func(t *testing.T) {
// 测试业务规则限制
})
```
## 破坏点注释规范
每个测试必须包含"破坏点"注释,说明什么代码变更会导致测试失败:
```go
// 破坏点:如果删除 handler.Create 中的 store.Create 调用,此测试将失败
// 破坏点:如果移除参数校验中的 name 必填检查,此测试将失败
// 破坏点:如果响应不包含创建的资源 ID此测试将失败
// 破坏点:如果删除权限检查中间件,此测试将失败
```
**为什么需要破坏点**
1. 证明测试真正验证了功能
2. 帮助理解测试意图
3. 重构时快速定位影响
## Table-Driven 模式
对于同一 API 的多个场景,使用 table-driven 模式:
```go
func TestPackage_Create_Validation(t *testing.T) {
env := testutils.NewIntegrationTestEnv(t)
tests := []struct {
name string
body map[string]interface{}
expectedStatus int
expectedCode int
breakpoint string
}{
{
name: "名称为空",
body: map[string]interface{}{
"name": "",
"price": 9900,
},
expectedStatus: 400,
expectedCode: 4000, // CodeInvalidParam
breakpoint: "移除 name 必填校验",
},
{
name: "价格为负",
body: map[string]interface{}{
"name": "测试套餐",
"price": -100,
},
expectedStatus: 400,
expectedCode: 4000,
breakpoint: "移除 price >= 0 校验",
},
{
name: "时长为0",
body: map[string]interface{}{
"name": "测试套餐",
"price": 9900,
"duration": 0,
},
expectedStatus: 400,
expectedCode: 4000,
breakpoint: "移除 duration > 0 校验",
},
}
for _, tt := range tests {
t.Run("Scenario_"+tt.name, func(t *testing.T) {
// 破坏点: {tt.breakpoint}
client := env.AsSuperAdmin()
resp, err := client.Request("POST", "/api/admin/packages", tt.body)
require.NoError(t, err)
assert.Equal(t, tt.expectedStatus, resp.StatusCode)
var result map[string]interface{}
err = resp.JSON(&result)
require.NoError(t, err)
assert.Equal(t, tt.expectedCode, int(result["code"].(float64)))
})
}
}
```
## 运行测试
```bash
# 运行所有验收测试
source .env.local && go test -v ./tests/acceptance/...
# 运行特定功能的验收测试
source .env.local && go test -v ./tests/acceptance/... -run TestPackage
# 运行特定场景
source .env.local && go test -v ./tests/acceptance/... -run "Scenario_成功创建"
```
## 测试环境
验收测试使用 `IntegrationTestEnv`,提供:
- **事务隔离**:每个测试在独立事务中运行,自动回滚
- **Redis 清理**:测试前自动清理相关 Redis 键
- **身份切换**:支持不同角色的请求
```go
env := testutils.NewIntegrationTestEnv(t)
// 以超级管理员身份请求
env.AsSuperAdmin().Request("GET", "/api/admin/xxx", nil)
// 以平台用户身份请求
env.AsPlatformUser(accountID).Request("GET", "/api/admin/xxx", nil)
// 以代理商身份请求
env.AsShopAgent(shopID).Request("GET", "/api/admin/xxx", nil)
// 以企业用户身份请求
env.AsEnterprise(enterpriseID).Request("GET", "/api/admin/xxx", nil)
```
## 与 Spec 的对应关系
```markdown
# Spec 中的 Scenario
#### Scenario: 成功创建套餐
- **GIVEN** 用户已登录且有创建权限
- **WHEN** POST /api/admin/packages with valid data
- **THEN** 返回 201 和套餐详情
- **AND** 数据库中存在该套餐记录
```
对应测试:
```go
// 直接从 Spec Scenario 转换
t.Run("Scenario_成功创建套餐", func(t *testing.T) {
// GIVEN: 用户已登录且有创建权限
client := env.AsSuperAdmin()
// WHEN: POST /api/admin/packages with valid data
resp, err := client.Request("POST", "/api/admin/packages", validBody)
// THEN: 返回 201 和套餐详情
assert.Equal(t, 201, resp.StatusCode)
// AND: 数据库中存在该套餐记录
// 验证数据库状态
})
```
## 常见问题
### Q: 验收测试和集成测试的区别?
| 方面 | 验收测试 | 集成测试 |
|------|---------|---------|
| 来源 | Spec Scenario | 开发者编写 |
| 目的 | 验证 API 契约 | 验证系统集成 |
| 粒度 | 单 API | 可能涉及多 API |
| 时机 | 实现前生成 | 实现后编写 |
### Q: 测试 PASS 了但功能还没实现?
说明测试写得太弱。检查:
1. 是否验证了响应状态码
2. 是否验证了响应体结构
3. 是否验证了数据库状态变化
4. 破坏点是否准确
### Q: 如何处理需要前置数据的测试?
在 GIVEN 阶段创建必要的前置数据:
```go
t.Run("Scenario_删除已分配的套餐失败", func(t *testing.T) {
// GIVEN: 存在一个已分配给店铺的套餐
client := env.AsSuperAdmin()
// 创建套餐
createResp, _ := client.Request("POST", "/api/admin/packages", packageBody)
var createResult map[string]interface{}
createResp.JSON(&createResult)
packageID := uint(createResult["data"].(map[string]interface{})["id"].(float64))
// 分配给店铺
client.Request("POST", "/api/admin/shop-packages", map[string]interface{}{
"package_id": packageID,
"shop_id": 1,
})
// WHEN: 尝试删除套餐
resp, _ := client.Request("DELETE", fmt.Sprintf("/api/admin/packages/%d", packageID), nil)
// THEN: 返回 400
assert.Equal(t, 400, resp.StatusCode)
})
```