测试替身全攻略——Mock vs Stub vs Fake vs Spy 的场景选择
测试替身全攻略——Mock vs Stub vs Fake vs Spy 的场景选择
适读人群:对测试替身(Test Double)概念模糊,在 Mock 和 Stub 之间反复纠结的工程师 | 阅读时长:约12分钟 | 核心价值:搞清楚四种测试替身的本质区别,每个场景用对工具
那场关于"Mock 还是 Stub"的争论
去年在一个技术交流群里,有人问了一个问题:"我要测试邮件发送功能,是用 Mock 还是 Stub?"
结果群里吵了起来:有人说"当然是 Mock,Mockito 不就是干这个的吗";有人说"应该用 Stub,因为不需要验证行为";还有人说"这种场景应该用 Fake,用真实的内存邮件服务器"。
三种答案,各有道理,但说的根本不是一个维度的事。
这场争论暴露了一个普遍问题:大家把"测试替身"(Test Double)这个大概念和具体的实现工具混淆了。
Mockito 只是一个工具,Mock 也只是测试替身的一种。测试替身一共有五种:Dummy、Stub、Fake、Spy、Mock。每种都有特定的适用场景。
测试替身的来源
"Test Double"这个概念来自 Gerard Meszaros 2007 年的著作《xUnit Test Patterns》。
他用"替身"类比电影里的特技替身(Stunt Double)——当真实演员(真实依赖)的参与代价太高、不安全或不可控时,用替身代替。
五种测试替身,各有其用途:
| 类型 | 核心作用 | 有返回值设定 | 有行为验证 |
|---|---|---|---|
| Dummy | 填充参数,不会被使用 | 否 | 否 |
| Stub | 提供预设返回值 | 是 | 否 |
| Fake | 轻量级真实实现 | 有真实逻辑 | 否 |
| Spy | 包装真实对象,可部分替换 | 部分是 | 是 |
| Mock | 验证交互行为 | 是 | 是 |
Dummy:最简单的替身
Dummy 对象的特点是:它会被传进去,但永远不会被用到。
典型场景:方法签名要求传一个参数,但这个测试根本不关心这个参数。
@Test
void testUserList_sorting() {
// 测试列表排序,不关心 logger 参数
Logger dummyLogger = mock(Logger.class); // Dummy:传进去,不会被调用
UserListService service = new UserListService(dummyLogger);
List<User> users = Arrays.asList(
new User(3L, "Charlie"),
new User(1L, "Alice"),
new User(2L, "Bob")
);
List<User> sorted = service.sortByName(users);
assertEquals("Alice", sorted.get(0).getName());
assertEquals("Bob", sorted.get(1).getName());
assertEquals("Charlie", sorted.get(2).getName());
// 注意:不 verify logger,也不关心它有没有被调用
// 这就是 Dummy 的特征:传进去但不验证
}在 Mockito 里,Dummy 通常就是一个普通的 mock() 对象,但你既不设定 stub,也不做 verify。
Stub:提供预设答案
Stub 的核心作用是:给被测代码提供它需要的数据,控制测试的间接输入。
你不关心 Stub 对象上的某个方法被调用了几次,你只关心它返回了正确的数据,让被测代码能走下去。
@Test
void testOrderService_applyDiscount() {
// Stub:为被测代码提供折扣数据
DiscountRepository stubDiscountRepo = mock(DiscountRepository.class);
when(stubDiscountRepo.findByUserId(1L))
.thenReturn(Optional.of(new Discount(0.9, "会员折扣")));
OrderService orderService = new OrderService(stubDiscountRepo);
OrderResult result = orderService.createOrder(new OrderRequest(1L, BigDecimal.valueOf(100)));
// 只断言业务结果
assertEquals(BigDecimal.valueOf(90), result.getFinalAmount());
// 注意:没有 verify stubDiscountRepo 的调用次数
// 我们不关心 repo 被调用了几次,只关心折扣算对了
}Stub 的重点:只关心数据,不验证行为。
Fake:有真实逻辑的轻量实现
Fake 是测试替身里最"重量级"的一种,它是一个真正可运行的实现,但走了捷径(比如用内存替代数据库、用本地文件替代网络调用)。
Fake 不是用 Mockito 生成的,通常是你自己手写的一个实现类。
// 真实接口
public interface UserRepository {
User save(User user);
Optional<User> findById(Long id);
Optional<User> findByEmail(String email);
List<User> findAll();
}
// Fake 实现:内存版的 UserRepository
public class InMemoryUserRepository implements UserRepository {
private final Map<Long, User> store = new HashMap<>();
private final AtomicLong idSequence = new AtomicLong(1);
@Override
public User save(User user) {
if (user.getId() == null) {
user.setId(idSequence.getAndIncrement());
}
store.put(user.getId(), user);
return user;
}
@Override
public Optional<User> findById(Long id) {
return Optional.ofNullable(store.get(id));
}
@Override
public Optional<User> findByEmail(String email) {
return store.values().stream()
.filter(u -> email.equals(u.getEmail()))
.findFirst();
}
@Override
public List<User> findAll() {
return new ArrayList<>(store.values());
}
}
// 测试里使用 Fake
class UserServiceTest {
private UserRepository fakeRepo = new InMemoryUserRepository();
private UserService userService = new UserService(fakeRepo);
@Test
void testRegisterAndFindUser() {
// Fake 有真实逻辑,register 后真的能 find 到
userService.register("alice@test.com", "Alice", "password");
Optional<User> found = userService.findByEmail("alice@test.com");
assertTrue(found.isPresent());
assertEquals("Alice", found.get().getName());
}
@Test
void testPreventDuplicateEmail() {
userService.register("alice@test.com", "Alice", "password");
assertThrows(DuplicateEmailException.class,
() -> userService.register("alice@test.com", "Another Alice", "password2"));
}
}什么时候用 Fake
Fake 适合用在:
- 依赖的行为比较复杂,Stub 要 mock 很多方法,维护成本高
- 需要测试多个互相依赖的操作(先 save 后 find)
- 希望测试更接近真实行为,但又不想依赖真实数据库
Spring Boot 的 H2 内存数据库 配合 @DataJpaTest,本质上就是一个 Fake——提供真实的 JPA 行为,但用内存代替真实数据库。
Spy:观察者与部分替换者
Spy 在上一篇讲过,这里补充它的定位:
Spy 是对真实对象的包装,默认走真实逻辑,但可以选择性地拦截某些方法。它同时具有"观察"(记录调用)和"部分替换"的能力。
@Test
void testEmailService_shouldRetryOnFailure() {
EmailSender realSender = new EmailSender();
EmailSender spySender = spy(realSender);
// 第一次发送失败,第二次成功
doThrow(new NetworkException("网络超时"))
.doCallRealMethod() // 之后走真实方法
.when(spySender).sendEmail(any(), any());
EmailService emailService = new EmailService(spySender);
emailService.sendWithRetry("test@example.com", "Hello");
// 验证发送了两次(一次失败重试,一次成功)
verify(spySender, times(2)).sendEmail(eq("test@example.com"), eq("Hello"));
}Mock:行为验证专家
Mock 和 Stub 的核心区别:Mock 关注的是"做了什么",Stub 关注的是"返回了什么"。
如果你的测试最终是 verify(...) 来验证某个方法被调用了,那你用的是 Mock 的思路。
@Test
void testOrderService_shouldSendNotificationAfterPayment() {
// 这里 notificationService 是 Mock(不是 Stub)
// 因为我们的断言是"通知被发送了",是行为断言
NotificationService mockNotification = mock(NotificationService.class);
OrderService orderService = new OrderService(paymentService, mockNotification);
orderService.processPayment(order);
// 核心断言:验证行为
verify(mockNotification, times(1)).sendOrderConfirmation(
eq(order.getId()),
eq(order.getUserId())
);
verify(mockNotification, never()).sendPaymentFailure(any());
}踩坑实录三则
踩坑一:把所有替身都当 Mock 用,verify 了一堆没意义的东西
现象:
@Test
void testGetUserName() {
UserRepository mockRepo = mock(UserRepository.class);
when(mockRepo.findById(1L)).thenReturn(Optional.of(new User(1L, "张三")));
String name = userService.getUserName(1L);
assertEquals("张三", name);
verify(mockRepo).findById(1L); // 这行多余!
}原因:verify(mockRepo).findById(1L) 验证了内部实现细节(查询了哪个方法)。当 getUserName 内部改成查缓存时,测试就失败了,但业务行为没变。
解法:如果你的断言是结果(assertEquals("张三", name)),就不需要再 verify 调用链。verify 应该用在"这个行为被触发了"是测试目标本身的场景,比如测试通知是否被发送。
踩坑二:Fake 实现没有遵循接口语义,导致测试误判
现象:Fake 的 InMemoryUserRepository.findByEmail() 忘了做大小写不敏感处理,而真实的数据库是大小写不敏感的。测试用的是 Fake,全通过。上线后,邮件登录功能出了 bug。
原因:Fake 实现和真实实现的行为有偏差,"Fake 测试通过"不等于"真实环境正常"。
解法:对关键的 Fake 实现,需要有一套"契约测试"——验证 Fake 的行为和真实实现的行为一致:
// 契约测试基类
abstract class UserRepositoryContractTest {
protected abstract UserRepository createRepository();
@Test
void findByEmail_shouldBeCaseInsensitive() {
UserRepository repo = createRepository();
repo.save(new User(null, "Alice", "Alice@Test.COM"));
assertTrue(repo.findByEmail("alice@test.com").isPresent());
assertTrue(repo.findByEmail("ALICE@TEST.COM").isPresent());
}
}
// 真实实现的契约测试
@DataJpaTest
class JpaUserRepositoryContractTest extends UserRepositoryContractTest {
@Autowired
private UserRepository jpaRepo;
@Override
protected UserRepository createRepository() { return jpaRepo; }
}
// Fake 的契约测试
class InMemoryUserRepositoryContractTest extends UserRepositoryContractTest {
@Override
protected UserRepository createRepository() { return new InMemoryUserRepository(); }
}踩坑三:混淆了 Stub 和 Mock 导致测试意图不清晰
现象:团队里有人在 verify 之前先设了一堆不必要的 when,有人在验证时不清楚该断言结果还是行为,代码审查时很难看懂这个测试到底在测什么。
解法:在测试命名和注释里明确说明用途。一个测试要么测"输入→输出"(用 Stub 控制输入,assertEquals 验证输出),要么测"动作→副作用"(用 Mock 验证行为,verify 检查调用)。两者混在一起的测试,往往需要拆分。
选择指南:一张决策树
测试需要替换某个依赖?
│
├── 不关心它的返回值,只是填满参数?→ Dummy
│
├── 需要它返回特定数据来驱动被测逻辑?
│ ├── 逻辑简单,几个方法就够?→ Stub(mockito when/thenReturn)
│ └── 需要模拟复杂的有状态交互(先存后取)?→ Fake(手写实现)
│
├── 需要验证依赖的方法被正确调用了?→ Mock(verify)
│
└── 需要测试真实类,但要替换掉其中某个方法?→ Spy把这个模型理解透,你写测试时的思路会清晰很多:先想清楚"我要验证什么",再决定用什么替身,最后才选择工具(Mockito、手写 Fake 还是反射)。
