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

8.1 KiB
Raw Blame History

验收测试 (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     # 店铺套餐分配验收测试
└── ...

测试模板

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)

t.Run("Scenario_成功创建资源", func(t *testing.T) {
	// 测试正常流程
})

参数校验

t.Run("Scenario_参数缺失返回400", func(t *testing.T) {
	// 测试缺少必填参数
})

t.Run("Scenario_参数格式错误返回400", func(t *testing.T) {
	// 测试参数格式不符合要求
})

权限校验

t.Run("Scenario_无权限返回403", func(t *testing.T) {
	// 测试权限不足的情况
})

t.Run("Scenario_跨店铺访问返回403", func(t *testing.T) {
	// 测试越权访问
})

业务规则

t.Run("Scenario_重复创建返回409", func(t *testing.T) {
	// 测试业务规则冲突
})

t.Run("Scenario_删除已使用资源返回400", func(t *testing.T) {
	// 测试业务规则限制
})

破坏点注释规范

每个测试必须包含"破坏点"注释,说明什么代码变更会导致测试失败:

// 破坏点:如果删除 handler.Create 中的 store.Create 调用,此测试将失败
// 破坏点:如果移除参数校验中的 name 必填检查,此测试将失败
// 破坏点:如果响应不包含创建的资源 ID此测试将失败
// 破坏点:如果删除权限检查中间件,此测试将失败

为什么需要破坏点

  1. 证明测试真正验证了功能
  2. 帮助理解测试意图
  3. 重构时快速定位影响

Table-Driven 模式

对于同一 API 的多个场景,使用 table-driven 模式:

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)))
		})
	}
}

运行测试

# 运行所有验收测试
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 键
  • 身份切换:支持不同角色的请求
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 的对应关系

# Spec 中的 Scenario

#### Scenario: 成功创建套餐
- **GIVEN** 用户已登录且有创建权限
- **WHEN** POST /api/admin/packages with valid data
- **THEN** 返回 201 和套餐详情
- **AND** 数据库中存在该套餐记录

对应测试:

// 直接从 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 阶段创建必要的前置数据:

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)
})