⚠️ Alpha内测版本警告:此为早期内部构建版本,尚不完整且可能存在错误,欢迎大家提Issue反馈问题或建议
Skip to content

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