# 验收测试 (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) }) ```