跳至主要內容

测试金字塔:如何制定团队的测试策略

郑天祺大约 14 分钟测试测试团队策略Bug

测试金字塔:如何制定团队的测试策略

测试不只是写代码,更是团队策略。本文帮你建立科学的测试体系,告别"写了很多测试但 Bug 还是很多"的困境。

1. 测试金字塔模型

1.1 经典测试金字塔

测试金字塔(由 Mike Cohn 提出,后被广泛采用)
                              投入成本
         ╱──────────────╲        高
        ╱     E2E        ╲      /\
       ╱   (端到端测试)   ╲     /  \
      ╱──────────────────╲    /    \
     ╱      集成测试       ╲   /      \
    ╱  (Service/API层)    ╲  /  ROI   \
   ╱────────────────────────╲/   分    \
  ╱      单元测试             ╲   析    \
 ╱   (类/方法级别,Mock依赖)  ╲  线     \
╱──────────────────────────────╲──────────╲
                                   数量

核心原则

层级比例速度可靠性维护成本反馈速度
单元测试70%极快(ms)即时
集成测试20%快(s)较高分钟级
E2E 测试10%慢(min)低(Flaky)小时级

1.2 各层的具体内容

单元测试(Unit)——底座

测试对象:单个类/方法
依赖处理:Mock 所有外部依赖
典型工具:JUnit 5 + Mockito
运行时间:< 1 秒

测试什么:
  ├── Service 层:业务逻辑、计算、状态机
  ├── Util 类:纯函数、转换逻辑
  ├── Domain 实体:领域规则、不变量
  └── Validator:校验逻辑

不测试什么:
  ├── Controller(做集成测试更好)
  ├── Repository(做集成测试更好)
  ├── 简单 getter/setter
  └── 框架配置代码

集成测试(Integration)——中部

测试对象:多个组件的协作
依赖处理:真实的数据库、消息队列、缓存
典型工具:Spring Boot Test + Testcontainers
运行时间:< 10 秒

测试什么:
  ├── Repository + 数据库:SQL 查询、事务
  ├── Controller + Service + Repository:API 全链路
  ├── 消息队列:生产 → 消费链路
  ├── 外部 API Client:序列化/反序列化
  └── 安全配置:认证 → 授权 → 访问控制

端到端测试(E2E)——顶部

测试对象:完整用户流程
依赖处理:全部真实环境
典型工具:Selenium / Playwright / Cypress
运行时间:分钟级

测试什么:
  ├── 核心用户旅程(Happy Path)
  ├── 登录 → 操作 → 登出
  └── 跨系统的完整流程

不测试什么:
  ├── 边界条件(用单元测试覆盖)
  ├── 各种异常情况(用集成测试覆盖)
  └── 每个页面交互(选最重要的)

2. 反模式:冰淇淋金字塔与沙漏型

2.1 冰淇淋金字塔(Anti-Pattern #1)

         ╱──────────────────────╲
        ╱    大量 E2E 测试        ╲    ← 最多!
       ╱                          ╲
      ╱────────────────────────────╲
     ╱       少量的集成测试          ╲   ← 很少
    ╱                                ╲
   ╱                                  ╲
  ╱          极少单元测试               ╲ ← 几乎没!
 ╱────────────────────────────────────────╲

症状

  • "我们测试环境又挂了,今天没法提测"
  • E2E 测试跑一次要 2-3 小时
  • 经常因为"环境问题"而不是代码问题导致测试失败
  • 测试团队比开发团队还大

后果

  • 反馈极慢(提交代码后几小时才知道有没有 Bug)
  • Flaky Test 泛滥(测试本身不稳定,经常误报)
  • 维护成本指数级上升
  • 开发人员对测试失去信心

为何会这样

  • 管理层只看"用户验收测试"的结果
  • QA 团队主导测试,天然倾向于 E2E
  • 开发和测试分离的组织结构
  • "单元测试看不出端到端有没有问题"的误解

2.2 沙漏型(Anti-Pattern #2)

         ╱──────────────────────╲
        ╱     大量的 E2E 测试      ╲
       ╱──────────────────────────╲
      ╱                            ╲
     ╱        几乎没有集成测试       ╲
    ╱                                ╲
   ╱                                  ╲
  ╱          大量单元测试               ╲
 ╱────────────────────────────────────────╲

症状

  • 单元测试覆盖率 90%+,但线上经常出 Bug
  • 单元测试通过了,集成到真实环境就炸
  • 数据库查询逻辑没有测试(只 Mock 了 Repository)
  • E2E 测试经常因为小问题整个流程失败

问题

  • 缺少了"中间层"的验证
  • 单元测试的 Mock 和真实环境差距太大
  • E2E 测试"一坏全坏",不具备问题定位能力

2.3 如何纠正

恢复正常金字塔的行动计划:

第 1 周:
  □ 统计当前测试分布(单元/集成/E2E 各有多少)
  □ 找出最不稳定的 E2E 测试,标记为"可降级"

第 2-4 周:
  □ 将 E2E 的核心流程缩小到 5-10 个
  □ 把 E2E 能覆盖的场景下沉为集成测试
  □ 引入 Testcontainers,让集成测试更容易写

第 2-3 个月:
  □ 建立 PR Review 中的"测试到位率"检查
  □ 新功能必须有对应层的测试
  □ 逐步补齐老代码的关键路径测试

3. 各层测试的投入产出比

3.1 ROI 分析

┌─────────────────────────────────────────────────────────────────┐
│                     各层测试 ROI 对比                              │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│  单元测试                                                        │
│  ├── 编写时间:5-10 min/个                                       │
│  ├── 维护成本:低(Mock 隔离了变化)                                 │
│  ├── Bug 发现率:高(覆盖所有代码路径)                              │
│  ├── 误报率:极低(不依赖外部环境)                                 │
│  └── ROI:⭐⭐⭐⭐⭐(最高)                                        │
│                                                                 │
│  集成测试                                                        │
│  ├── 编写时间:15-30 min/个                                      │
│  ├── 维护成本:中(需要维护测试数据)                                │
│  ├── Bug 发现率:中高(发现组件交互问题)                            │
│  ├── 误报率:低(Testcontainers 提供稳定环境)                      │
│  └── ROI:⭐⭐⭐⭐                                                │
│                                                                 │
│  E2E 测试                                                        │
│  ├── 编写时间:30-60 min/个                                       │
│  ├── 维护成本:高(页面改动就要更新测试)                            │
│  ├── Bug 发现率:低(只测 Happy Path)                             │
│  ├── 误报率:高(网络波动、浏览器差异、环境问题)                      │
│  └── ROI:⭐⭐(慎用!)                                           │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘

3.2 资源分配建议

如果你的团队每周有 40 小时的测试相关时间:

├── 单元测试:28 小时(70%)
│     └── 新增功能 + 重构老代码
├── 集成测试:8 小时(20%)
│     └── Repository 查询 + API 关键链路
├── E2E 测试:2 小时(5%)
│     └── 核心用户旅程(不超过 10 个场景)
└── 测试基础设施:2 小时(5%)
      └── CI 优化、测试数据管理、工具链改进

4. 测试用例设计方法

4.1 等价类划分法

将输入分成若干等价类,每类中选一个代表值来测试。

/**
 * 场景:用户年龄验证
 * 规则:18-65 岁可以注册,其他年龄拒绝
 */
@Test
@DisplayName("年龄验证 → 等价类划分")
void ageValidation() {
    AgeValidator validator = new AgeValidator();

    // ─── 有效等价类 ───
    // 代表值:25(18-65之间的任意值都等价)

    // ─── 无效等价类 ───
    // 等价类1: < 18(未成年)
    assertThrows(IllegalArgumentException.class,
        () -> validator.validate(17));

    // 等价类2: > 65(超龄)
    assertThrows(IllegalArgumentException.class,
        () -> validator.validate(66));

    // 等价类3: 负数
    assertThrows(IllegalArgumentException.class,
        () -> validator.validate(-1));

    // 等价类4: 0
    assertThrows(IllegalArgumentException.class,
        () -> validator.validate(0));
}

4.2 边界值分析法

边界是最容易出错的地方。对每个边界,测试 边界本身边界-1边界+1

/**
 * 场景:订单金额折扣计算
 * 规则:
 *   满 100 减 10
 *   满 200 减 30
 *   满 500 减 100
 */

@ParameterizedTest
@CsvSource({
    // 金额, 期望折扣
    // ─── 0-99: 无折扣 ───
    "0,    0",    // 边界:最小值
    "99,   0",    // 边界-1(100的边界)
    "100,  10",   // 边界本身
    "101,  10",   // 边界+1
    // ─── 100-199: 减10 ───
    "199,  10",   // 边界-1
    "200,  30",   // 边界本身
    "201,  30",   // 边界+1
    // ─── 200-499: 减30 ───
    "499,  30",   // 边界-1
    "500,  100",  // 边界本身
    "501,  100",  // 边界+1
})
@DisplayName("折扣计算 → 边界值分析")
void discountBoundaryTest(BigDecimal amount, BigDecimal expectedDiscount) {
    DiscountCalculator calculator = new DiscountCalculator();
    assertEquals(expectedDiscount, calculator.calculate(amount));
}

4.3 场景法(业务流程测试)

/**
 * 场景:电商退货流程
 */
@Nested
@DisplayName("退货流程")
class ReturnProcess {

    @Test
    @DisplayName("场景1:正常退货 → 收到货后7天内,商品完好")
    void normalReturn() {
        // Given: 已完成的订单,收货时间在 7 天内
        Order order = createOrder(OrderStatus.COMPLETED, LocalDateTime.now().minusDays(3));

        // When: 申请退货
        ReturnRequest result = returnService.applyReturn(order.getId(),
                "尺寸不合适");

        // Then: 退货申请成功
        assertEquals(ReturnStatus.PENDING_APPROVAL, result.getStatus());
    }

    @Test
    @DisplayName("场景2:超期退货 → 收到货超过7天")
    void overdueReturn() {
        // Given: 已完成的订单,收货时间超过 7 天
        Order order = createOrder(OrderStatus.COMPLETED, LocalDateTime.now().minusDays(10));

        // When & Then: 申请退货被拒绝
        BusinessException ex = assertThrows(BusinessException.class,
            () -> returnService.applyReturn(order.getId(), "不想要了"));

        assertEquals("已超过7天退货期限", ex.getMessage());
    }

    @Test
    @DisplayName("场景3:商品已使用 → 不可退货")
    void usedProductReturn() {
        Order order = createOrder(OrderStatus.COMPLETED, LocalDateTime.now().minusDays(1));
        // 标记商品已拆封使用
        markItemAsUsed(order.getItems().get(0));

        BusinessException ex = assertThrows(BusinessException.class,
            () -> returnService.applyReturn(order.getId(), "已拆封"));

        assertEquals("已拆封商品不支持无理由退货", ex.getMessage());
    }
}

4.4 状态机测试

/**
 * 订单状态机:
 *   PENDING → CONFIRMED → SHIPPED → DELIVERED
 *                    ↓         ↓
 *               CANCELLED   RETURNING → RETURNED
 */
@Nested
@DisplayName("订单状态转换")
class OrderStatusTransition {

    @ParameterizedTest
    @CsvSource({
        "PENDING,  CONFIRMED,  true",   // 合法转换
        "PENDING,  CANCELLED,  true",   // 合法转换
        "PENDING,  SHIPPED,    false",  // 非法:必须先确认
        "CONFIRMED, SHIPPED,   true",
        "CONFIRMED, CANCELLED,  true",
        "CONFIRMED, DELIVERED, false",  // 非法:必须先发货
        "SHIPPED,  DELIVERED,  true",
        "SHIPPED,  RETURNING,  true",
        "SHIPPED,  CANCELLED,  false",  // 非法:已发货不能取消
        "DELIVERED, RETURNING, true",
        "DELIVERED, SHIPPED,   false",  // 非法:不能回退
        "CANCELLED, CONFIRMED, false",  // 非法:取消后不可恢复
    })
    @DisplayName("状态转移合法性验证")
    void transitionValidity(OrderStatus from, OrderStatus to, boolean expected) {
        assertEquals(expected, from.canTransitionTo(to),
            () -> String.format("%s → %s 应该是 %s", from, to, expected ? "合法" : "非法"));
    }
}

5. 测试覆盖率陷阱

5.1 高覆盖率 ≠ 高质量

场景一:覆盖率 95%,但线上 Bug 不断

原因分析:
  ├── 测试了大量 getter/setter(刷覆盖率)
  ├── 只测了正常路径,没测异常路径
  ├── 没有断言或断言过于宽松(只 assertNotNull)
  └── 测试代码本身有 Bug(Mock 设置错了但测试还是过了)

场景二:覆盖率 60%,但质量很高

原因分析:
  ├── 排除了不需要测的代码(配置、POJO)
  ├── 剩下的 60% 覆盖了所有关键业务逻辑
  ├── 每个测试都有有意义的断言
  └── 异常路径和边界条件都覆盖了

5.2 "僵尸测试"识别

// ❌ 僵尸测试:有覆盖率但没质量
@Test
void zombieTest() {
    User user = userService.createUser(request);
    assertNotNull(user);  // 这是唯一的断言!什么都没验证!
}

// ✅ 有质量的测试
@Test
void meaningfulTest() {
    User user = userService.createUser(
        new CreateUserRequest("张三", "P@ssw0rd123!", "zhangsan@test.com"));

    assertAll("创建的用户信息",
        () -> assertEquals("张三", user.getUsername()),
        () -> assertNotEquals("P@ssw0rd123!", user.getPassword()),  // 确认密码被加密
        () -> assertTrue(user.getPassword().startsWith("$2a$")),     // BCrypt 格式
        () -> assertEquals("zhangsan@test.com", user.getEmail()),
        () -> assertTrue(user.isEnabled()),
        () -> assertNotNull(user.getCreatedAt())
    );
}

5.3 正确的覆盖率策略

// Jacoco 规则:按包分层设置覆盖率阈值
<rules>
    <!-- 核心业务逻辑:高要求 -->
    <rule>
        <element>PACKAGE</element>
        <includes>
            <include>com.example.service.*</include>
        </includes>
        <limits>
            <limit>
                <counter>LINE</counter>
                <value>COVEREDRATIO</value>
                <minimum>0.90</minimum>  <!-- 90% 行覆盖率 -->
            </limit>
            <limit>
                <counter>BRANCH</counter>
                <value>COVEREDRATIO</value>
                <minimum>0.85</minimum>  <!-- 85% 分支覆盖率 -->
            </limit>
        </limits>
    </rule>

    <!-- 工具类:中等要求 -->
    <rule>
        <element>PACKAGE</element>
        <includes>
            <include>com.example.util.*</include>
        </includes>
        <limits>
            <limit>
                <counter>LINE</counter>
                <value>COVEREDRATIO</value>
                <minimum>0.80</minimum>
            </limit>
        </limits>
    </rule>

    <!-- Controller/Repository:较低要求(集成测试覆盖更好) -->
    <rule>
        <element>PACKAGE</element>
        <includes>
            <include>com.example.controller.*</include>
            <include>com.example.repository.*</include>
        </includes>
        <limits>
            <limit>
                <counter>LINE</counter>
                <value>COVEREDRATIO</value>
                <minimum>0.60</minimum>
            </limit>
        </limits>
    </rule>
</rules>

<!-- 排除不需要检查的 -->
<excludes>
    <exclude>**/config/**</exclude>
    <exclude>**/dto/**</exclude>
    <exclude>**/entity/**</exclude>
    <exclude>**/mapper/**</exclude>
    <exclude>**/*Application*</exclude>
</excludes>

6. 如何推动团队写测试

6.1 从 0 到 1 的阻力分析

┌─────────────────────────────────────────────────────────┐
│              常见的抵触心理与应对                           │
├─────────────────────────────────────────────────────────┤
│                                                         │
│  "没时间写测试"                                            │
│  → 反驳:不写测试后期修 Bug 花的时间更多                       │
│  → 策略:先写最关键的 20% 逻辑,不是所有代码都要测试             │
│                                                         │
│  "写测试比写代码还难"                                       │
│  → 反驳:那是因为代码本身设计不好(不可测试)                    │
│  → 策略:提供模板和示例测试类,降低入门门槛                     │
│                                                         │
│  "需求老变,测试白写了"                                      │
│  → 反驳:需求变时测试更能帮你确认哪些地方被影响了                 │
│  → 策略:测试测的是行为,不是实现细节                           │
│                                                         │
│  "我们这是老项目,没法加测试"                                 │
│  → 反驳:不是要求 100%,新代码加测试,修改老代码时加测试          │
│  → 策略:童子军规则——"每次离开时,让代码比你来时更干净一点" ⭐     │
│                                                         │
└─────────────────────────────────────────────────────────┘

6.2 渐进式推行路线图

Phase 1: 培育土壤(第 1-2 周)
├── 在 CI 中加入测试覆盖率报告(只展示,不强制)
├── 团队分享会:分享一个"测试救了命"的真实案例
├── 准备项目模板:基类、Mock 示例、常用测试数据
└── 结对编程:资深带新人写第一个测试

Phase 2: 建立习惯(第 3-4 周)
├── PR Review Checklist 加入:"有对应的测试吗?"
├── Bug 修复必须包含测试(复现 Bug + 验证修复)
├── 新功能代码覆盖率 ≥ 80% 才允许合并
└── 每周团队表扬"本周最赞测试"

Phase 3: 逐步覆盖(第 2-3 月)
├── 识别 Top 10 高风险模块,优先补齐测试
├── CI 中加入覆盖率基线(不能比上周低)
├── 重构老代码前先加"防护网测试"
└── 逐步提高覆盖率阈值

Phase 4: 形成文化(持续)
├── 测试是 PR 的默认组成部分
├── 代码 Review 时关注测试质量而非只是覆盖率
├── 新人入职第一周就是写测试(通过测试了解系统)
└── "没测试 = 没完成"

6.3 度量与激励

❌ 错误的度量(会导致行为扭曲):
  ├── "每个方法的覆盖率必须 > 80%"
  │   → 导致大量没有断言的僵尸测试
  ├── "测试数量必须 > X 个"
  │   → 导致拆分测试凑数量
  └── "测试通过率必须 100%"
      → 导致删除不稳定的测试而非修复

✅ 正确的度量:
  ├── Mutation Testing(变异测试)得分
  │   → 能杀死多少变异(故意引入的 Bug)
  ├── 关键业务路径覆盖率
  │   → 核心流程而不是所有代码
  ├── Bug 逃逸率
  │   → 上线后发现 Bug 的比例是否下降
  └── 重构信心指数(定性)
      → 团队是否敢于大胆重构

7. 测试即文档的理念

7.1 测试为什么是最好的文档

文档 vs 测试:

文档(Wiki/Confluence):
  ├── 写的时候最清晰 → 写完就开始过时
  ├── 代码改了,文档没改 → 慢慢变成"仅供参考"
  ├── 新人不确定文档是否准确 → 不敢依赖
  └── 维护需要额外的纪律和时间

测试:
  ├── 永远和代码同步(不同步就红,红灯就会修)
  ├── 提供可执行的规格说明
  ├── 新人可以运行测试理解系统行为
  └── 不需要额外维护成本(它是开发的一部分)

7.2 把测试写成规格说明书

/**
 * ❌ 糟糕的测试命名 → 看不懂测什么
 */
@Test
void test1() { ... }

@Test
void testCreateOrder() { ... }

/**
 * ✅ 好的测试命名 → 读测试即读文档
 * 命名模式:方法名_场景_预期结果
 * 或使用 @DisplayName 写完整的中文描述
 */
@Nested
@DisplayName("订单创建")
class OrderCreation {

    @Test
    @DisplayName("成功创建 → 用户登录且库存充足时,创建订单并返回订单信息")
    void successWhenUserLoggedInAndStockSufficient() { ... }

    @Test
    @DisplayName("创建失败 → 用户未登录时,抛出 401 Unauthorized")
    void failWhenUserNotLoggedIn() { ... }

    @Test
    @DisplayName("创建失败 → 商品库存为 0 时,抛出库存不足异常")
    void failWhenProductOutOfStock() { ... }

    @Test
    @DisplayName("创建失败 → 商品已下架时,抛出商品不可用异常")
    void failWhenProductOffline() { ... }

    @Test
    @DisplayName("价格计算 → 多件商品 + 优惠券,总价 = 商品总价 - 优惠金额 + 运费")
    void calculateTotalPriceWithMultipleItemsAndCoupon() { ... }
}

运行测试后的报告就是一份活的文档:

订单创建
  ├── ✅ 成功创建 → 用户登录且库存充足时,创建订单并返回订单信息
  ├── ✅ 创建失败 → 用户未登录时,抛出 401 Unauthorized
  ├── ✅ 创建失败 → 商品库存为 0 时,抛出库存不足异常
  ├── ✅ 创建失败 → 商品已下架时,抛出商品不可用异常
  └── ✅ 价格计算 → 多件商品 + 优惠券,总价 = 商品总价 - 优惠金额 + 运费

7.3 实践建议

让测试成为文档的 6 个技巧:

1. 用 @DisplayName 写完整的业务场景描述
   不是"testAdd",而是"两数相加 → 正数加负数,返回正确结果"

2. 用 @Nested 组织成业务模块
   不是"UserServiceTest",而是"用户服务 > 注册 > 邮箱已存在时拒绝"

3. BDD 风格的 Given-When-Then 结构
   // Given: 库存为 5 件
   // When: 用户购买 10 件
   // Then: 抛出库存不足异常

4. 测试数据要有业务含义
   // ❌ User user = new User("test", "test@test.com")
   // ✅ User user = new User("张三", "zhangsan@real-email.com")

5. 测试辅助方法封装
   // createEligibleUser() 比 new User(构建一大堆字段) 更清晰

6. 测试文件放在被测代码旁边(测试即文档的一部分)
   // src/main/java/com/example/order/OrderService.java
   // src/test/java/com/example/order/OrderServiceTest.java

总结

测试金字塔不是教条,而是经过验证的实践指南。好的测试策略带来三个层次的收益:

个人层面:
├── 编码信心:改代码不怕改坏
├── 调试效率:不用频繁地手工测试
└── 设计提升:测试驱动更好的代码结构

团队层面:
├── Code Review 效率:测试描述了代码行为
├── 新人上手速度:看测试就知道系统怎么用
└── 知识传递:测试是活的文档

业务层面:
├── 交付速度:少了大量手工回归测试时间
├── 线上质量:Bug 在开发阶段就被拦截
└── 技术债务可控:有测试保护,重构不慌

团队行动清单

□ 本周内:统计团队当前的测试分布(单元/集成/E2E)
□ 本周内:找出最不稳定的 3 个测试,修复或降级
□ 下周:在 CI 中加入测试覆盖率报告
□ 下月:新代码 PR 要求有关键路径的测试
□ 下季度:覆盖率基线设定,逐步提升
□ 持续:每次团队回顾时讨论测试策略的有效性

终极目标:不是"我们要写很多测试",而是"测试让我们更有信心更快地交付价值"。

上次编辑于:
贡献者: zhengtianqi