阅读时间:约 45 分钟 | 难度:中等偏深 | 前置知识:Java 基础、构建工具(Maven/Gradle)
一、为什么要写单元测试
1.1 测试不是"额外工作",是设计工具
大多数工程师把测试看作"写完代码之后顺手补一下"的体力活。这种认知导致测试沦为形式——代码覆盖率够 80% 就交差,每个测试里塞几个 assertNotNull,CI 过了就算完。
实际情况是:单元测试是代码质量的唯一自动化守卫,也是架构设计的反馈器。
一个类很难测试 → 这个类的设计有问题。一个方法需要 Mock 十个依赖 → 这个方法违反了单一职责。测试写起来很痛苦 → 代码耦合度太高。这些信号比任何架构评审都更诚实。
1.2 写测试 vs 不写测试的真实成本
别拿"工期紧"当借口。来看看一组真实数据对比:
| 不写测试 | 写测试 | |
|---|---|---|
| 开发速度 | 前两周快 | 前两周慢 20-30% |
| Bug 发现时机 | 测试环境 / 生产 | 本地,秒级反馈 |
| 单 Bug 修复成本 | 高(需要部署、重现、排查) | 极低(本地即发现) |
| 重构信心 | 零——不敢改 | 高——红灯变绿即可 |
| 新人上手 | 靠文档和口口相传 | 测试即文档 |
| 四个月后代码 | 无人敢动的祖传遗产 | 可维护的工程资产 |
结论非常直白:不写测试是把开发成本转移到了维护成本上,而且是加杠杆转移——1 小时的偷懒 = 后续 10 小时的排查。
1.3 单元测试在质量体系中的位置
单元测试不是独立存在的。它和集成测试、端到端测试、手工测试共同构成质量防护网。但单元测试有不可替代的优势:
- 速度:毫秒级反馈。集成测试跑一次少则数秒多则数分钟。
- 精确性:失败时定位到具体行。集成测试报错往往只知道"某个环节出了问题"。
- 隔离性:不依赖外部环境,CI 上稳定复现。
- 低成本:不需要数据库、中间件、容器。
核心原则:单元测试负责回答"代码写得对不对";集成测试负责回答"组件之间能不能配合";端到端测试负责回答"用户能不能用"。三者不能互相替代。
二、测试金字塔:你的测试策略指南
2.1 经典金字塔模型

Mike Cohn 在 2009 年提出的测试金字塔,至今仍是行业共识。核心思想很简单:越底层的测试应该越多,越上层的测试应该越少。
/\
/E2E\ 端到端测试:少而精,覆盖关键业务流程
/------\
/ 集成 \ 集成测试:中等数量,验证组件交互
/----------\
/ 单元测试 \ 单元测试:数量最多,覆盖所有逻辑分支
/--------------\
这种分层不是刻意的教条,而是基于经济性推导出来的:
- 单元测试:编写成本低、执行快、定位准 → 应该大量写
- 端到端测试:编写成本高、执行慢、定位模糊、环境脆弱 → 应该少写
理想比例:单元测试 70%,集成测试 20%,端到端测试 10%。
2.2 各层定义与实践
单元测试
- 范围:单个类或方法
- 依赖:全部 Mock
- 时间:毫秒级
- 环境:纯内存,无需外部服务
- 典型框架:JUnit 5 + Mockito
@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
@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 三段式:
@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 社区的测试命名已经收敛到一个清晰的惯例:
// 命名模式:should_预期行为_when_条件
shouldThrowInsufficientBalance_whenWithdrawExceedsBalance()
shouldReturnCachedValue_whenKeyExistsInCache()
shouldTimeoutAfter5Seconds_whenServiceUnavailable()
// 或者中文风格(适合团队非全英文环境)
应在余额不足时抛出异常()
缓存命中时不应调用数据库()
核心原则:从测试名就能知道测了什么、预期什么行为。失败时不需要读测试代码就能定位问题。
四、测试替身:Mock, Stub, Fake, Spy, Dummy
4.1 五种测试替身的定义

Gerard Meszaros 在《xUnit Test Patterns》中定义了五种测试替身。理解它们的区别是写好测试的基础。
测试替身(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)
// 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 核心用法
// 基础 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 的典型症状:
// 坏味道: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());
}
这种测试的问题:
- 脆弱:重构实现细节(比如把两个 repo 调用合并)测试就挂了,即使行为正确
- 无价值:它只是在复述代码,没有验证任何业务逻辑
- 维护成本高:改一行代码要改五处 Mock
正确的 Mock 策略:
- Mock 外部边界(数据库、消息队列、第三方 API)
- 不要 Mock 你拥有的类型——除非它是外部边界的门面
- 如果一个测试需要 Mock 超过 3 个依赖,考虑重构被测试类
五、可测试性设计
5.1 依赖注入:可测试性的基石
一个类能不能测试,在构造函数里就能看出来:
// 不可测试 —— 依赖是硬编码的
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,不启动 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 六边形架构中的测试边界

六边形架构(端口与适配器)天然支持可测试性:
┌──────────────────────────┐
│ 领域模型(核心) │
│ → 纯单元测试,无需 Mock │
├──────────────────────────┤
│ 应用服务层 │
│ → Mock 端口接口即可 │
├──────────────────────────┤
│ 适配器层 │
│ → 集成测试,连真实外部 │
└──────────────────────────┘
这种分层使得:
- 领域逻辑:纯函数,测试零依赖
- 应用服务:Mock 端口接口,测试编排逻辑
- 适配器:用 Testcontainers/WireMock 测试真实交互
5.3 隐式依赖是你的敌人

以下四种隐式依赖是单元测试的头号杀手:
1. 时间
// 坏:依赖系统时钟
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. 随机数
// 坏
Math.random();
// 好:注入 Random 实例,测试中用固定 seed
new Random(42);
3. 文件系统和网络
// 坏:直接 new FileInputStream
// 好:依赖注入 InputStream/OutputStream,或封装为 FileStorage 接口
4. 单例和静态方法
// 坏:静态调用无法替换
DiscountCalculator.getGlobalDiscount();
// 好:通过实例方法调用
discountPolicy.calculateDiscount(order);
5.4 "可测试"的代码长什么样
可测试性不是事后补救,而是设计的结果。一个可测试的方法具备这些特征:
- 输入和输出明确:参数进,返回值出,没有隐式副作用
- 依赖显式:所有外部依赖通过参数或构造函数传入
- 状态隔离:不依赖全局状态,不修改共享的可变状态
- 职责单一:一个方法做一件事
// 可测试性满分
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 一个不少。看这个例子:
@Test
void testDivide() {
// 覆盖率 100%:每行都执行了
assertEquals(2, calculator.divide(10, 5)); // 正常路径
// 没有测:除以零、负数、溢出、精度丢失
}
导致这个问题的根源是:覆盖率度量的是"覆盖"(exercise),而不是"验证"(verify)。 代码走过的每一行,测试都没有 assert 它的正确性。
更隐蔽的情况是:
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 变异测试:覆盖率的试金石
传统的覆盖率只能告诉你"代码被执行了"。变异测试更进一步:故意篡改代码,看测试能否检测出来。
// 原代码
if (a > b) { return a; }
// 变异体 1:把 > 改成 >= —— 如果测试没变红,说明测试不够
// 变异体 2:把 > 改成 < —— 如果测试没变红,说明测试不够
// 变异体 3:去掉整个 if —— 如果测试没变红,说明测试不够
PITest 会在字节码层面自动生成变异体,运行测试套件。存活的变异体(测试没发现)就是测试盲区。
<!-- 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 的流程:
- 消费者定义自己期望的 API 格式(输入参数、返回字段)
- 契约文件交给提供者
- 提供者验证自己能满足所有消费者的期望
- 任何一方违反契约,CI 立即报错
消费者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 流程:
消费者端(定义契约):
@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());
}
}
提供者端(验证契约):
@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 测试数据管理
不要共享测试数据。 每个测试应该创建自己需要的数据。
// 坏:测试之间共享可变数据
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 方法来减少样板代码:
// 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 万行、零测试的老系统,不要试图"先写测试再重构"。那是妄想。
可行策略,按优先级:
- 给新功能写测试:从现在开始,所有新增代码必须有测试
- 给修复的 Bug 写测试:修 Bug 之前先写一个失败的测试,修完之后测试变绿,这个 Bug 永远不会回归
- 给关键路径加特征测试(Characterization Test):不知道该不该返回这个值?先写测试锁定当前行为,再重构
- 高价值重构加测试:如果要重构支付模块,先给它上测试护栏
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 技术,从覆盖率质量到可测试性设计,这四者在工程实践中是一个整体:
- 测试金字塔给你策略——知道每种测试的比例和职责
- Mock/Test Double给你工具——隔离依赖,聚焦被测对象
- 覆盖率给你度量——但不是目的,而是手段
- 可测试性设计给你基础——测试不是后补的,代码天然可测才是正道
如果只记住一句话:单元测试验证的是代码的"正确性假设"。如果你写代码时没有形成任何可验证的假设,那你的代码没有经过思考。
🤔 面试官视角:问答模块
以下问题从面试官视角设计。回答时不要背诵本文内容,用自己的理解回答,展示思考过程。
Q1:你说测试覆盖率要达到 80%,但你们团队的覆盖率实际是 45%。给你三个月,你怎么把它提到 80%?
点击查看参考思路
面试官想听的不是"多写测试"这种废话。他在考察你是否理解覆盖率提升是一个系统工程,而不是体力活。
关键点:
- 先说不能做什么:不能为了数字强行补测试(getter/setter 的测试毫无意义)
- 分阶段:第一个月堵增量(所有新代码必须有测试,CI 卡覆盖率阈值),第二个月补关键模块(支付、库存、订单状态机),第三个月补 Bug 修复回归测试
- 技术手段:识别不可测试的老代码 → 加接缝(Seam)→ 加特征测试锁定行为 → 重构 → 补真正测试
- 团队文化:Code Review 时检查测试质量,不能只看测试存在与否
- 度量分层:核心领域 90%,应用层 80%,适配器层不设硬指标
Q2:请你设计一个"不可测试"的场景,然后证明只要改设计它就能测。(展示你的架构思维)
点击查看参考思路
经典例子:一个 PaymentService.process() 方法内部直接 new HttpClient() 调用第三方支付网关。
"不可测试"的理由:PayPal 沙箱需要网络、账号、真实交互,每次跑都真的扣钱。
改造方案:
- 抽出接口
PaymentGateway,定义PaymentResult charge(PaymentRequest) - 生产实现
PayPalPaymentGateway(做真正的 HTTP 调用) - 测试用 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 兼容性不被破坏?在没有契约测试的前提下,你从零开始推契约测试,怎么落地?
点击查看参考思路
面试官在考察微服务治理的实际经验和推新工具的能力。
落地策略:
- 不要一次铺开 40 个服务。选一对耦合最紧密、API 变更最频繁的服务(比如订单服务和用户服务)做试点
- 先让消费者写契约(因为消费者最清楚自己需要什么字段)
- 让提供者的 CI 集成 Pact 验证:
./mvnw test时自动跑契约验证 - 试点跑通后,加入 Pact Broker,建立契约版本管理
- 推广策略:展示数据——"上个月因为 API 不兼容导致的线上故障 3 次,契约测试可以在 CI 阶段就拦住"
- 有了契约测试后,则可以安全地减少端到端测试数量(降低维护成本)
关键点:不要技术驱动推工具,要用故障数据驱动推方案。
Q5:测试覆盖率达到 95%,但线上还是出 Bug。你怎么向领导解释这个现象?
点击查看参考思路
面试官在考察你能否区分覆盖率的幻觉和真正的测试质量。
解释要点:
- 覆盖率只能证明代码被执行过,不能证明被正确验证过。有可能 95% 的代码都有测试,但测试没有覆盖关键边界条件(null、空列表、负数、并发场景)
- 行覆盖 ≠ 逻辑覆盖。一个
if (a > 0 && b < 100 && c != null)被走了一遍不代表三个条件的每种组合都被测了 - 覆盖率不包括未写的代码。线上 Bug 往往是"缺少了某个校验"——覆盖率工具无法检测不存在的代码
- 集成问题:每个服务的单元测试都绿,但服务之间的交互没测(这正是契约测试的用武之地)
然后给出改进方案:引入变异测试(PITest)检测测试质量,补充边界值和异常路径测试,对跨服务交互加契约测试。
下一篇预告:247|集成测试与契约测试——Testcontainers、消费者驱动契约和环境治理