
上周,我和一个小伙伴因为一个bug大吵了一架。他写了一个用户注册功能,所有单元测试都通过了,绿灯一片。但部署到测试环境后,邮件服务直接挂掉了。
我问他:"你测试里是怎么处理邮件发送的?"
他挠挠头:"我mock了呀,这样就不会真的去调用邮件API啊。"
我接着问:"那你的mock是真的在隔离依赖,还是在欺骗自己——你的测试根本没在测什么?"
这个对话促使我深度思考mock和spy两个工具。很多开发者把它们当成了测试中的"魔法道具",会用但不知道在做什么。更恐怖的是,很多人写出来的测试根本没在测试真正重要的东西。
在开始讲代码之前,我想从哲学角度定义一下这两个工具的本质。
Mock(模拟) 的本质是:用一个假的东西完全替换掉真实的东西,让被测试的代码即使调用了它也不会对系统造成影响。
Spy(间谍) 的本质是:在不改变真实实现的前提下,偷偷观察真实对象怎么被使用的。
这两个工具解决的是同一个核心问题:依赖隔离。但它们的隔离策略完全不同。
来画个图理解一下:
真实应用的一次调用链路
┌─────────────────────────────────────┐
│ registerUser() 被调用 │
│ │ │
│ ├─> 验证邮箱 │
│ │ │
│ └─> 调用 sendEmail() │
│ │ │
│ └─> 真实邮件API调用 │
└─────────────────────────────────────┘
用Mock的测试
┌─────────────────────────────────────┐
│ registerUser() 被调用 │
│ │ │
│ ├─> 验证邮箱 │
│ │ │
│ └─> 调用 sendEmail() (假的) │
│ │ │
│ └─> 不调用真实API │
└─────────────────────────────────────┘
用Spy的测试
┌─────────────────────────────────────┐
│ registerUser() 被调用 │
│ │ │
│ ├─> 验证邮箱 │
│ │ │
│ └─> 调用 sendEmail() (真实) │
│ │ │
│ ├─> 记录:被调用了! │
│ │ │
│ └─> 继续执行原逻辑 │
└─────────────────────────────────────┘
我先从一个真实的场景入手。假设你在做一个用户注册系统,核心逻辑在这里:
// emailService.js
export function sendEmail(to, subject, body) {
// 真实情况下,这里会调用 SendGrid、AWS SES 或其他外部API
console.log(`正在通过真实邮件服务发送邮件到 ${to}...`);
// 实际代码会是几百行的HTTP请求、重试逻辑、错误处理
}
// userController.js
import { sendEmail } from'./emailService.js';
exportfunction registerUser(user) {
if (!user.email) thrownewError('缺少邮箱地址');
// 注册逻辑...
sendEmail(user.email, '欢迎来到我们的平台!', '感谢你的注册。');
return { success: true, userId: 123 };
}
这里的问题是:sendEmail 依赖了一个外部邮件服务。在单元测试中,我们绝对不能真的去调用它。为什么?因为:
这就是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() —— 它创建的是一个会记录自己被调用信息的假函数。这个假函数:
看起来很完美对吧?但这里藏着我之前说的那个坑。
很多人写出来的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根本没测。
看这个例子:
test('调用sendEmail三次...不对,两次...等等是多少次?', () => {
const user1 = { email: 'user1@example.com' };
const user2 = { email: 'user2@example.com' };
registerUser(user1);
registerUser(user2);
// 这就是典型的测试实现细节
expect(sendEmail).toHaveBeenCalledTimes(2);
});
这个测试会因为你改了registerUser内部的任何实现细节而失败,即使外部行为根本没变。这就是脆弱的测试。
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的职责。
假设有一个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。但在大型项目中,有些人用Sinon。两个库的哲学有点不同。
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是独立的库,不依赖特定的测试框架。它给你更多的控制权:
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 = awaitthis.paymentAPI.charge(order.amount);
// 步骤2:如果支付成功,发邮件通知
if (payment.success) {
awaitthis.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('订单完成时应该发送确认邮件', () => {
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(newError('支付被拒')))
};
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)是必要的,因为它们有副作用。但我们验证的不是"这些方法被调用了",而是"在订单成功的情况下邮件被发送了,在支付失败的情况下没有发送"——这是真实的行为。
// ❌ 坏的例子
exportfunction 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);
// 这个测试很脆弱,如果你后来改了税率计算逻辑,测试就会失败
});
// ❌ 坏的例子
test('第一个测试', () => {
const spy = jest.spyOn(console, 'log');
console.log('hello');
expect(spy).toHaveBeenCalled();
// 忘记调用 spy.mockRestore()
});
test('第二个测试', () => {
console.log('world');
// 第一个测试的spy仍然在生效,可能导致意外结果
});
// ❌ 坏的例子
jest.mock('./database', () => ({
query: jest.fn(() => Promise.resolve([]))
}));
test('用户查询', async () => {
const result = await userService.findById(1);
expect(result).toBeDefined();
});
// 这个测试完全没有测试SQL构建的正确性
// 在真实数据库中可能会因为ORM的问题而失败
// ❌ 坏的例子
const apiMock = jest.fn(() => ({
userId: 123,
name: 'John'
}));
test('API返回用户信息', () => {
const result = apiMock();
expect(result.userId).toBe(123);
// 这个测试其实在测Mock本身,不是在测你的代码
});
让我总结一个决策树,帮你在真实项目中选择:
你需要验证一个函数吗?
│
├─ 需要隔离外部依赖(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改了,你根本发现不了
});
一个更健康的方案是:
单元测试 集成测试 E2E测试
─────────────────────────────────────
Mock程度 高 中 低
速度 很快 中等 较慢
真实性 低 中 高
成本 低 中 高
现代开发中的最佳实践是找到一个平衡点。不要所有东西都Mock(那样的测试毫无意义),也不要什么都不Mock(那样的测试会很慢)。
我来分享一个从我的真实项目中提取出来的例子。这是一个用户认证模块的测试:
// auth.service.js
exportclass AuthService {
constructor(userRepository, emailService, jwtService) {
this.userRepository = userRepository;
this.emailService = emailService;
this.jwtService = jwtService;
}
async register(email, password) {
// 验证邮箱格式(这是我自己的代码,应该真实测试)
if (!this.isValidEmail(email)) {
thrownewError('邮箱格式不对');
}
// 检查邮箱是否已存在(涉及数据库,可以Mock)
const exists = awaitthis.userRepository.findByEmail(email);
if (exists) {
thrownewError('邮箱已注册');
}
// 创建用户(涉及数据库,可以Mock)
const user = awaitthis.userRepository.create({ email, password });
// 发送确认邮件(涉及外部服务,应该Mock)
awaitthis.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(
newError('邮件服务故障')
);
await expect(service.register('test@example.com', 'password'))
.rejects.toThrow('邮件服务故障');
});
});
看这个例子:
jwtService(这是我们自己的代码,应该真实测试)我在最开始提到的那个bug,其实就是因为他的测试Mock了sendEmail但从来没验证过在邮件服务异常时是怎么处理的。他的代码根本没有任何错误处理逻辑,但因为Mock隐藏了这一点,测试通过了。
Mock和Spy是强大的工具,但"强大的工具往往被滥用"。关键是:
如果你的测试只验证了函数被调用了几次,但从没验证过返回值是否正确、是否处理了异常、是否满足了业务逻辑,那你写的不是测试,是自欺欺人。
如果这篇文章对你有帮助,请别忘记点赞、分享和推荐给更多需要的开发者。 让我们一起把更多想要提升测试水平的程序员聚集在这里,形成一个真正思考技术本质的社区。