
上周,我和一个小伙伴因为一个 Bug 大吵了一架。他写了一个用户注册功能,所有单元测试都通过了,绿灯一片。但部署到测试环境后,邮件服务直接挂掉了。
我问他:“你测试里是怎么处理邮件发送的?”
他挠挠头:“我 mock 了呀,这样就不会真的去调用邮件 API 啊。”
我接着问:“那你的 mock 是真的在隔离依赖,还是在欺骗自己——你的测试根本没在测什么?”
这个对话促使我深度思考 mock 和 spy 两个工具。很多开发者把它们当成了软件测试中的“魔法道具”,会用但不知道在做什么。更令人担忧的是,很多人写出来的测试根本没在测试真正重要的东西。
核心问题:Mock 和 Spy 到底在做什么?
在开始讲代码之前,我想从哲学角度定义一下这两个工具的本质。
Mock(模拟) 的本质是:用一个假的东西完全替换掉真实的东西,让被测试的代码即使调用了它也不会对系统造成影响。
Spy(间谍) 的本质是:在不改变真实实现的前提下,偷偷观察真实对象怎么被使用的。
这两个工具解决的是同一个核心问题:依赖隔离。但它们的隔离策略完全不同。
来画个图理解一下:
真实应用的一次调用链路
┌─────────────────────────────────────┐
│ registerUser() 被调用 │
│ │ │
│ ├─> 验证邮箱 │
│ │ │
│ └─> 调用 sendEmail() │
│ │ │
│ └─> 真实邮件API调用 │
└─────────────────────────────────────┘
用Mock的测试
┌─────────────────────────────────────┐
│ registerUser() 被调用 │
│ │ │
│ ├─> 验证邮箱 │
│ │ │
│ └─> 调用 sendEmail() (假的) │
│ │ │
│ └─> 不调用真实API │
└─────────────────────────────────────┘
用Spy的测试
┌─────────────────────────────────────┐
│ registerUser() 被调用 │
│ │ │
│ ├─> 验证邮箱 │
│ │ │
│ └─> 调用 sendEmail() (真实) │
│ │ │
│ ├─> 记录:被调用了! │
│ │ │
│ └─> 继续执行原逻辑 │
└─────────────────────────────────────┘
第一部分:Mock—完全替换,绝对隔离
我先从一个真实的场景入手。假设你在做一个用户注册系统,核心逻辑在这里:
// emailService.js
export function sendEmail(to, subject, body) {
// 真实情况下,这里会调用 SendGrid、AWS SES 或其他外部API
console.log(`正在通过真实邮件服务发送邮件到 ${to}...`);
// 实际代码会是几百行的HTTP请求、重试逻辑、错误处理
}
// userController.js
import { sendEmail } from './emailService.js';
export function registerUser(user) {
if (!user.email) throw new Error('缺少邮箱地址');
// 注册逻辑...
sendEmail(user.email, '欢迎来到我们的平台!', '感谢你的注册。');
return { success: true, userId: 123 };
}
这里的问题是:sendEmail 依赖了一个外部邮件服务。在单元测试中,我们绝对不能真的去调用它。为什么?因为:
- 太慢了 —— 每次测试都等待网络请求,单元测试会变得超级慢
- 不稳定 —— 网络可能波动,邮件服务可能宕机,你的测试就会 flaky
- 有副作用 —— 你真的会往测试邮箱发送邮件(如果配置不当还会发给真实用户)
- 无法模拟异常 —— 你怎么测试邮件服务返回 500 的情况?每次都等着它真的挂掉?
这就是 Mock 要解决的核心痛点。用 Jest 的 Mock,做法是这样的:
// 这行代码告诉Jest:我要把 './emailService' 这个模块完全替换掉
jest.mock('./emailService', () => ({
sendEmail: jest.fn() // jest.fn() 创建一个假的函数
}));
// 现在当registerUser导入sendEmail时,它拿到的已经不是真的了
import { registerUser } from './userController';
import { sendEmail } from './emailService';
test('用户注册时应该发送欢迎邮件', () => {
const user = { email: 'test@example.com' };
registerUser(user);
// 关键:验证这个被假的sendEmail被正确调用了
expect(sendEmail).toHaveBeenCalledWith(
'test@example.com',
'欢迎来到我们的平台!',
'感谢你的注册。'
);
});
这里的魔法在于 jest.fn() —— 它创建的是一个会记录自己被调用信息的假函数。这个假函数:
- ✅ 不会真的去调用邮件 API
- ✅ 会记录每次被调用的参数
- ✅ 可以返回你指定的值
- ✅ 可以模拟异常
看起来很完美对吧?但这里藏着我之前说的那个坑。
Mock 的第一个坑:过度隔离
很多人写出来的 Mock 测试,根本不像真实场景。比如这样:
jest.mock('./emailService', () => ({
sendEmail: jest.fn() // 什么都不做,默认返回undefined
}));
test('注册成功', () => {
const result = registerUser({ email: 'test@example.com' });
expect(result.success).toBe(true);
expect(sendEmail).toHaveBeenCalled();
});
看起来测试通过了。但你有没有想过一个问题:如果 emailService.js 那边的 sendEmail 突然改了签名,或者抛异常了,你这个测试根本发现不了。
你的测试只验证了“注册函数调用了 sendEmail”,但没验证“注册函数正确处理了 sendEmail 的返回值”。真实场景中,如果邮件服务返回 error,注册流程该怎么处理?你的 test 根本没测。
Mock 的第二个坑:测试实现细节而不是行为
看这个例子:
test('调用sendEmail三次...不对,两次...等等是多少次?', () => {
const user1 = { email: 'user1@example.com' };
const user2 = { email: 'user2@example.com' };
registerUser(user1);
registerUser(user2);
// 这就是典型的测试实现细节
expect(sendEmail).toHaveBeenCalledTimes(2);
});
这个测试会因为你改了 registerUser 内部的任何实现细节而失败,即使外部行为根本没变。这就是脆弱的测试。
Mock 的第三个坑:模拟返回值时不够真实
Mock 最强大的地方是可以模拟各种场景。但很多人只模拟了成功的情况:
// 模拟邮件服务返回成功
jest.mock('./emailService', () => ({
sendEmail: jest.fn(() => Promise.resolve({ sent: true }))
}));
test('邮件发送成功', async () => {
const result = await registerUser({ email: 'test@example.com' });
expect(result.success).toBe(true);
});
这完全没有测试邮件服务失败的情况。在真实的生产环境中,邮件发送可能会失败,registerUser 需要处理这种情况。但你的测试从来没验证过。
第二部分:Spy—保留原貌,植入观察者
现在换个思路。有时候你不想替换真实实现,你只是想观察它怎么被使用。这就是 Spy 的职责。
假设有一个 Logger 类:
class Logger {
log(message) {
console.log(`[LOG] ${message}`);
}
}
function doSomething(logger) {
logger.log('开始处理数据');
// 做一些事情...
logger.log('处理完成');
}
用 Jest 的 spyOn,你可以这样测试:
test('应该在合适的时机记录日志', () => {
const logger = new Logger();
// 植入间谍,监听 log 方法
const spy = jest.spyOn(logger, 'log');
doSomething(logger);
// 验证log被调用了多少次
expect(spy).toHaveBeenCalledTimes(2);
// 验证调用时的具体参数
expect(spy).toHaveBeenNthCalledWith(1, '开始处理数据');
expect(spy).toHaveBeenNthCalledWith(2, '处理完成');
// 重要:清理spy,否则会污染其他测试
spy.mockRestore();
});
Spy 的妙处在于:Logger 的 log 方法的真实实现(那个 console.log)仍然在运行。你的 spy 只是在“监听”而已。
Spy的工作原理
原始方法调用链:
└─> logger.log('消息')
└─> console.log(`[LOG] 消息`)
└─> 实际打印出来
加了Spy后:
└─> logger.log('消息')
├─> Spy记录:被调用了!参数是'消息'
└─> console.log(`[LOG] 消息`)
└─> 实际打印出来
(真实逻辑继续运行,Spy只是偷偷记录)
第三部分:Jest vs Sinon—两大测试库对比
到目前为止我都在讲 Jest。但在大型项目中,有些人用 Sinon。两个库的哲学有点不同。
Jest 的风格:自动化 Mock
Jest 会在模块加载时自动处理 mock。你在测试文件顶部写 jest.mock('./emailService'),之后所有这个模块的导入都会被替换。
jest.mock('./emailService', () => ({
sendEmail: jest.fn()
}));
import { registerUser } from './userController';
import { sendEmail } from './emailService';
test('Jest风格的Mock', () => {
registerUser({ email: 'test@example.com' });
expect(sendEmail).toHaveBeenCalled();
});
这个做法优点是方便,缺点是有点“魔法”—— 如果你不了解 Jest hoisting 的原理,会很困惑。
Sinon 的风格:手动控制
Sinon 是独立的库,不依赖特定的测试框架。它给你更多的控制权:
import sinon from 'sinon';
const obj = {
greet(name) {
return `你好,${name}`;
}
};
// 创建stub(Sinon的术语,类似Mock)
const stub = sinon.stub(obj, 'greet').returns('Hi!');
obj.greet('Alice');
console.log(stub.called); // true
console.log(stub.calledWith('Alice')); // true
console.log(obj.greet('Bob')); // "Hi!" —— 完全被替换了
stub.restore(); // 清理
Sinon 还有一个强大的特性叫做 Stub,它比 Jest 的 Mock 更灵活:
const stub = sinon.stub(obj, 'fetchData');
// 可以指定返回值
stub.withArgs('success').returns({ data: 'ok' });
// 可以指定不同参数返回不同值
stub.withArgs('error').throws(new Error('服务器错误'));
// 可以链式调用
stub.onFirstCall().returns(1);
stub.onSecondCall().returns(2);
obj.fetchData() // 返回 1
obj.fetchData() // 返回 2
第四部分:真实场景—行为驱动设计
现在来看一个真正复杂的场景,说明 Mock 和 Spy 怎么配合使用才能写出有意义的测试。
假设你在做一个订单系统:
export class OrderService {
constructor(paymentAPI, emailService, logger) {
this.paymentAPI = paymentAPI;
this.emailService = emailService;
this.logger = logger;
}
async placeOrder(order) {
try {
this.logger.log(`订单创建: ${order.id}`);
// 步骤1:调用支付API
const payment = await this.paymentAPI.charge(order.amount);
// 步骤2:如果支付成功,发邮件通知
if (payment.success) {
await this.emailService.sendOrderConfirmation(order.id);
}
this.logger.log(`订单完成: ${order.id}`);
return { success: true };
} catch (error) {
this.logger.error(`订单失败: ${error.message}`);
throw error;
}
}
}
如果用过度 Mock 的方式测试,你会写出这样的代码(这是坏的):
// ❌ 不推荐的做法:过度Mock
test('订单流程应该调用支付API', () => {
const paymentAPI = { charge: jest.fn(() => Promise.resolve({ success: true })) };
const emailService = { sendOrderConfirmation: jest.fn() };
const logger = { log: jest.fn(), error: jest.fn() };
const service = new OrderService(paymentAPI, emailService, logger);
service.placeOrder({ id: 1, amount: 100 });
// 只在乎是否被调用了,不在乎真实的业务逻辑
expect(paymentAPI.charge).toHaveBeenCalled();
expect(emailService.sendOrderConfirmation).toHaveBeenCalled();
});
这个测试的问题:
- 没有验证调用的参数是否正确
- 没有验证订单的实际状态变化
- 没有测试支付失败的情况
- 没有验证正确的调用顺序
正确的做法应该是这样的(称为行为驱动):
// ✅ 推荐的做法:行为驱动
test('订单完成时应该发送确认邮件', async () => {
const paymentAPI = { charge: jest.fn(() => Promise.resolve({ success: true })) };
const emailService = { sendOrderConfirmation: jest.fn(() => Promise.resolve()) };
const logger = { log: jest.fn(), error: jest.fn() };
const service = new OrderService(paymentAPI, emailService, logger);
// 关键:准备一个真实的订单对象
const order = { id: '12345', amount: 100, email: 'customer@example.com' };
// 执行业务逻辑
const result = await service.placeOrder(order);
// 验证外部行为
expect(result.success).toBe(true);
// 验证支付API被正确调用(参数重要)
expect(paymentAPI.charge).toHaveBeenCalledWith(100);
// 验证确认邮件被发送(只在支付成功时)
expect(emailService.sendOrderConfirmation).toHaveBeenCalledWith('12345');
// 验证日志记录(用Spy可以加强这一点)
expect(logger.log).toHaveBeenCalledWith('订单创建: 12345');
expect(logger.log).toHaveBeenCalledWith('订单完成: 12345');
});
test('支付失败时不应该发送邮件', async () => {
const paymentAPI = {
charge: jest.fn(() => Promise.reject(new Error('支付被拒')))
};
const emailService = { sendOrderConfirmation: jest.fn() };
const logger = { log: jest.fn(), error: jest.fn() };
const service = new OrderService(paymentAPI, emailService, logger);
const order = { id: '12345', amount: 100 };
// 执行
try {
await service.placeOrder(order);
} catch (e) {
// 预期会抛异常
}
// 关键验证:邮件不应该被发送
expect(emailService.sendOrderConfirmation).not.toHaveBeenCalled();
// 验证错误被正确记录
expect(logger.error).toHaveBeenCalledWith(expect.stringContaining('支付被拒'));
});
看这个例子,模拟外部依赖(paymentAPI、emailService)是必要的,因为它们有副作用。但我们验证的不是“这些方法被调用了”,而是“在订单成功的情况下邮件被发送了,在支付失败的情况下没有发送”——这是真实的行为。
第五部分:常见的测试陷阱
陷阱 1:Mock 泄露了实现细节
// ❌ 坏的例子
export function calculateTotal(items) {
const subtotal = items.reduce((sum, item) => sum + item.price, 0);
const tax = subtotal * 0.1;
const total = subtotal + tax;
return total;
}
test('计算总价', () => {
const items = [{ price: 100 }, { price: 50 }];
expect(calculateTotal(items)).toBe(165);
// 这个测试很脆弱,如果你后来改了税率计算逻辑,测试就会失败
});
陷阱 2:Spy 没有清理导致污染其他测试
// ❌ 坏的例子
test('第一个测试', () => {
const spy = jest.spyOn(console, 'log');
console.log('hello');
expect(spy).toHaveBeenCalled();
// 忘记调用 spy.mockRestore()
});
test('第二个测试', () => {
console.log('world');
// 第一个测试的spy仍然在生效,可能导致意外结果
});
陷阱 3:Mock 隐藏了真实问题
// ❌ 坏的例子
jest.mock('./database', () => ({
query: jest.fn(() => Promise.resolve([]))
}));
test('用户查询', async () => {
const result = await userService.findById(1);
expect(result).toBeDefined();
});
// 这个测试完全没有测试SQL构建的正确性
// 在真实数据库中可能会因为ORM的问题而失败
陷阱 4:过度信任 Mock 的返回值
// ❌ 坏的例子
const apiMock = jest.fn(() => ({
userId: 123,
name: 'John'
}));
test('API返回用户信息', () => {
const result = apiMock();
expect(result.userId).toBe(123);
// 这个测试其实在测Mock本身,不是在测你的代码
});
第六部分:实战建议—何时用 Mock,何时用 Spy
让我总结一个决策树,帮你在真实项目中选择:
你需要验证一个函数吗?
│
├─ 需要隔离外部依赖(API、数据库、文件系统)?
│ └─ 是 → 用 Mock
│ 理由:外部依赖有副作用,不能真的调用
│
├─ 你想保持真实实现但需要验证调用方式?
│ └─ 是 → 用 Spy
│ 理由:关心的是怎么被调用,不想改变行为
│
└─ 你想测试完整的集成流程?
└─ 是 → 最少化Mock,只Mock最外层的依赖
理由:太多Mock会让测试脱离现实
一个核心原则:Mock 应该 Mock 你不拥有的代码(第三方库、外部服务),Spy 应该 Spy 你自己的代码。
第七部分:性能和可维护性思考
到这里,我想讨论一个更深层的问题:过度的 Mock 和 Spy 会让你的测试成为“假的通过”。
考虑这个场景:你有一个复杂的用户注册流程,涉及验证邮箱、检查用户名、创建账户、发送欢迎邮件。如果你 Mock 了所有外部调用:
// 假如这样写
jest.mock('./emailService');
jest.mock('./database');
jest.mock('./usernameValidator');
test('用户注册', () => {
// 所有依赖都被Mock了,测试"通过"了
// 但真实场景中数据库的schema改了,你根本发现不了
});
一个更健康的方案是:
- 在单元测试中,Mock 外部的、不可控的服务(邮件 API、支付网关)
- 在集成测试中,使用真实的数据库(或者测试数据库)
- 保持一些端到端测试,验证完整流程
单元测试 集成测试 E2E测试
─────────────────────────────────────
Mock程度 高 中 低
速度 很快 中等 较慢
真实性 低 中 高
成本 低 中 高
现代开发中的最佳实践是找到一个平衡点。不要所有东西都 Mock(那样的测试毫无意义),也不要什么都不 Mock(那样的测试会很慢)。
最后一个真实案例
我来分享一个从我的真实项目中提取出来的例子。这是一个用户认证模块的测试:
// auth.service.js
export class AuthService {
constructor(userRepository, emailService, jwtService) {
this.userRepository = userRepository;
this.emailService = emailService;
this.jwtService = jwtService;
}
async register(email, password) {
// 验证邮箱格式(这是我自己的代码,应该真实测试)
if (!this.isValidEmail(email)) {
throw new Error('邮箱格式不对');
}
// 检查邮箱是否已存在(涉及数据库,可以Mock)
const exists = await this.userRepository.findByEmail(email);
if (exists) {
throw new Error('邮箱已注册');
}
// 创建用户(涉及数据库,可以Mock)
const user = await this.userRepository.create({ email, password });
// 发送确认邮件(涉及外部服务,应该Mock)
await this.emailService.sendVerificationEmail(email);
// 生成token(这是我自己的代码,应该真实测试)
const token = this.jwtService.sign({ userId: user.id });
return { user, token };
}
isValidEmail(email) {
// 实现邮箱验证逻辑
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}
}
这是一个正确的测试写法:
describe('AuthService', () => {
let service;
let userRepository;
let emailService;
let jwtService;
beforeEach(() => {
// Mock数据库和邮件服务
userRepository = {
findByEmail: jest.fn(),
create: jest.fn()
};
emailService = {
sendVerificationEmail: jest.fn()
};
// 注意:jwtService是我们自己的代码,不Mock
jwtService = new JwtService('secret');
service = new AuthService(userRepository, emailService, jwtService);
});
test('邮箱格式验证失败应该抛异常', async () => {
await expect(service.register('invalid-email', 'password'))
.rejects.toThrow('邮箱格式不对');
// 关键:不应该调用任何依赖
expect(userRepository.findByEmail).not.toHaveBeenCalled();
});
test('邮箱已存在应该抛异常', async () => {
userRepository.findByEmail.mockResolvedValue({ id: 1 });
await expect(service.register('test@example.com', 'password'))
.rejects.toThrow('邮箱已注册');
// 验证查询了数据库
expect(userRepository.findByEmail).toHaveBeenCalledWith('test@example.com');
// 但不应该创建新用户
expect(userRepository.create).not.toHaveBeenCalled();
});
test('注册成功应该创建用户并发送邮件', async () => {
const newUser = { id: 1, email: 'test@example.com' };
userRepository.findByEmail.mockResolvedValue(null);
userRepository.create.mockResolvedValue(newUser);
const result = await service.register('test@example.com', 'password');
// 验证用户被创建
expect(userRepository.create).toHaveBeenCalledWith({
email: 'test@example.com',
password: 'password'
});
// 验证邮件被发送
expect(emailService.sendVerificationEmail)
.toHaveBeenCalledWith('test@example.com');
// 验证返回了正确的结果(包括token)
expect(result.user).toEqual(newUser);
expect(result.token).toBeDefined();
// token应该包含正确的userId
const decoded = jwtService.verify(result.token);
expect(decoded.userId).toBe(1);
});
test('邮件发送失败应该触发异常', async () => {
userRepository.findByEmail.mockResolvedValue(null);
userRepository.create.mockResolvedValue({ id: 1 });
emailService.sendVerificationEmail.mockRejectedValue(
new Error('邮件服务故障')
);
await expect(service.register('test@example.com', 'password'))
.rejects.toThrow('邮件服务故障');
});
});
看这个例子:
- ✅ 用 Mock 隔离了数据库和邮件服务(它们有副作用)
- ✅ 没有 Mock
jwtService(这是我们自己的代码,应该真实测试)
- ✅ 验证的是真实的业务行为,不是实现细节
- ✅ 测试了成功和失败两种路径
- ✅ 清楚地表达了“当 A 时,B 应该发生”
总结与反思
我在最开始提到的那个 bug,其实就是因为他的测试 Mock 了 sendEmail 但从来没验证过在邮件服务异常时是怎么处理的。他的代码根本没有任何错误处理逻辑,但因为 Mock 隐藏了这一点,测试通过了。
Mock 和 Spy 是强大的工具,但“强大的工具往往被滥用”。关键是:
- 理解你在 Mock 什么:是在隔离依赖,还是在欺骗自己?
- 验证真实的行为:不是验证实现细节,而是验证在特定条件下应该发生什么
- 保持平衡:既要 Mock 外部依赖,也要保留足够的真实性
- 定期反思:如果测试太容易通过,那可能是 Mock 太多了
如果你的测试只验证了函数被调用了几次,但从没验证过返回值是否正确、是否处理了异常、是否满足了业务逻辑,那你写的不是测试,是自欺欺人。构建高质量的测试套件需要清晰的策略和对工具本质的理解,这恰恰是优秀开发者与运维 & 测试工程师深度思考的体现。技术社区的交流,例如在云栈社区中分享这些实战心得,能帮助我们共同避开这些隐形的陷阱。