一个真实的场景:你正在为一个电商系统的订单服务编写单元测试。订单服务依赖库存检查、支付处理、邮件通知三个外部服务。如果使用真实的支付网关,每次测试都会产生实际费用;如果连接真实的SMTP服务器,测试邮箱会被垃圾邮件淹没。更糟糕的是,支付网关的响应时间不稳定,导致测试时快时慢。
于是你决定"模拟"这些依赖。打开测试框架文档,映入眼帘的是mock、stub、fake、spy、dummy……这些术语在教程中似乎可以互换使用。随便选一个mock框架,写下几行配置代码,测试通过了。但当你三个月后重构代码时,测试突然大面积失败——明明业务逻辑没变,只是把内部实现从调用方法A改成了方法B。
问题出在哪里?你可能从未真正理解过测试替身的本质区别。
测试替身:一个统一术语的诞生
在2007年之前,测试社区陷入了一场术语混乱。有人把所有"假对象"都叫stub,有人坚持mock和stub是同一回事,还有人自创术语让讨论更加混乱。
Gerard Meszaros在撰写《xUnit Test Patterns》时决定终结这场混乱。他借鉴电影行业的"替身演员"(stunt double)概念,提出了"测试替身"(Test Double)作为统称——任何在测试中替代真实对象的假对象都是测试替身。在这个统称之下,他定义了五种具体类型:Dummy、Stub、Spy、Mock和Fake。
Martin Fowler在2006年的博客中推广了这套术语,它在今天已成为测试领域的标准词汇。理解这套分类,是写出高质量测试的第一步。
Dummy:最不起眼的占位符
Dummy是五种测试替身中最简单的一种。它存在的唯一理由是满足方法签名的要求——某些方法需要一个参数,但测试根本不会用到这个参数。
// 一个需要Logger参数的构造函数
public class OrderProcessor {
public OrderProcessor(Logger logger, InventoryService inventory) {
this.inventory = inventory;
// logger参数在本测试中不会被使用
}
}
// 测试中使用null作为Dummy
@Test
public void testProcessOrder() {
OrderProcessor processor = new OrderProcessor(null, mockInventory);
// 测试逻辑...
}
在这个例子中,null就是一个Dummy。它只是占个位置,让编译器满意,测试代码根本不会触碰它。
Dummy也可以是一个空实现的实例:
# Python中的Dummy示例
class DummyLogger:
def log(self, message):
pass # 什么都不做
def test_order_processing():
processor = OrderProcessor(DummyLogger(), real_inventory)
# DummyLogger被传入但从未被调用
关键特征:Dummy从不参与测试逻辑,它只是为了让代码能够编译或运行而存在。如果你的测试需要验证Dummy的行为,那么你选错了测试替身类型。
Stub:预设答案的应答机器
Stub比Dummy复杂一步:它会响应方法调用,但只返回预设的固定答案。Stub不关心调用细节,不记录调用历史,只负责让被测代码能够继续执行下去。
// 一个返回固定值的Stub
public class StubInventoryService implements InventoryService {
@Override
public boolean hasEnoughInventory(String productId, int quantity) {
return true; // 总是返回true,不管查询什么
}
@Override
public void reduceInventory(String productId, int quantity) {
// 空实现,什么都不做
}
}
@Test
public void testOrderCanBePlacedWhenInventoryAvailable() {
InventoryService stub = new StubInventoryService();
OrderService orderService = new OrderService(stub);
Order order = new Order("product-123", 5);
boolean result = orderService.placeOrder(order);
assertTrue(result); // 因为Stub总是返回true
}
Stub的核心价值在于控制被测代码的执行路径。通过预设不同的返回值,你可以让被测代码走到不同的分支:
# Python中使用Stub测试不同场景
class StubPaymentGateway:
def __init__(self, should_succeed=True):
self._should_succeed = should_succeed
def charge(self, amount, card_info):
if self._should_succeed:
return {"status": "success", "transaction_id": "txn-123"}
else:
return {"status": "failed", "error": "insufficient_funds"}
def test_successful_payment():
stub = StubPaymentGateway(should_succeed=True)
service = PaymentService(stub)
result = service.process_payment(100.0, card_info)
assert result.is_successful()
def test_failed_payment():
stub = StubPaymentGateway(should_succeed=False)
service = PaymentService(stub)
result = service.process_payment(100.0, card_info)
assert not result.is_successful()
关键特征:Stub提供" canned answers"(预设答案),它不会主动失败,也不会验证任何东西。它是被动的应答者。
Spy:会做笔记的Stub
Spy在Stub的基础上增加了记录能力。它不仅返回预设答案,还会记住自己被如何调用——哪些方法被调用了、调用了几次、传入了什么参数。
// 手写的Spy实现
public class SpyEmailService implements EmailService {
private List<String> sentEmails = new ArrayList<>();
private List<Object[]> sendParameters = new ArrayList<>();
@Override
public void send(String to, String subject, String body) {
sentEmails.add(to);
sendParameters.add(new Object[]{to, subject, body});
}
// 提供验证方法
public int getEmailCount() {
return sentEmails.size();
}
public boolean wasEmailSentTo(String recipient) {
return sentEmails.contains(recipient);
}
public Object[] getLastSendParameters() {
return sendParameters.get(sendParameters.size() - 1);
}
}
@Test
public void testOrderConfirmationEmailIsSent() {
SpyEmailService spy = new SpyEmailService();
OrderService orderService = new OrderService(spy);
orderService.placeOrder(new Order("[email protected]", "product-123"));
// 验证Spy记录的行为
assertEquals(1, spy.getEmailCount());
assertTrue(spy.wasEmailSentTo("[email protected]"));
}
Spy的名字来源于"间谍"——它潜伏在被测系统和依赖之间,记录一切交互,然后向测试代码汇报。
现代测试框架通常提供Spy的快捷创建方式:
// Jest中的Spy
const emailService = {
send: jest.fn().mockReturnValue(true)
};
const orderService = new OrderService(emailService);
orderService.placeOrder(order);
// 验证Spy的记录
expect(emailService.send).toHaveBeenCalledTimes(1);
expect(emailService.send).toHaveBeenCalledWith(
'[email protected]',
'Order Confirmation',
expect.stringContaining('product-123')
);
关键特征:Spy是"会做笔记的Stub"。它在被测代码执行期间默默记录,测试代码在事后验证这些记录。
Mock:预设期望的严格监工
Mock是最严格的测试替身。它不仅记录调用,还会在调用发生时立即验证是否符合预设的期望。如果调用与期望不符,Mock会立即抛出异常,测试失败。
// 使用Mockito框架创建Mock
@Test
public void testOrderProcessCallsInventoryCheck() {
// 创建Mock并预设期望
InventoryService mockInventory = mock(InventoryService.class);
when(mockInventory.hasEnoughInventory("product-123", 5))
.thenReturn(true);
EmailService mockEmail = mock(EmailService.class);
OrderService orderService = new OrderService(mockInventory, mockEmail);
orderService.placeOrder(new Order("[email protected]", "product-123", 5));
// 验证Mock的期望是否被满足
verify(mockInventory).hasEnoughInventory("product-123", 5);
verify(mockInventory).reduceInventory("product-123", 5);
verify(mockEmail).send(eq("[email protected]"), anyString(), anyString());
}
Mock的核心特征是行为验证。测试不再关心最终结果是什么状态,而是关心"系统是否按照预期的方式与其依赖进行了交互"。
# Python中使用unittest.mock
from unittest.mock import Mock, patch
def test_order_notification():
mock_email = Mock()
mock_email.send.return_value = True
order_service = OrderService(mock_email)
result = order_service.place_order(order)
# 验证行为
mock_email.send.assert_called_once_with(
to="[email protected]",
subject="Order Confirmation",
body=mock.ANY
)
关键特征:Mock预设了严格的调用期望,任何偏离期望的调用都会导致测试失败。它关注的是"如何调用"而非"调用结果"。
Fake:能工作的简化版实现
Fake是五种测试替身中最"真实"的一种。它有完整的、可工作的实现,只是比真实实现更简单、更快、更适合测试环境。
最常见的Fake例子是内存数据库:
// 一个真实的数据库仓库
public class SqlUserRepository implements UserRepository {
private DatabaseConnection db;
@Override
public User findById(long id) {
return db.query("SELECT * FROM users WHERE id = ?", id);
}
@Override
public void save(User user) {
db.execute("INSERT INTO users ...");
}
}
// 一个内存版的Fake实现
public class InMemoryUserRepository implements UserRepository {
private Map<Long, User> storage = new HashMap<>();
@Override
public User findById(long id) {
return storage.get(id); // 纯内存操作,毫秒级
}
@Override
public void save(User user) {
storage.put(user.getId(), user);
}
}
@Test
public void testUserRegistration() {
// 使用Fake,测试速度快,无需真实数据库
UserRepository fakeRepo = new InMemoryUserRepository();
UserService userService = new UserService(fakeRepo);
userService.register("[email protected]", "John Doe");
User savedUser = fakeRepo.findByEmail("[email protected]");
assertNotNull(savedUser);
assertEquals("John Doe", savedUser.getName());
}
Fake的关键价值在于高保真:它的行为与真实实现非常接近,测试通过意味着生产环境也很可能正常工作。
其他常见的Fake例子:
# Fake文件系统
class FakeFileSystem:
def __init__(self):
self._files = {}
def write(self, path, content):
self._files[path] = content
def read(self, path):
return self._files.get(path, "")
def exists(self, path):
return path in self._files
# Fake时间服务
class FakeClock:
def __init__(self, initial_time):
self._current_time = initial_time
def now(self):
return self._current_time
def advance(self, seconds):
self._current_time += seconds
关键特征:Fake有真实的业务逻辑实现,只是做了简化。它是最"诚实"的测试替身。
五种测试替身的本质区别
理解五种测试替身的关键在于回答两个问题:
第一个问题:这个测试替身有没有行为?
- Dummy:没有行为,只是占位符
- Stub:有行为,返回预设答案
- Spy:有行为,返回预设答案并记录调用
- Mock:有行为,验证调用是否符合期望
- Fake:有行为,提供完整的简化实现
第二个问题:测试如何验证结果?
这引出了测试领域的一个核心分歧:状态验证 vs 行为验证。
┌─────────────────────────────────────────────────────────────────┐
│ 测试替身的验证方式 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 状态验证 行为验证 │
│ (检查结果是什么) (检查如何达到结果) │
│ │
│ ┌───────┐ ┌───────┐ │
│ │ Dummy │ │ Spy │ │
│ │ Stub │ │ Mock │ │
│ │ Fake │ │ │ │
│ └───────┘ └───────┘ │
│ │
│ "我得到了期望的结果吗?" "我以期望的方式调用了依赖吗?" │
│ │
└─────────────────────────────────────────────────────────────────┘
状态验证关注最终状态,不关心中间过程:
// 状态验证示例
@Test
public void testOrderIsMarkedAsShipped() {
Order order = new Order();
ShippingService service = new ShippingService(stubWarehouse);
service.ship(order, "tracking-123");
// 验证最终状态
assertEquals(OrderStatus.SHIPPED, order.getStatus());
assertEquals("tracking-123", order.getTrackingNumber());
}
行为验证关注交互过程:
// 行为验证示例
@Test
public void testShippingServiceContactsWarehouse() {
Warehouse mockWarehouse = mock(Warehouse.class);
ShippingService service = new ShippingService(mockWarehouse);
service.ship(order, "tracking-123");
// 验证交互行为
verify(mockWarehouse).markAsShipped(order, "tracking-123");
verify(mockWarehouse).notifyCustomer(order);
}
古典派 vs Mockist派:测试哲学的分歧
关于何时使用哪种测试替身,测试社区分成了两大阵营。
古典派(底特律学派)
古典派主张:尽可能使用真实对象,只有在不得已时才使用测试替身。
- 能用真实对象就用真实对象
- 需要测试替身时,优先选择Fake
- Stub用于控制测试输入
- Mock只用于验证"有副作用的对外交互"
古典派的测试更像"小型集成测试"——它们测试的是多个真实对象协作后的最终结果。
// 古典派测试风格
@Test
public void testOrderProcessing() {
// 使用真实对象
InMemoryDatabase fakeDb = new InMemoryDatabase(); // Fake
UserRepository realRepo = new UserRepository(fakeDb);
OrderRepository realOrderRepo = new OrderRepository(fakeDb);
// Stub只用于控制输入
PaymentGateway stubPayment = new StubPaymentGateway(successful());
// 组装真实系统
OrderService service = new OrderService(realRepo, realOrderRepo, stubPayment);
// 执行测试
Order order = new Order("user-123", "product-456", 2);
service.placeOrder(order);
// 状态验证:检查最终结果
User user = realRepo.findById("user-123");
assertEquals(2, user.getOrderHistory().size());
Order savedOrder = realOrderRepo.findById(order.getId());
assertEquals(OrderStatus.CONFIRMED, savedOrder.getStatus());
}
Mockist派(伦敦学派)
Mockist派主张:隔离被测单元,所有依赖都用测试替身替代。
- 每个类独立测试
- 所有协作者都用Mock替代
- 测试验证的是"类是否正确地与其依赖交互"
- 强调"由外向内"的开发方式
// Mockist派测试风格
@Test
public void testOrderProcessing() {
// 所有依赖都是Mock
UserRepository mockUserRepo = mock(UserRepository.class);
OrderRepository mockOrderRepo = mock(OrderRepository.class);
PaymentGateway mockPayment = mock(PaymentGateway.class);
EmailService mockEmail = mock(EmailService.class);
// 预设行为
when(mockUserRepo.findById("user-123")).thenReturn(testUser);
when(mockPayment.charge(anyDouble(), any())).thenReturn(successfulPayment());
OrderService service = new OrderService(
mockUserRepo, mockOrderRepo, mockPayment, mockEmail
);
service.placeOrder(new Order("user-123", "product-456", 2));
// 行为验证:验证每个交互
verify(mockPayment).charge(eq(199.98), any());
verify(mockOrderRepo).save(any(Order.class));
verify(mockEmail).sendOrderConfirmation(eq("user-123"), any());
}
哪种方式更好?
两种方式各有优劣,没有绝对的对错。
| 维度 | 古典派 | Mockist派 |
|---|---|---|
| 测试稳定性 | 较高(关注结果而非过程) | 较低(重构易破坏测试) |
| 测试隔离性 | 较低(多对象协作) | 较高(单对象测试) |
| 错误定位 | 较难(多个对象可能失败) | 较易(只有被测对象可能失败) |
| 重构友好度 | 高(内部重构不影响测试) | 低(改变交互方式会破坏测试) |
| 设计导向 | 面向领域建模 | 面向接口设计 |
Martin Fowler本人是古典派的支持者,但他也承认Mockist方法在特定场景下有其价值——尤其是当你希望"由外向内"地设计系统接口时。
何时使用哪种测试替身?
Google在其《Software Engineering at Google》书中给出了明确的决策指南:
优先级从高到低:
- 使用真实实现:保真度最高,测试最接近生产行为
- 使用Fake:当真实实现不可行时(太慢、需要外部资源等)
- 使用Mock:作为最后的手段
┌──────────────────────────────────────────────────────────────┐
│ Google的测试替身决策树 │
├──────────────────────────────────────────────────────────────┤
│ │
│ 能否使用真实实现? │
│ │ │
│ ├── 是 → 使用真实实现(最高保真度) │
│ │ │
│ └── 否 → 是否有可用的Fake? │
│ │ │
│ ├── 是 → 使用Fake │
│ │ │
│ └── 否 → 使用Mock(最低保真度) │
│ │
└──────────────────────────────────────────────────────────────┘
具体场景指南
使用Dummy的场景:
- 参数只是为了满足方法签名
- 参数在当前测试中不会被使用
- 例子:日志记录器、配置对象、回调函数
使用Stub的场景:
- 需要控制被测代码的执行路径
- 测试不同分支逻辑
- 例子:返回不同状态码的HTTP客户端
使用Spy的场景:
- 需要验证某些行为发生了
- 但又不想使用Mock的严格验证
- 例子:记录发送了多少封邮件
使用Mock的场景:
- 验证与外部系统的交互
- 测试有副作用的操作
- 例子:验证支付请求的参数是否正确
使用Fake的场景:
- 需要接近真实行为的测试替身
- 真实依赖太慢或不可用
- 例子:内存数据库、文件系统模拟器
常见反模式与陷阱
反模式一:对Stub进行断言
这是最常见的错误之一。Stub只是提供输入的工具,对它的调用是实现细节,不应该成为测试验证的一部分。
// 错误:对Stub进行断言
@Test
public void testOrderProcessing() {
InventoryService stub = mock(InventoryService.class);
when(stub.hasInventory(anyString(), anyInt())).thenReturn(true);
orderService.process(order);
// 反模式!不应该验证对Stub的调用
verify(stub).hasInventory("product-123", 5);
}
// 正确:只验证最终结果
@Test
public void testOrderProcessing() {
InventoryService stub = new StubInventoryService(true);
orderService.process(order);
// 验证业务结果,而非实现细节
assertEquals(OrderStatus.PROCESSED, order.getStatus());
}
反模式二:过度Mock
将所有依赖都Mock掉会导致测试与实现高度耦合。任何内部重构都会破坏测试,即使业务逻辑没有变化。
// 过度Mock的测试
@Test
public void testUserRegistration() {
UserRepository mockRepo = mock(UserRepository.class);
EmailService mockEmail = mock(EmailService.class);
Validator mockValidator = mock(Validator.class);
PasswordEncoder mockEncoder = mock(PasswordEncoder.class);
TokenGenerator mockToken = mock(TokenGenerator.class);
when(mockValidator.validate(any())).thenReturn(true);
when(mockEncoder.encode(anyString())).thenReturn("encoded");
when(mockToken.generate()).thenReturn("token-123");
UserService service = new UserService(
mockRepo, mockEmail, mockValidator, mockEncoder, mockToken
);
service.register("[email protected]", "password");
// 验证每个Mock的调用...
verify(mockValidator).validate(any());
verify(mockEncoder).encode("password");
verify(mockRepo).save(any());
verify(mockEmail).sendWelcomeEmail(anyString(), anyString());
verify(mockToken).generate();
}
这个测试验证了实现细节,而非业务行为。如果把密码编码从"先编码再保存"改成"保存时编码",测试就会失败,尽管业务结果完全相同。
反模式三:Mock你拥有的类
Kent Beck有一个著名的建议:不要Mock你拥有的类型。
你拥有的类可以重构、修改接口。如果Mock了它们,测试就会阻止你进行这些改进。只Mock外部依赖(如第三方库、外部服务),它们不受你控制。
反模式四:Fake与真实实现不同步
Fake需要维护,确保其行为与真实实现一致。否则测试通过但生产失败。
// 危险:Fake实现与真实实现不一致
public class InMemoryPaymentGateway implements PaymentGateway {
public PaymentResult charge(double amount, CreditCard card) {
// Fake没有验证卡号格式
return PaymentResult.success("txn-123");
}
}
public class StripePaymentGateway implements PaymentGateway {
public PaymentResult charge(double amount, CreditCard card) {
// 真实实现会验证卡号
if (!card.isValid()) {
return PaymentResult.failure("invalid_card");
}
// ...
}
}
// 测试通过了,但生产环境会失败
@Test
public void testPayment() {
PaymentGateway fake = new InMemoryPaymentGateway();
// 无效卡号,但Fake没有验证
fake.charge(100.0, invalidCard); // 测试通过
}
解决方法是为Fake编写自己的测试,确保其行为与真实实现一致:
// 测试Fake与真实实现的一致性
public abstract class PaymentGatewayContractTest {
protected abstract PaymentGateway createGateway();
@Test
public void invalidCardShouldFail() {
PaymentGateway gateway = createGateway();
PaymentResult result = gateway.charge(100.0, invalidCard);
assertFalse(result.isSuccess());
}
}
public class InMemoryPaymentGatewayTest extends PaymentGatewayContractTest {
protected PaymentGateway createGateway() {
return new InMemoryPaymentGateway();
}
}
public class StripePaymentGatewayTest extends PaymentGatewayContractTest {
protected PaymentGateway createGateway() {
return new StripePaymentGateway(testApiKey);
}
}
主流框架中的测试替身支持
Java: Mockito
Mockito是Java生态中最流行的Mock框架:
// 创建Mock
UserRepository mockRepo = mock(UserRepository.class);
// 配置Stub行为
when(mockRepo.findById(1L)).thenReturn(Optional.of(testUser));
when(mockRepo.save(any())).thenReturn(testUser);
// 验证Mock调用
verify(mockRepo).findById(1L);
verify(mockRepo, times(1)).save(any());
verify(mockRepo, never()).delete(any());
// Spy(包装真实对象)
UserRepository realRepo = new UserRepositoryImpl();
UserRepository spy = spy(realRepo);
when(spy.findById(1L)).thenReturn(Optional.of(customUser));
Python: unittest.mock
Python标准库自带的Mock框架:
from unittest.mock import Mock, patch, MagicMock
# 创建Mock
mock_db = Mock()
mock_db.query.return_value = [{'id': 1, 'name': 'John'}]
# 使用patch装饰器
@patch('mymodule.Database')
def test_with_patch(mock_db_class):
mock_db_class.return_value.query.return_value = []
# ...
# 创建Spy(通过设置spec参数保持真实行为)
real_db = Database()
spy_db = Mock(wraps=real_db)
JavaScript: Jest
Jest内置了强大的Mock功能:
// 自动Mock整个模块
jest.mock('./emailService');
// 手动Mock
const mockEmail = {
send: jest.fn().mockResolvedValue({ success: true })
};
// Spy
const spy = jest.spyOn(console, 'log');
spy.mockImplementation(() => {});
// 验证
expect(mockEmail.send).toHaveBeenCalledWith(
'[email protected]',
'Hello'
);
expect(mockEmail.send).toHaveBeenCalledTimes(1);
测试替身的选择决策
在实际项目中,选择正确的测试替身需要考虑多个因素:
依赖类型:
- 内部依赖(你控制的代码):优先用真实实现或Fake
- 外部依赖(第三方服务):用Mock或Fake
- 基础设施依赖(数据库、文件系统):用Fake
测试目的:
- 验证业务逻辑:用Stub或Fake
- 验证外部交互:用Mock
- 快速反馈:用内存Fake
团队风格:
- 古典派倾向:少用Mock
- Mockist倾向:多用Mock
最重要的是:测试应该验证对用户有意义的行为,而非实现细节。无论选择哪种测试替身,都应该服务于这个目标。
测试替身是单元测试的基石。Dummy填充空缺,Stub提供输入,Spy记录行为,Mock验证交互,Fake模拟真实——五种类型各有其位,混淆它们会导致脆弱的测试和虚假的信心。理解它们的本质区别,选择正确的工具,才能写出既能验证行为又经得起重构的高质量测试。
参考文献
-
Meszaros, G. (2007). xUnit Test Patterns: Refactoring Test Code. Addison-Wesley Professional.
-
Fowler, M. (2007). Mocks Aren’t Stubs. martinfowler.com.
-
Fowler, M. (2006). TestDouble. martinfowler.com Bliki.
-
Trenk, A., & Bly, D. (2024). Increase Test Fidelity By Avoiding Mocks. Google Testing Blog.
-
Google. (2020). Techniques for Using Test Doubles. Software Engineering at Google.
-
Bisesi, G. (2025). Pitfalls Of Mocking In Tests And How To Avoid It. Xebia Blog.
-
Khorikov, V. (2020). When to Mock. Enterprise Craftsmanship.
-
Atwood, J. (2007). Test Doubles: A Taxonomy of Pretend Objects. Coding Horror.