找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

3431

积分

0

好友

469

主题
发表于 2026-2-12 14:55:04 | 查看: 34| 回复: 0

程序员面对屏幕进行测试的扁平化插画

上周,我和一个小伙伴因为一个 Bug 大吵了一架。他写了一个用户注册功能,所有单元测试都通过了,绿灯一片。但部署到测试环境后,邮件服务直接挂掉了。

我问他:“你测试里是怎么处理邮件发送的?”

他挠挠头:“我 mock 了呀,这样就不会真的去调用邮件 API 啊。”

我接着问:“那你的 mock 是真的在隔离依赖,还是在欺骗自己——你的测试根本没在测什么?”

这个对话促使我深度思考 mockspy 两个工具。很多开发者把它们当成了软件测试中的“魔法道具”,会用但不知道在做什么。更令人担忧的是,很多人写出来的测试根本没在测试真正重要的东西。

核心问题: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 依赖了一个外部邮件服务。在单元测试中,我们绝对不能真的去调用它。为什么?因为:

  1. 太慢了 —— 每次测试都等待网络请求,单元测试会变得超级慢
  2. 不稳定 —— 网络可能波动,邮件服务可能宕机,你的测试就会 flaky
  3. 有副作用 —— 你真的会往测试邮箱发送邮件(如果配置不当还会发给真实用户)
  4. 无法模拟异常 —— 你怎么测试邮件服务返回 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();
});

这个测试的问题:

  1. 没有验证调用的参数是否正确
  2. 没有验证订单的实际状态变化
  3. 没有测试支付失败的情况
  4. 没有验证正确的调用顺序

正确的做法应该是这样的(称为行为驱动):

// ✅ 推荐的做法:行为驱动
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('支付被拒'));
});

看这个例子,模拟外部依赖(paymentAPIemailService)是必要的,因为它们有副作用。但我们验证的不是“这些方法被调用了”,而是“在订单成功的情况下邮件被发送了,在支付失败的情况下没有发送”——这是真实的行为。

第五部分:常见的测试陷阱

陷阱 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 是强大的工具,但“强大的工具往往被滥用”。关键是:

  1. 理解你在 Mock 什么:是在隔离依赖,还是在欺骗自己?
  2. 验证真实的行为:不是验证实现细节,而是验证在特定条件下应该发生什么
  3. 保持平衡:既要 Mock 外部依赖,也要保留足够的真实性
  4. 定期反思:如果测试太容易通过,那可能是 Mock 太多了

如果你的测试只验证了函数被调用了几次,但从没验证过返回值是否正确、是否处理了异常、是否满足了业务逻辑,那你写的不是测试,是自欺欺人。构建高质量的测试套件需要清晰的策略和对工具本质的理解,这恰恰是优秀开发者与运维 & 测试工程师深度思考的体现。技术社区的交流,例如在云栈社区中分享这些实战心得,能帮助我们共同避开这些隐形的陷阱。




上一篇:Qwen-Image-2.0图像生成模型实测:中文理解与编辑能力深度评测
下一篇:K8s生产环境十大高频故障深度复盘与解决方案
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-2-23 10:26 , Processed in 0.808041 second(s), 40 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表