01 测试替身:为什么你的单元测试总是不“单元”?
单元测试的核心是“隔离”。但现实中的代码,没有哪个方法能独自存活。一个订单服务要调用支付网关,一个用户模块要查询数据库,一个通知系统要发送邮件。这些外部依赖,就是单元测试的“污染源”。
真正的单元测试,必须切断所有外部依赖。
这就是测试替身(Test Double)的战场。测试替身是一个通用术语,指代所有在测试中替代真实对象的“替身演员”。这个家族里有两个核心成员:Stub(桩)和Mock(模拟对象)。
先看一个反面教材。假设你有一个OrderService,它依赖PaymentGateway:
java
public class OrderService {
private PaymentGateway paymentGateway;
public OrderService(PaymentGateway paymentGateway) {
this.paymentGateway = paymentGateway;
}
public boolean processOrder(Order order) {
boolean paymentSuccess = paymentGateway.charge(order.getAmount());
if (paymentSuccess) {
order.setStatus(OrderStatus.PAID);
return true;
}
return false;
}
}
最差的测试写法是直接调用真实的PaymentGateway:
java
// 错误示范:依赖真实外部服务
public class OrderServiceTest {
@Test
public void testProcessOrder() {
PaymentGateway realGateway = new RealPaymentGateway(); // 需要网络、账号、余额
OrderService service = new OrderService(realGateway);
Order order = new Order(100.0);
boolean result = service.processOrder(order);
assertTrue(result);
}
}
这个测试跑一次要3秒,跑100次要5分钟。更致命的是,如果支付网关宕机,你的测试全部失败。这不是单元测试,是集成测试。
测试不隔离,等于没测试。
正确的做法是用测试替身替换真实依赖。Stub和Mock是两种不同的替身策略。Stub只负责“提供数据”,Mock负责“验证行为”。理解这个区别,是写出高质量单元测试的第一步。
02 Stub:给测试一个“假”但“稳”的答案
Stub的核心使命是提供预设的返回值。它不关心调用次数、调用顺序,只关心“当被调用时,返回什么”。
适用场景:当你的代码需要从外部获取数据,但你不关心数据来源,只关心代码如何处理这些数据。
继续用OrderService的例子。我们需要一个Stub版本的PaymentGateway:
java
// Stub:只提供返回值,不记录调用行为
public class PaymentGatewayStub implements PaymentGateway {
private boolean chargeResult;
public PaymentGatewayStub(boolean chargeResult) {
this.chargeResult = chargeResult;
}
@Override
public boolean charge(double amount) {
return chargeResult; // 直接返回预设值
}
}
测试代码变得干净、快速:
java
public class OrderServiceTest {
@Test
public void testProcessOrder_Success() {
// 创建Stub,预设返回true
PaymentGatewayStub stub = new PaymentGatewayStub(true);
OrderService service = new OrderService(stub);
Order order = new Order(100.0);
boolean result = service.processOrder(order);
assertTrue(result);
assertEquals(OrderStatus.PAID, order.getStatus());
}
@Test
public void testProcessOrder_Failure() {
// 创建Stub,预设返回false
PaymentGatewayStub stub = new PaymentGatewayStub(false);
OrderService service = new OrderService(stub);
Order order = new Order(100.0);
boolean result = service.processOrder(order);
assertFalse(result);
assertEquals(OrderStatus.PENDING, order.getStatus());
}
}
这个测试跑一次只要0.01秒,100次只要1秒。没有网络延迟,没有数据库连接,没有外部依赖。
Stub让你掌控数据,Mock让你掌控行为。
但Stub有局限性。它只能验证“结果”,不能验证“过程”。比如,你想确认processOrder是否真的调用了paymentGateway.charge(),或者是否传入了正确的金额。Stub做不到这一点,它只会傻傻地返回预设值。
03 Mock:不仅给答案,还要查作业
Mock比Stub多一层责任:验证交互行为。它记录下每一次调用,并允许你在测试结束时检查:这个方法被调用了多少次?传入了什么参数?调用顺序是否正确?
适用场景:当你的代码有副作用(发送邮件、写入队列、记录日志),你需要确保这些操作确实发生了,并且参数正确。
看一个需要Mock的场景。假设OrderService在支付成功后,还需要发送一封邮件:
java
public class OrderService {
private PaymentGateway paymentGateway;
private EmailService emailService;
public OrderService(PaymentGateway paymentGateway, EmailService emailService) {
this.paymentGateway = paymentGateway;
this.emailService = emailService;
}
public boolean processOrder(Order order) {
boolean paymentSuccess = paymentGateway.charge(order.getAmount());
if (paymentSuccess) {
order.setStatus(OrderStatus.PAID);
emailService.sendPaymentConfirmation(order); // 副作用:发送邮件
return true;
}
return false;
}
}
用Stub测试,你只能验证order.getStatus()。但无法确认邮件是否被发送。这就是Mock登场的时候。
手动写一个Mock版本的EmailService:
java
// Mock:记录调用行为,供后续验证
public class EmailServiceMock implements EmailService {
private int sendCount = 0;
private Order lastOrder = null;
@Override
public void sendPaymentConfirmation(Order order) {
sendCount++;
lastOrder = order;
}
// 验证方法
public void verifySendCalled(int expectedCount) {
assertEquals(expectedCount, sendCount);
}
public void verifySendCalledWith(Order expectedOrder) {
assertEquals(expectedOrder, lastOrder);
}
}
测试代码:
java
public class OrderServiceTest {
@Test
public void testProcessOrder_SendsEmail() {
PaymentGatewayStub stub = new PaymentGatewayStub(true);
EmailServiceMock mock = new EmailServiceMock();
OrderService service = new OrderService(stub, mock);
Order order = new Order(100.0);
service.processOrder(order);
// 验证:邮件被发送了一次
mock.verifySendCalled(1);
// 验证:发送的订单对象正确
mock.verifySendCalledWith(order);
}
}
Mock是测试的“摄像头”,记录每一个关键动作。
手动写Mock很累。实际项目中,99%的情况会用Mock框架(如Mockito、Moq)。但理解手动Mock的原理,能让你更清楚框架底层在做什么。
04 实战:用Mockito写出专业级单元测试
Mockito是Java世界最流行的Mock框架。它把Stub和Mock的功能合二为一,语法简洁到令人发指。
先看依赖(Maven):
xml
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId>
<version>5.0.0</version>
<scope>test</scope>
</dependency>
场景1:创建Stub,返回预设值
java
import static org.mockito.Mockito.*;
public class OrderServiceTest {
@Test
public void testProcessOrder_WithStub() {
// 创建Mock对象(同时具备Stub能力)
PaymentGateway gateway = mock(PaymentGateway.class);
EmailService emailService = mock(EmailService.class);
// 设置Stub行为:当调用charge(100.0)时,返回true
when(gateway.charge(100.0)).thenReturn(true);
OrderService service = new OrderService(gateway, emailService);
Order order = new Order(100.0);
boolean result = service.processOrder(order);
assertTrue(result);
assertEquals(OrderStatus.PAID, order.getStatus());
}
}
场景2:创建Mock,验证交互行为
java
@Test
public void testProcessOrder_VerifyEmailSent() {
PaymentGateway gateway = mock(PaymentGateway.class);
EmailService emailService = mock(EmailService.class);
when(gateway.charge(100.0)).thenReturn(true);
OrderService service = new OrderService(gateway, emailService);
Order order = new Order(100.0);
service.processOrder(order);
// 验证:emailService的sendPaymentConfirmation方法被调用了1次,且参数是order
verify(emailService, times(1)).sendPaymentConfirmation(order);
}
场景3:参数匹配器,灵活验证
java
@Test
public void testProcessOrder_VerifyWithAnyAmount() {
PaymentGateway gateway = mock(PaymentGateway.class);
EmailService emailService = mock(EmailService.class);
// 只要金额大于0,就返回true
when(gateway.charge(anyDouble())).thenReturn(true);
OrderService service = new OrderService(gateway, emailService);
Order order = new Order(99.99);
service.processOrder(order);
// 验证:支付网关被调用,金额是99.99
verify(gateway, times(1)).charge(99.99);
// 验证:邮件被发送
verify(emailService, times(1)).sendPaymentConfirmation(order);
}
场景4:异常模拟,测试错误处理
java
@Test(expected = PaymentException.class)
public void testProcessOrder_PaymentFails() {
PaymentGateway gateway = mock(PaymentGateway.class);
EmailService emailService = mock(EmailService.class);
// 模拟支付网关抛出异常
when(gateway.charge(anyDouble())).thenThrow(new PaymentException("Insufficient funds"));
OrderService service = new OrderService(gateway, emailService);
Order order = new Order(1000.0);
service.processOrder(order); // 应该抛出异常
}
一行when,胜过十行if;一句verify,查遍所有调用。
使用Mockito后,每个测试方法不超过10行。测试速度从秒级降到毫秒级。这才是单元测试该有的样子。
05 选Stub还是Mock?一个决策框架
新手最常问的问题:什么时候用Stub,什么时候用Mock?
核心原则:优先用Stub,只在需要验证行为时用Mock。
决策框架:
1. 你的测试对象是否返回数据? → 用Stub
2. 你的测试对象是否产生副作用(发送消息、写入文件、调用外部API)? → 用Mock
3. 你只关心最终结果? → 用Stub
4. 你需要确认过程是否正确? → 用Mock
看几个实际场景:
| 场景 | 推荐替身 | 原因 |
| 从数据库查询用户信息 | Stub | 只关心返回的用户数据 |
| 写入订单到数据库 | Mock | 需要验证写入操作是否发生 |
| 调用第三方支付API | Stub | 只关心支付成功/失败的结果 |
| 发送短信通知 | Mock | 需要验证短信是否被发送 |
| 读取配置文件 | Stub | 只关心配置值 |
| 记录审计日志 | Mock | 需要验证日志内容是否正确 |
还有一个反直觉的事实:过度使用Mock会让测试变脆弱。如果你对每个内部方法都做Mock验证,测试就和实现细节强耦合。改一行代码,10个测试同时失败。
正确做法:只Mock外部边界(数据库、网络、文件系统),不Mock内部逻辑。
Stub测逻辑,Mock测边界。混用是灾难,分清是艺术。
总结
Stub给你数据,Mock给你真相。Stub回答“是什么”,Mock回答“发生了什么”。Stub让测试变快,Mock让测试变准。
一个测试不用Mock,就像闭着眼走路;一个测试全是Mock,就像带着镣铐跳舞。
从今天开始,拒绝在单元测试中调用真实数据库、真实API。用Stub隔离数据,用Mock验证行为。你的测试速度会提升10倍,你的bug率会下降90%。
记住:真正的单元测试,不需要网络,不需要数据库,只需要代码和逻辑。
声明:本站部分稿件版权来源于网络,如有侵犯版权,请及时联系我们。