一个真实的场景:你正在为一个电商系统的订单服务编写单元测试。订单服务依赖库存检查、支付处理、邮件通知三个外部服务。如果使用真实的支付网关,每次测试都会产生实际费用;如果连接真实的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》书中给出了明确的决策指南:

优先级从高到低:

  1. 使用真实实现:保真度最高,测试最接近生产行为
  2. 使用Fake:当真实实现不可行时(太慢、需要外部资源等)
  3. 使用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模拟真实——五种类型各有其位,混淆它们会导致脆弱的测试和虚假的信心。理解它们的本质区别,选择正确的工具,才能写出既能验证行为又经得起重构的高质量测试。

参考文献

  1. Meszaros, G. (2007). xUnit Test Patterns: Refactoring Test Code. Addison-Wesley Professional.

  2. Fowler, M. (2007). Mocks Aren’t Stubs. martinfowler.com.

  3. Fowler, M. (2006). TestDouble. martinfowler.com Bliki.

  4. Trenk, A., & Bly, D. (2024). Increase Test Fidelity By Avoiding Mocks. Google Testing Blog.

  5. Google. (2020). Techniques for Using Test Doubles. Software Engineering at Google.

  6. Bisesi, G. (2025). Pitfalls Of Mocking In Tests And How To Avoid It. Xebia Blog.

  7. Khorikov, V. (2020). When to Mock. Enterprise Craftsmanship.

  8. Atwood, J. (2007). Test Doubles: A Taxonomy of Pretend Objects. Coding Horror.