9.3.4 外部依赖怎么办——Mock 策略:外部依赖的模拟
Mock 的目的是隔离测试,让你专注于测试自己的代码,而不是第三方服务的行为。
需要 Mock 的场景
Jest Mock 基础
模拟函数
typescript
// 创建 Mock 函数
const mockFn = jest.fn();
// 设置返回值
mockFn.mockReturnValue('hello');
mockFn.mockReturnValueOnce('first').mockReturnValueOnce('second');
// 设置异步返回值
mockFn.mockResolvedValue({ data: 'async result' });
mockFn.mockRejectedValue(new Error('failed'));
// 验证调用
expect(mockFn).toHaveBeenCalled();
expect(mockFn).toHaveBeenCalledWith('arg1', 'arg2');
expect(mockFn).toHaveBeenCalledTimes(2);模拟模块
typescript
// 自动模拟整个模块
jest.mock('@/lib/stripe');
// 手动实现模拟
jest.mock('@/lib/email', () => ({
sendEmail: jest.fn().mockResolvedValue({ success: true }),
sendBulkEmail: jest.fn().mockResolvedValue({ sent: 10 }),
}));常见 Mock 场景
场景一:Mock HTTP 请求
typescript
// 方法一:使用 msw(推荐)
// test/mocks/handlers.ts
import { rest } from 'msw';
export const handlers = [
rest.get('https://api.example.com/users/:id', (req, res, ctx) => {
return res(
ctx.status(200),
ctx.json({ id: req.params.id, name: 'Test User' })
);
}),
rest.post('https://api.example.com/orders', async (req, res, ctx) => {
const body = await req.json();
return res(
ctx.status(201),
ctx.json({ id: 'order-123', ...body })
);
}),
];
// test/mocks/server.ts
import { setupServer } from 'msw/node';
import { handlers } from './handlers';
export const server = setupServer(...handlers);
// jest.setup.ts
import { server } from './test/mocks/server';
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());typescript
// 方法二:Mock fetch
global.fetch = jest.fn().mockResolvedValue({
ok: true,
json: async () => ({ data: 'mocked' }),
});场景二:Mock 支付服务
typescript
// __mocks__/stripe.ts
export const createPaymentIntent = jest.fn().mockResolvedValue({
id: 'pi_test_123',
client_secret: 'secret_123',
status: 'requires_payment_method',
});
export const confirmPayment = jest.fn().mockResolvedValue({
id: 'pi_test_123',
status: 'succeeded',
});
export const refundPayment = jest.fn().mockResolvedValue({
id: 're_test_123',
status: 'succeeded',
});
// 测试中使用
jest.mock('@/lib/stripe');
describe('PaymentService', () => {
it('应创建支付意图', async () => {
const result = await paymentService.createIntent(1000, 'CNY');
expect(createPaymentIntent).toHaveBeenCalledWith({
amount: 1000,
currency: 'CNY',
});
expect(result.client_secret).toBe('secret_123');
});
});场景三:Mock 邮件服务
typescript
// services/email.service.ts
export class EmailService {
async sendWelcomeEmail(user: User): Promise<void> {
await this.send({
to: user.email,
subject: '欢迎加入',
template: 'welcome',
data: { name: user.name },
});
}
}
// __tests__/services/email.service.test.ts
import { EmailService } from '@/services/email.service';
jest.mock('@/lib/mailer', () => ({
send: jest.fn().mockResolvedValue({ messageId: 'msg-123' }),
}));
import { send } from '@/lib/mailer';
describe('EmailService', () => {
it('应发送欢迎邮件', async () => {
const emailService = new EmailService();
const user = { email: 'test@example.com', name: 'Test' };
await emailService.sendWelcomeEmail(user);
expect(send).toHaveBeenCalledWith(
expect.objectContaining({
to: 'test@example.com',
subject: '欢迎加入',
})
);
});
});场景四:Mock 时间
typescript
// __tests__/services/subscription.service.test.ts
describe('SubscriptionService', () => {
beforeEach(() => {
jest.useFakeTimers();
jest.setSystemTime(new Date('2024-01-15'));
});
afterEach(() => {
jest.useRealTimers();
});
it('应正确计算订阅到期日', async () => {
const subscription = await subscriptionService.create({
userId: 'user-1',
plan: 'monthly',
});
expect(subscription.expiresAt).toEqual(new Date('2024-02-15'));
});
it('应识别过期订阅', async () => {
const subscription = await createSubscription({
expiresAt: new Date('2024-01-10'), // 已过期
});
expect(subscription.isExpired()).toBe(true);
});
});场景五:Mock 文件上传
typescript
// __mocks__/@/lib/storage.ts
export const uploadFile = jest.fn().mockResolvedValue({
url: 'https://cdn.example.com/files/test.jpg',
key: 'files/test.jpg',
});
export const deleteFile = jest.fn().mockResolvedValue(undefined);
// 测试
describe('FileService', () => {
it('应上传文件并返回 URL', async () => {
const file = new File(['test'], 'test.jpg', { type: 'image/jpeg' });
const result = await fileService.upload(file);
expect(result.url).toBe('https://cdn.example.com/files/test.jpg');
expect(uploadFile).toHaveBeenCalledWith(file, expect.any(Object));
});
});Mock 最佳实践
1. 只 Mock 边界
typescript
// ✅ 好:Mock 外部服务边界
jest.mock('@/lib/stripe');
// ❌ 坏:Mock 内部模块
jest.mock('@/services/order.service');2. 验证 Mock 被调用
typescript
it('应调用支付服务', async () => {
await orderService.checkout(orderId);
// 验证外部服务被正确调用
expect(createPaymentIntent).toHaveBeenCalledWith(
expect.objectContaining({
amount: 1000,
currency: 'CNY',
})
);
});3. 测试错误场景
typescript
it('支付失败时应回滚订单', async () => {
confirmPayment.mockRejectedValueOnce(new Error('Card declined'));
await expect(orderService.checkout(orderId)).rejects.toThrow('支付失败');
const order = await prisma.order.findUnique({ where: { id: orderId } });
expect(order?.status).toBe('CANCELLED');
});4. 清理 Mock 状态
typescript
// jest.config.ts
export default {
clearMocks: true, // 每次测试后清理调用记录
restoreMocks: true, // 每次测试后恢复原始实现
};
// 或手动清理
afterEach(() => {
jest.clearAllMocks();
});本节小结
Mock 是隔离测试的重要工具。只 Mock 外部边界(第三方 API、支付、邮件等),保持内部逻辑的真实测试。使用 MSW 处理 HTTP 请求,使用 jest.fn() 处理函数调用,使用 jest.useFakeTimers() 处理时间相关测试。记住:Mock 越少越好,但边界必须 Mock。
