246|单元测试与可测试性设计:测试金字塔、Mock、契约和覆盖率质量

工程效能、测试与DDD约 15 分钟阅读8,536 字符
知识库文档 247 / 251

阅读时间:约 45 分钟 | 难度:中等偏深 | 前置知识:Java 基础、构建工具(Maven/Gradle)


一、为什么要写单元测试

1.1 测试不是"额外工作",是设计工具

大多数工程师把测试看作"写完代码之后顺手补一下"的体力活。这种认知导致测试沦为形式——代码覆盖率够 80% 就交差,每个测试里塞几个 assertNotNull,CI 过了就算完。

实际情况是:单元测试是代码质量的唯一自动化守卫,也是架构设计的反馈器。

一个类很难测试 → 这个类的设计有问题。一个方法需要 Mock 十个依赖 → 这个方法违反了单一职责。测试写起来很痛苦 → 代码耦合度太高。这些信号比任何架构评审都更诚实。

1.2 写测试 vs 不写测试的真实成本

别拿"工期紧"当借口。来看看一组真实数据对比:

不写测试 写测试
开发速度 前两周快 前两周慢 20-30%
Bug 发现时机 测试环境 / 生产 本地,秒级反馈
单 Bug 修复成本 高(需要部署、重现、排查) 极低(本地即发现)
重构信心 零——不敢改 高——红灯变绿即可
新人上手 靠文档和口口相传 测试即文档
四个月后代码 无人敢动的祖传遗产 可维护的工程资产

结论非常直白:不写测试是把开发成本转移到了维护成本上,而且是加杠杆转移——1 小时的偷懒 = 后续 10 小时的排查。

1.3 单元测试在质量体系中的位置

单元测试不是独立存在的。它和集成测试、端到端测试、手工测试共同构成质量防护网。但单元测试有不可替代的优势:

  1. 速度:毫秒级反馈。集成测试跑一次少则数秒多则数分钟。
  2. 精确性:失败时定位到具体行。集成测试报错往往只知道"某个环节出了问题"。
  3. 隔离性:不依赖外部环境,CI 上稳定复现。
  4. 低成本:不需要数据库、中间件、容器。

核心原则:单元测试负责回答"代码写得对不对";集成测试负责回答"组件之间能不能配合";端到端测试负责回答"用户能不能用"。三者不能互相替代。


二、测试金字塔:你的测试策略指南

2.1 经典金字塔模型

文章配图
文章配图

Mike Cohn 在 2009 年提出的测试金字塔,至今仍是行业共识。核心思想很简单:越底层的测试应该越多,越上层的测试应该越少。

code
         /\
        /E2E\         端到端测试:少而精,覆盖关键业务流程
       /------\
      /  集成  \       集成测试:中等数量,验证组件交互
     /----------\
    /   单元测试   \    单元测试:数量最多,覆盖所有逻辑分支
   /--------------\

这种分层不是刻意的教条,而是基于经济性推导出来的:

  • 单元测试:编写成本低、执行快、定位准 → 应该大量写
  • 端到端测试:编写成本高、执行慢、定位模糊、环境脆弱 → 应该少写

理想比例:单元测试 70%,集成测试 20%,端到端测试 10%。

2.2 各层定义与实践

单元测试

  • 范围:单个类或方法
  • 依赖:全部 Mock
  • 时间:毫秒级
  • 环境:纯内存,无需外部服务
  • 典型框架:JUnit 5 + Mockito
java
@Test
void shouldDeductBalanceWhenWithdrawSucceeds() {
    // Arrange
    var account = new Account("123", new Money(1000));
    // Act
    account.withdraw(new Money(200));
    // Assert
    assertEquals(new Money(800), account.balance());
}

集成测试

  • 范围:多个组件协作
  • 依赖:部分真实(如数据库、MQ),部分 Mock
  • 时间:秒级
  • 环境:嵌入式数据库或 Testcontainers
  • 典型框架:Spring Boot Test + Testcontainers
java
@SpringBootTest
@Testcontainers
class OrderRepositoryTest {
    @Container
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:15");
    
    @Test
    void shouldPersistAndRetrieveOrder() {
        orderRepository.save(order);
        var found = orderRepository.findById(order.getId());
        assertTrue(found.isPresent());
    }
}

端到端测试

  • 范围:完整业务流程
  • 依赖:全部真实
  • 时间:分钟级
  • 环境:完整部署
  • 典型框架:Selenium、Playwright、REST Assured

2.3 反模式:你的金字塔可能长这样

冰淇淋锥(倒金字塔):大量手工测试和端到端测试,几乎没有单元测试。常见于"赶工期"项目——结果越赶越慢,因为每次回归都需要大量手工测试。

沙漏型:单元测试和端到端测试很多,但缺乏中间层的集成测试。导致端到端测试承担了太多集成验证的职责,运行越来越慢、越来越不稳定。

只有塔尖型:全靠手工测试。这在小型应用初期还能撑,一旦系统复杂度和团队规模上来,必然崩溃。

2.4 金字塔的现代调整

文章配图
文章配图

Swizec Teller 在 2018 年提出了"奖杯模型"(Testing Trophy),更强调集成测试的比重,适用于前端重交互的应用。但在后端 Java 系统中,金字塔模型仍然是最佳实践——只是别忘了给集成测试足够的空间。

对于微服务架构,金字塔应该变形为:

  • 每个服务内部:经典金字塔
  • 跨服务交互:API 契约测试替代端到端测试的重度使用
  • 关键全链路:少数端到端冒烟测试

三、单元测试的核心原则

3.1 FIRST 原则

好的单元测试应该满足五个条件:

原则 含义 违反示例
Fast 毫秒级完成 测试里连数据库
Independent 测试之间不依赖 testB 依赖 testA 创建的数据
Repeatable 每次运行结果一致 依赖当前时间/随机数的测试
Self-validating 不需要人工判断 打印日志让人去看对不对
Timely 与代码同步编写 写完功能三个月后补测试

3.2 AAA 模式

每个测试用例都应该遵循 Arrange-Act-Assert 三段式:

java
@Test
void shouldApplyDiscountForVIPCustomer() {
    // Arrange —— 准备数据和前置条件
    var vip = new Customer("张三", CustomerLevel.VIP);
    var order = new Order(vip);
    order.addItem(new Item("键盘", new Money(500)));

    // Act —— 执行被测试的行为
    order.applyDiscount();

    // Assert —— 验证结果
    assertEquals(new Money(425), order.totalAmount());  // VIP 享 85 折
}

一个测试只测一个行为。如果你看到多个 Act 段,说明这个测试承载了太多职责,应该拆分。

3.3 测试命名:可读性就是可维护性

Java 社区的测试命名已经收敛到一个清晰的惯例:

java
// 命名模式:should_预期行为_when_条件
shouldThrowInsufficientBalance_whenWithdrawExceedsBalance()
shouldReturnCachedValue_whenKeyExistsInCache()
shouldTimeoutAfter5Seconds_whenServiceUnavailable()

// 或者中文风格(适合团队非全英文环境)
应在余额不足时抛出异常()
缓存命中时不应调用数据库()

核心原则:从测试名就能知道测了什么、预期什么行为。失败时不需要读测试代码就能定位问题。


四、测试替身:Mock, Stub, Fake, Spy, Dummy

4.1 五种测试替身的定义

文章配图
文章配图

Gerard Meszaros 在《xUnit Test Patterns》中定义了五种测试替身。理解它们的区别是写好测试的基础。

code
                        测试替身(Test Double)
                              |
        ┌─────────────────────┼─────────────────────┐
        |                     |                     |
     Dummy                  Stub                  Mock
   (只传参不调用)       (预设返回值)        (验证行为)
                              |
                           Fake                 Spy
                    (轻量级真实实现)      (记录调用信息)
类型 作用 验证方式 示例
Dummy 只占位,方法不被调用 无 new Order(null, null) 中的 null 参数
Stub 返回预设值 状态验证 when(repo.findById(1)).thenReturn(user)
Mock 验证是否被正确调用 行为验证 verify(emailService).send(any())
Fake 轻量级内存实现 状态验证 内存版 UserRepository(用 HashMap)
Spy 记录调用信息,部分真实 行为 + 状态 @Spy 包装真实对象,部分方法 Mock

4.2 Mock vs Stub:这是两个完全不同的东西

文章配图
文章配图

这是日常讨论中最常见的混淆点。一句话说清:

Stub 验证状态:"调用后,结果对不对?" → assertEquals(expected, result)Mock 验证行为:"调用过程中,是否以正确的参数调了正确的依赖?" → verify(mock).method(args)

java
// Stub 示例:只关心返回值
@Test
void shouldReturnUser_whenIdExists() {
    // Stub:预设行为
    when(userRepo.findById(1L)).thenReturn(Optional.of(new User("张三")));
    
    // Act & Assert:验证状态
    User user = userService.getUser(1L);
    assertEquals("张三", user.getName());
}

// Mock 示例:关心是否正确调用了依赖
@Test
void shouldSendEmail_whenOrderConfirmed() {
    // Act
    orderService.confirm(orderId);
    
    // Assert:验证行为 —— 确认 emailService.send() 被调用了一次
    verify(emailService, times(1)).send(
        argThat(email -> email.getTo().equals(order.getEmail()))
    );
}

经验法则:

  • 查询 → Stub
  • 命令 → Mock
  • 与返回值无关的副作用 → Mock
  • 一个测试中同时用 Mock 和 Stub → 通常没问题,但要警惕——说明被测试的方法可能做了太多事

4.3 Mockito 核心用法

java
// 基础 Mock
@Mock
private OrderRepository orderRepository;

// 参数匹配器
when(orderRepository.findById(anyLong())).thenReturn(Optional.of(order));
when(orderRepository.findByStatus(eq(Status.PAID))).thenReturn(List.of(order1, order2));

// 自定义参数匹配
argThat(order -> order.getAmount().compareTo(new Money(100)) > 0)

// 多次调用返回不同值
when(counter.next()).thenReturn(1, 2, 3, 4, 5);

// 抛出异常
when(paymentGateway.charge(any())).thenThrow(new PaymentFailedException());

// Void 方法的行为验证
doThrow(new IllegalStateException()).when(service).delete(anyLong());

// 验证调用次数
verify(repository, times(1)).save(any());
verify(repository, never()).delete(any());
verify(repository, atLeastOnce()).findById(anyLong());

// 验证调用顺序
InOrder inOrder = inOrder(repo1, repo2);
inOrder.verify(repo1).save(any());
inOrder.verify(repo2).updateStatus(any());

4.4 过度 Mock 的危害

Mock 是把双刃剑。用得好,测试快速稳定;用得不好,测试变成对实现的复读机。

过度 Mock 的典型症状:

java
// 坏味道:Mock 了太多东西,测试只是在描述"步骤"
@Test
void shouldCreateOrder() {
    when(userRepo.findById(1L)).thenReturn(...);
    when(inventoryRepo.check(any())).thenReturn(...);
    when(pricingService.calculate(any())).thenReturn(...);
    when(paymentGateway.charge(any())).thenReturn(...);
    when(orderRepo.save(any())).thenReturn(...);
    when(notificationService.send(any())).thenReturn(...);
    
    orderService.create(request);
    
    verify(userRepo).findById(1L);
    verify(inventoryRepo).check(any());
    verify(pricingService).calculate(any());
    verify(paymentGateway).charge(any());
    verify(orderRepo).save(any());
    verify(notificationService).send(any());
}

这种测试的问题:

  1. 脆弱:重构实现细节(比如把两个 repo 调用合并)测试就挂了,即使行为正确
  2. 无价值:它只是在复述代码,没有验证任何业务逻辑
  3. 维护成本高:改一行代码要改五处 Mock

正确的 Mock 策略:

  • Mock 外部边界(数据库、消息队列、第三方 API)
  • 不要 Mock 你拥有的类型——除非它是外部边界的门面
  • 如果一个测试需要 Mock 超过 3 个依赖,考虑重构被测试类

五、可测试性设计

5.1 依赖注入:可测试性的基石

一个类能不能测试,在构造函数里就能看出来:

java
// 不可测试 —— 依赖是硬编码的
public class OrderService {
    private final OrderRepository repo = new OrderRepositoryImpl(
        new DataSource("jdbc:mysql://...")
    );
}

// 可测试 —— 依赖从外部注入
public class OrderService {
    private final OrderRepository repo;
    
    public OrderService(OrderRepository repo) {
        this.repo = repo;
    }
}

第二个版本在测试中只需要 new OrderService(mockRepo),无需任何 DI 容器。这就是为什么构造函数注入优于字段注入——它不仅更安全(防止 NPE),也更容易测试。

Spring 可以帮你做 DI,但测试不应该依赖 Spring 容器。好的单元测试不启动 Spring:

java
// 好的单元测试:纯 Java,不启动 Spring
@Test
void shouldCalculateTotalPrice() {
    var pricingService = new PricingService(new FixedDiscountPolicy(0.1));
    var total = pricingService.calculate(List.of(item1, item2));
    assertEquals(new Money(900), total);
}

5.2 六边形架构中的测试边界

文章配图
文章配图

六边形架构(端口与适配器)天然支持可测试性:

code
         ┌──────────────────────────┐
         │      领域模型(核心)       │
         │    → 纯单元测试,无需 Mock  │
         ├──────────────────────────┤
         │      应用服务层            │
         │    → Mock 端口接口即可      │
         ├──────────────────────────┤
         │      适配器层              │
         │    → 集成测试,连真实外部   │
         └──────────────────────────┘

这种分层使得:

  • 领域逻辑:纯函数,测试零依赖
  • 应用服务:Mock 端口接口,测试编排逻辑
  • 适配器:用 Testcontainers/WireMock 测试真实交互

5.3 隐式依赖是你的敌人

文章配图
文章配图

以下四种隐式依赖是单元测试的头号杀手:

1. 时间

java
// 坏:依赖系统时钟
if (LocalDate.now().isAfter(deadline)) { ... }

// 好:注入 Clock
public class DeadlineChecker {
    private final Clock clock;
    public boolean isOverdue(Order order) {
        return LocalDate.now(clock).isAfter(order.getDeadline());
    }
}
// 测试中:Clock.fixed(Instant.parse("2026-01-01T00:00:00Z"), ZoneOffset.UTC)

2. 随机数

java
// 坏
Math.random();
// 好:注入 Random 实例,测试中用固定 seed
new Random(42);

3. 文件系统和网络

java
// 坏:直接 new FileInputStream
// 好:依赖注入 InputStream/OutputStream,或封装为 FileStorage 接口

4. 单例和静态方法

java
// 坏:静态调用无法替换
DiscountCalculator.getGlobalDiscount();

// 好:通过实例方法调用
discountPolicy.calculateDiscount(order);

5.4 "可测试"的代码长什么样

可测试性不是事后补救,而是设计的结果。一个可测试的方法具备这些特征:

  1. 输入和输出明确:参数进,返回值出,没有隐式副作用
  2. 依赖显式:所有外部依赖通过参数或构造函数传入
  3. 状态隔离:不依赖全局状态,不修改共享的可变状态
  4. 职责单一:一个方法做一件事
java
// 可测试性满分
public Money calculateShippingFee(Address destination, Weight weight, ShippingPolicy policy) {
    if (policy.isFreeShipping(weight)) return Money.ZERO;
    return policy.calculate(destination, weight);
}
// 测试零 Mock,纯输入输出

六、覆盖率:数字背后的真相

6.1 覆盖率类型

类型 度量内容 典型工具
行覆盖 这行代码被执行过吗? JaCoCo
分支覆盖 if/else 的每个分支都走过吗? JaCoCo
路径覆盖 所有可能的执行路径都覆盖了吗? 极少使用,组合爆炸
变异测试 改动代码后测试会失败吗? PITest

JaCoCo 报告中最值得关注的是分支覆盖率,因为大多数 bug 藏在"没想到会走到这里"的分支里。

6.2 覆盖率 = 代码被执行过 ≠ 代码是正确的

文章配图
文章配图

覆盖率最高可以达到 100%,但 bug 一个不少。看这个例子:

java
@Test
void testDivide() {
    // 覆盖率 100%:每行都执行了
    assertEquals(2, calculator.divide(10, 5));  // 正常路径
    // 没有测:除以零、负数、溢出、精度丢失
}

导致这个问题的根源是:覆盖率度量的是"覆盖"(exercise),而不是"验证"(verify)。 代码走过的每一行,测试都没有 assert 它的正确性。

更隐蔽的情况是:

java

public int findMax(int[ ] arr) {

    int max = arr[0];
    for (int i = 1; i < arr.length; i++) {
        if (arr[i] > max) {
            max = arr[i];  // 覆盖率 100%,实际少了边界处理
        }
    }
    return max;
}

// 测试只测了 {1,2,3},没测 null、空数组、单元素、全相同值

6.3 合理的覆盖率策略

行业内常见的争议是"覆盖率应该达到多少"。以下是我的建议:

场景 建议覆盖率 说明
核心领域逻辑 ≥ 90% 错了就是钱
应用服务 ≥ 80% 编排逻辑一般不会太复杂
适配器/Controller 不设硬指标 用集成测试覆盖
DTO/Getter/Setter/Enum 不强制 用 Lombok,测试没有增量价值
配置类 不强制 Spring 自动配置足以验证

不要追求 100% 行覆盖率。 为了覆盖最后几个 getter 而写毫无意义的测试,只会降低整个测试套件的信噪比——当有人需要快速定位问题时,50% 的测试都是废话。

6.4 变异测试:覆盖率的试金石

传统的覆盖率只能告诉你"代码被执行了"。变异测试更进一步:故意篡改代码,看测试能否检测出来。

java
// 原代码
if (a > b) { return a; }

// 变异体 1:把 > 改成 >=  —— 如果测试没变红,说明测试不够
// 变异体 2:把 > 改成 <   —— 如果测试没变红,说明测试不够  
// 变异体 3:去掉整个 if    —— 如果测试没变红,说明测试不够

PITest 会在字节码层面自动生成变异体,运行测试套件。存活的变异体(测试没发现)就是测试盲区。

xml
<!-- Maven 配置 -->
<plugin>
    <groupId>org.pitest</groupId>
    <artifactId>pitest-maven</artifactId>
    <version>1.15.0</version>
    <configuration>
        <targetClasses>
            <param>com.example.domain.*</param>
        </targetClasses>
        <targetTests>
            <param>com.example.domain.*</param>
        </targetTests>
    </configuration>
</plugin>

变异测试是覆盖率质量的最佳探测器,但运行成本较高,适合对核心领域模块定期运行。


七、契约测试

7.1 端到端测试为什么不够

微服务架构中,服务 A 调用服务 B。团队 A 改了 B 的 API,B 的所有端到端测试全红。但 A 的测试全绿——因为 A Mock 了 B。这种"假绿灯"是微服务环境的定时炸弹。

**契约测试(Contract Testing)**正是为了解决这个问题:让服务提供者和服务消费者之间有一份可验证的协议。

7.2 消费者驱动契约(Consumer-Driven Contract)

文章配图
文章配图

CDC 的流程:

  1. 消费者定义自己期望的 API 格式(输入参数、返回字段)
  2. 契约文件交给提供者
  3. 提供者验证自己能满足所有消费者的期望
  4. 任何一方违反契约,CI 立即报错
code
  消费者A期望:             消费者B期望:              消费者C期望:
  GET /users/1             GET /users?page=1         POST /users
  {name, email}            {id, name, avatar}        body: {name, email}
        \                      |                      /
         \                     |                     /
          └────────────────────┼────────────────────┘
                               |
                       提供者验证: 所有契约都能满足
                               |
                         ✓ A: name ✓, email ✓
                         ✓ B: id ✓, name ✓, avatar ✓
                         ✓ C: POST 接受并创建 ✓

7.3 Pact 简介

Pact 是 CDC 的事实标准。一个完整的 Pact 流程:

消费者端(定义契约):

java
@ExtendWith(PactConsumerTestExt.class)
@PactTestFor(providerName = "user-service", port = "8080")
class UserServiceConsumerTest {
    
    @Pact(consumer = "order-service")
    V3Pact createPact(PactDslWithProvider builder) {
        return builder
            .given("user 1 exists")
            .uponReceiving("a request for user 1")
                .path("/users/1")
                .method("GET")
            .willRespondWith()
                .status(200)
                .headers(Map.of("Content-Type", "application/json"))
                .body("{\"id\": 1, \"name\": \"张三\", \"email\": \"zhangsan@example.com\"}")
            .toPact(V3Pact.class);
    }
    
    @Test
    @PactTestFor(pactMethod = "createPact")
    void shouldGetUser() {
        User user = userClient.getUser(1L);
        assertEquals("张三", user.getName());
        assertEquals("zhangsan@example.com", user.getEmail());
    }
}

提供者端(验证契约):

java
@Provider("user-service")
@PactBroker(url = "https://pact-broker.example.com")
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class UserServiceProviderTest {
    
    @LocalServerPort
    int port;
    
    @BeforeEach
    void setUp() {
        // 准备测试数据
    }
    
    @TestTemplate
    @ExtendWith(PactVerificationInvocationContextProvider.class)
    void pactVerificationTestTemplate(PactVerificationContext context) {
        context.verifyInteraction();
    }
    
    @State("user 1 exists")
    void user1Exists() {
        userRepository.save(new User(1L, "张三", "zhangsan@example.com"));
    }
}

7.4 契约测试 vs 端到端测试 vs 集成测试

契约测试 集成测试 端到端测试
测试范围 一个消费者 vs 一个提供者 应用内部多个组件 完整系统
速度 快(模拟 HTTP) 中等 慢
维护成本 低 中 高
发现什么问题 API 兼容性 组件交互 bug 系统级 bug

八、实战技巧与常见陷阱

8.1 测试数据管理

不要共享测试数据。 每个测试应该创建自己需要的数据。

java
// 坏:测试之间共享可变数据
private static final List<Order> ORDERS = new ArrayList<>();
@BeforeAll
static void setUp() {
    ORDERS.add(new Order(...));  // 多个测试修改同一个列表 → 测试顺序依赖
}

// 好:每个测试自己造数据
@Test
void shouldCalculateTotal() {
    var orders = List.of(
        new Order(new Money(100)),
        new Order(new Money(200))
    );
    // ...
}

使用 Builder 或 Factory 方法来减少样板代码:

java
// Test Data Builder
Order anOrder() {
    return Order.builder()
        .id(1L)
        .customer(aCustomer())
        .items(List.of(anItem()))
        .build();
}

8.2 测试反模式

文章配图
文章配图

反模式 症状 解决方案
一测多事 一个 @Test 里做了多次 assert,且覆盖不同场景 拆成多个独立测试
间接测试 测试通过其他测试的副作用来验证 每个测试独立准备数据
测试私有方法 用反射测 private 方法 通过公开接口间接覆盖,或反思设计
条件逻辑 测试里有 if/for/while 测试应该线性执行,否则有 bug
睡眠等待 Thread.sleep(1000) 用 Awaitility / CountDownLatch
测试配置文件 @ActiveProfiles("test") 引入了一套完全不同的配置 覆盖具体 Bean,不要换整个配置

8.3 给老旧系统加测试的策略

面对一个 20 万行、零测试的老系统,不要试图"先写测试再重构"。那是妄想。

可行策略,按优先级:

  1. 给新功能写测试:从现在开始,所有新增代码必须有测试
  2. 给修复的 Bug 写测试:修 Bug 之前先写一个失败的测试,修完之后测试变绿,这个 Bug 永远不会回归
  3. 给关键路径加特征测试(Characterization Test):不知道该不该返回这个值?先写测试锁定当前行为,再重构
  4. 高价值重构加测试:如果要重构支付模块,先给它上测试护栏

8.4 让测试跑得快

一个 500 个测试的套件,如果跑一次要 5 分钟,工程师就不会频繁跑。测试速度直接影响测试文化。

  • 不要每个测试都启动 Spring 容器。尽可能用纯 Java 测试(@ExtendWith(MockitoExtension.class))
  • 使用测试切片:@WebMvcTest、@DataJpaTest、@JsonTest,只加载需要的 Bean
  • 并行执行:JUnit 5 支持 junit-platform.properties 中配置 junit.jupiter.execution.parallel.enabled=true
  • 模块化构建:只跑改动的模块的测试

总结

文章配图
文章配图

单元测试不是负担,而是专业工程师的标志。从测试金字塔到底层 Mock 技术,从覆盖率质量到可测试性设计,这四者在工程实践中是一个整体:

  1. 测试金字塔给你策略——知道每种测试的比例和职责
  2. Mock/Test Double给你工具——隔离依赖,聚焦被测对象
  3. 覆盖率给你度量——但不是目的,而是手段
  4. 可测试性设计给你基础——测试不是后补的,代码天然可测才是正道

如果只记住一句话:单元测试验证的是代码的"正确性假设"。如果你写代码时没有形成任何可验证的假设,那你的代码没有经过思考。


🤔 面试官视角:问答模块

以下问题从面试官视角设计。回答时不要背诵本文内容,用自己的理解回答,展示思考过程。

Q1:你说测试覆盖率要达到 80%,但你们团队的覆盖率实际是 45%。给你三个月,你怎么把它提到 80%?

点击查看参考思路

面试官想听的不是"多写测试"这种废话。他在考察你是否理解覆盖率提升是一个系统工程,而不是体力活。

关键点:

  1. 先说不能做什么:不能为了数字强行补测试(getter/setter 的测试毫无意义)
  2. 分阶段:第一个月堵增量(所有新代码必须有测试,CI 卡覆盖率阈值),第二个月补关键模块(支付、库存、订单状态机),第三个月补 Bug 修复回归测试
  3. 技术手段:识别不可测试的老代码 → 加接缝(Seam)→ 加特征测试锁定行为 → 重构 → 补真正测试
  4. 团队文化:Code Review 时检查测试质量,不能只看测试存在与否
  5. 度量分层:核心领域 90%,应用层 80%,适配器层不设硬指标

Q2:请你设计一个"不可测试"的场景,然后证明只要改设计它就能测。(展示你的架构思维)

点击查看参考思路

经典例子:一个 PaymentService.process() 方法内部直接 new HttpClient() 调用第三方支付网关。

"不可测试"的理由:PayPal 沙箱需要网络、账号、真实交互,每次跑都真的扣钱。

改造方案:

  1. 抽出接口 PaymentGateway,定义 PaymentResult charge(PaymentRequest)
  2. 生产实现 PayPalPaymentGateway(做真正的 HTTP 调用)
  3. 测试用 Mock:when(gateway.charge(any())).thenReturn(success) —— 不需要真实调用即可验证 process 方法的所有逻辑

但更进一步的设计改变:如果支付结果需要异步回调怎么办?那么应该引入 PaymentOutcomeHandler 接口,将同步逻辑和异步回调处理分离。这就是"测试驱动架构改进"。

Q3:Mockito 的**verify**和**when().thenReturn()**从原理上分别改变了对象的什么?Mock 对象的内部是怎么工作的?

点击查看参考思路

这是一个深入原理的问题,面试官在测试你对 Mock 框架底层机制的理解。

when().thenReturn() 是stubbing——Mockito 在 Mock 对象的内部方法调用记录表中注册了一条规则:"当 findById(1L) 被调用时,返回 Optional.of(user)"。此后该 Mock 对象的每个方法调用都会先查表,匹配到规则就返回预设值。

verify() 是验证——Mockito 在 Mock 对象的内部调用记录中搜索是否存在符合条件的历史调用。verify(repo).findById(1L) 就是在问:"findById(1L) 被调用过吗?"

底层原理:Mockito 通过 CGLIB(类)或动态代理(接口)创建代理对象。每个方法调用都被代理拦截,记录到 InvocationContainer 中。Stubbing 是在这个容器里注册"怎么回答"的规则,Verification 是在这个容器里查询"被问过什么问题"。

追问:CGLIB 代理对 final 类和 final 方法是无效的——这就是为什么 Mockito 2.1+ 需要 mockito-inline 来通过 Instrumentation API Mock final 类型。

Q4:你们团队有 40 个微服务。现在要做一个需求,改动了服务 A 和服务 B。你怎么保证 A 和 B 之间的 API 兼容性不被破坏?在没有契约测试的前提下,你从零开始推契约测试,怎么落地?

点击查看参考思路

面试官在考察微服务治理的实际经验和推新工具的能力。

落地策略:

  1. 不要一次铺开 40 个服务。选一对耦合最紧密、API 变更最频繁的服务(比如订单服务和用户服务)做试点
  2. 先让消费者写契约(因为消费者最清楚自己需要什么字段)
  3. 让提供者的 CI 集成 Pact 验证:./mvnw test 时自动跑契约验证
  4. 试点跑通后,加入 Pact Broker,建立契约版本管理
  5. 推广策略:展示数据——"上个月因为 API 不兼容导致的线上故障 3 次,契约测试可以在 CI 阶段就拦住"
  6. 有了契约测试后,则可以安全地减少端到端测试数量(降低维护成本)

关键点:不要技术驱动推工具,要用故障数据驱动推方案。

Q5:测试覆盖率达到 95%,但线上还是出 Bug。你怎么向领导解释这个现象?

点击查看参考思路

面试官在考察你能否区分覆盖率的幻觉和真正的测试质量。

解释要点:

  1. 覆盖率只能证明代码被执行过,不能证明被正确验证过。有可能 95% 的代码都有测试,但测试没有覆盖关键边界条件(null、空列表、负数、并发场景)
  2. 行覆盖 ≠ 逻辑覆盖。一个 if (a > 0 && b < 100 && c != null) 被走了一遍不代表三个条件的每种组合都被测了
  3. 覆盖率不包括未写的代码。线上 Bug 往往是"缺少了某个校验"——覆盖率工具无法检测不存在的代码
  4. 集成问题:每个服务的单元测试都绿,但服务之间的交互没测(这正是契约测试的用武之地)

然后给出改进方案:引入变异测试(PITest)检测测试质量,补充边界值和异常路径测试,对跨服务交互加契约测试。


下一篇预告:247|集成测试与契约测试——Testcontainers、消费者驱动契约和环境治理