在编写单元测试时,我们通常用断言验证方法的返回值或对象的状态,但有些行为本身没有返回值,或者真正的副作用发生在依赖对象内部,此时仅靠状态断言很难覆盖。比如一个订单服务在保存成功后需要调用消息发送器通知用户,这个通知行为是否发生、参数是否正确,就需要借助Spy这种测试替身来验证。Spy的本质是在真实对象(或替身对象)外围包一层记录器,把每一次方法调用的参数、次数和顺序都记下来,供测试代码事后断言。

Spy的概念与底层原理
测试替身是一个统称,Spy是其中专注于行为记录的一种。它的核心思想是代理:当你在Spy对象上调用某个方法时,这个调用会先被拦截并记录到内部的调用列表中,然后再决定是转发给真实实现还是返回默认值。正因为有了这层拦截,测试代码才能够在方法执行结束后回过头来检查交互细节。
从实现层面看,不同语言的Spy框架殊途同归。在Java中,Mockito通过动态代理或字节码增强(CGLIB、ByteBuddy)生成子类,重写目标方法并在重写方法里插入记录逻辑;在Python中,unittest.mock通过对象协议拦截属性访问,把函数包装成Mock对象;在JavaScript中,Jest则通过重写模块导出的函数来实现监听。理解这一层原理很重要,因为Spy的多数限制都来自代理机制本身,比如无法Spy私有方法、无法Spy静态方法(旧版本框架)、对final方法的无力等。
以Java为例,Mockito中创建Spy的方式非常直观:
import static org.mockito.Mockito.*;
import java.util.ArrayList;
import java.util.List;
public class SpyDemoTest {
public void testSpy() {
List<String> list = new ArrayList<>();
List<String> spy = spy(list);
// 调用真实方法,同时记录这次交互
spy.add("one");
spy.add("two");
// 行为断言:验证add被调用且参数正确
verify(spy).add("one");
verify(spy, times(2)).add(anyString());
// 状态断言依然可用,因为真实逻辑被执行了
org.junit.Assert.assertEquals(2, spy.size());
}
}
上面的例子体现了Spy最重要的特征:默认执行真实逻辑,同时记录交互。这一点是它和Mock最本质的区别,也是选择测试替身时的判断依据之一。
Spy与Stub、Mock、Fake的区别
测试替身家族成员众多,容易混淆。Stub(桩)只负责返回预设的值,不关心方法是否被调用、被调用了多少次,它服务于状态测试;Mock则强调交互验证,通常不执行真实逻辑,方法调用返回默认值,重点验证被测对象与依赖之间是否发生了预期的协作;Spy介于两者之间,既保留真实实现,又记录交互信息;Fake则是一个功能简化但真实可用的实现,比如内存数据库代替真实数据库。
可以用一个简单的场景说明四者的差异:假设被测的OrderService依赖PaymentGateway。用Stub时,你让charge()固定返回成功,然后断言订单状态;用Mock时,你验证charge()是否被以正确金额调用;用Spy时,你在真实的PaymentGateway上包一层监听,既验证调用又保留其原有逻辑;用Fake时,你写一个把支付结果存在内存里的假网关,跑完整的业务流程。
| 替身类型 | 执行真实逻辑 | 记录交互 | 典型用途 |
|---|---|---|---|
| Stub | 否 | 否 | 提供预设返回值 |
| Mock | 否 | 是 | 验证行为协作 |
| Spy | 是 | 是 | 真实逻辑加行为记录 |
| Fake | 简化版 | 否 | 轻量级可用实现 |
选择的原则可以概括为一句话:验证状态优先用Stub或Fake,验证交互必须用Mock或Spy。而当被测对象的某个方法既有真实逻辑需要执行、又有交互需要验证时,Spy就是最佳选择。盲目地对所有依赖都做Mock或Spy,反而会让测试与实现细节过度耦合,一旦重构内部调用顺序测试就会大面积失败。
主流框架中的Spy实战
在Python中,unittest.mock提供了spy风格的用法:用wraps参数包装真实对象,即可在保留原实现的同时记录调用。这是最贴近Spy语义的写法:
from unittest.mock import Mock
class Notifier:
def send(self, user, message):
# 真实的发送逻辑,比如写日志或调用接口
print("send to", user, ":", message)
return True
notifier = Notifier()
spy = Mock(wraps=notifier)
# 调用会转发给真实对象,同时被记录
spy.send("alice", "order shipped")
spy.send("bob", "order shipped")
# 断言调用次数与参数
assert spy.send.call_count == 2
spy.send.assert_called_with("bob", "order shipped")
在JavaScript生态中,Jest内置了jest.spyOn,可以直接监听某个对象的方法,并通过mockRestore在测试结束后还原,避免污染其他用例:
const mailer = {
send(to, subject) {
console.log(`send mail to ${to}: ${subject}`);
return true;
}
};
test('验证send方法被正确调用', () => {
const spy = jest.spyOn(mailer, 'send');
mailer.send('test@ipipp.com', 'hello');
mailer.send('test@ipipp.com', 'hello');
expect(spy).toHaveBeenCalledTimes(2);
expect(spy).toHaveBeenCalledWith('test@ipipp.com', 'hello');
spy.mockRestore(); // 还原原始方法
});
两种写法都体现了Spy的通用模式:拦截、记录、转发、还原。需要注意的是,如果只想记录而不执行真实逻辑,可以在Jest中链式调用mockImplementation覆盖行为,在Mockito中用doReturn().when(spy)语法打桩,这相当于把Spy当Mock用,适合真实实现有副作用(比如真实发邮件、写数据库)的场景。
使用Spy的常见陷阱与最佳实践
第一个陷阱是部分模拟带来的假象。Spy依赖真实对象,当真实对象状态复杂或依赖外部资源时,测试的稳定性会受影响。例如对一个持有数据库连接的对象做Spy,测试依然可能因为环境问题失败。此时应优先拆分依赖,把外部交互抽象成接口,再对接口做Mock。
第二个陷阱是Mockito中when(spy.method())的写法问题。这种写法会先真实执行一次method(),可能引发副作用,正确做法是使用doReturn(value).when(spy).method()语法,让打桩发生在拦截层而不是真实调用之后:
List<String> list = new ArrayList<>();
List<String> spy = spy(list);
// 错误写法:size()会被真实调用一次
// when(spy.get(0)).thenReturn("stub");
// 正确写法:先设置桩,再触发调用
doReturn("stub").when(spy).get(0);
System.out.println(spy.get(0)); // 输出stub,不会抛出IndexOutOfBoundsException
第三个陷阱是验证过度。Spy让行为验证变得太容易,容易导致测试断言了每一次内部调用,包括调用顺序和具体次数,这类测试极其脆弱。最佳实践是只验证对业务有意义的交互:那些直接体现需求的协作,比如消息确实发出了、事务确实提交了。对于内部实现细节,应保持测试的宽松度,给重构留出空间。
最后一点建议是在测试之间做好清理。Spy修改了对象的原始方法,如果没有还原,可能影响同一测试套件中的其他用例。Jest的mockRestore、Python的patch上下文管理器、Mockito的Mockito.reset(需谨慎使用)都是清理手段。遵循每个测试独立、无残留的原则,Spy才能持续为你的测试体系提供准确而灵活的行为验证能力。