人人都会AI编程

断言、Mock、Stub、测试覆盖率

更新时间:2026-07-10

单元测试的核心并不仅仅是“跑一遍被测函数”,而是通过精确的验证手段,确认代码在各种输入和边界条件下都能产生预期的行为。这一节将拆解四个在 Node.js 项目中每天都会用到的测试概念:断言、Mock、Stub,以及测试覆盖率。结合 Jest 和 Vitest 的示例,让它们不但好懂,而且拿了就能用。

一、断言(Assertion):用代码验证你的想象

断言是单元测试的最小原子——一句“我预期某个值等于什么”或“某段代码应该抛错”的声明。如果断言失败,测试框架会标记该测试用例为失败,并给出期望值与实际值的对比。

1. 常用断言风格

Jest 和 Vitest 都内置了丰富的断言匹配器(matcher),覆盖了日常开发中的绝大多数验证需求:

// 相等性判断
expect(sum(1, 2)).toBe(3);               // 严格相等(===)
expect({ name: 'Node' }).toEqual({ name: 'Node' }); // 深度相等(用于对象/数组)

// 真假性
expect(getUser()).toBeTruthy();          // 非假值(非false、0、null等)
expect(null).toBeNull();                 // 是否为 null

// 包含性
expect(['js', 'node']).toContain('node');
expect('Hello World').toMatch(/World/);  // 正则匹配

// 异常
expect(() => { throw new Error('Oops'); }).toThrow('Oops');

// 异步
await expect(fetchData()).resolves.toEqual({ id: 1 });  // Promise resolved
await expect(fetchError()).rejects.toThrow('Network');  // Promise rejected

2. 编写断言时的实践经验

  • 一个测试最好只针对一个行为,避免多个无关的断言混在一起。
  • 使用精确匹配器而不是万能 toBeDefinedexpect(result).toEqual({ id: 1 })expect(result).toBeDefined() 能捕捉更多意外。
  • 异常测试非常重要:很多 bug 源自错误未正确处理,一定要覆盖函数在非法参数、异常场景下的抛出行为。
// 不好的断言:太宽松
expect(user).toBeDefined();

// 好的断言:明确具体结构
expect(user).toEqual({
  id: 1,
  name: 'Node.js',
  role: 'runtime'
});

二、Mock(模拟):隔离外部依赖的替身

Mock 的核心作用是在测试中用预设行为的假对象替换真实对象,从而隔离被测单元对外部模块、数据库、网络请求等非纯函数的依赖。

1. 用 Mock 隔离依赖

假设我们的 UserService 依赖了 UserRepository(负责数据库操作)。在测试 UserService.getUserById 时,根本不想真的去连数据库,只要验证:当 UserRepository.findById 返回特定数据时,UserService 是否能正确格式化并返回。

Jest/Vitest 都提供 jest.fn()vi.fn() 来创建 Mock 函数:

// 被测对象:UserService
class UserService {
  constructor(userRepo) {
    this.userRepo = userRepo;
  }
  async getUserInfo(id) {
    const user = await this.userRepo.findById(id);
    if (!user) throw new Error('User not found');
    return { name: user.name.toUpperCase(), email: user.email };
  }
}

// 测试用例
test('getUserInfo 应返回大写名称和原样邮箱', async () => {
  // 创建 Mock 对象,模拟 findById 方法
  const mockRepo = {
    findById: jest.fn().mockResolvedValue({
      id: 1,
      name: 'alice',
      email: 'alice@example.com'
    })
  };

  const service = new UserService(mockRepo);
  const result = await service.getUserInfo(1);

  // 验证返回结果
  expect(result).toEqual({ name: 'ALICE', email: 'alice@example.com' });

  // 验证 Mock 方法是否被调用,且参数正确
  expect(mockRepo.findById).toHaveBeenCalledTimes(1);
  expect(mockRepo.findById).toHaveBeenCalledWith(1);
});

这样,测试不仅验证了业务逻辑,还证实了 UserService 正确地调用了其依赖,且参数传递无误。

2. 模块级别的自动 Mock

Jest 可以自动 Mock 整个模块,避免手动为每个方法创建 Mock:

jest.mock('../src/emailService');
const emailService = require('../src/emailService');

// emailService.send 现在是自动 Mock 函数
test('注册时发送邮件', () => {
  emailService.send.mockResolvedValue(true);
  // ...
});

类似的,Vitest 中有 vi.mock()。使用自动 Mock 可以快速隔离第三方库(如 axios、fs 等),不过需要注意 Mock 后行为可能过于“安静”,关键调用需要显式配置返回值。


三、Stub(桩):控制间接输入与输出

Stub 是 Mock 的一种特定类型,侧重于预先设定函数的返回值或行为,而不关心它是否被调用、调用了多少次。Mock 更像一个全能替身,而 Stub 则比较“老实”,它只会按你的指示返回数据,不会主动记录调用信息(在测试框架中,其实还是用 Mock 函数实现 Stub,但意图不同)。

Stub 的典型使用场景:

  • 提供固定测试数据:数据库查询的返回值、文件读取的内容。
  • 触发特定异常:测试错误处理分支时,让依赖函数抛出错误。
  • 替换耗时操作:用同步返回替代需要真实 I/O 的异步方法。
// 使 findById 返回一个固定对象(stub 意图)
const stubRepo = {
  findById: jest.fn().mockReturnValue({ id: 1, name: 'bob' })
};

Mock 和 Stub 之间的边界在现代测试框架中已经模糊,关键要理解的是:我们到底需要验证被替换的东西? (Mock)还是只是需要一个哑元提供数据?

实践中更常用的方式是:当需要验证调用参数、调用次数时,显式使用 Mock;如果只需要提供固定返回值来驱动被测代码走向某个分支,那就是 Stub 的心态。 对团队来说,不必纠结名词,但搞清楚“验证调用”还是“仅提供输入”能避免测试过度绑定实现细节。


四、测试覆盖率:数字背后的健康度

测试覆盖率衡量的是被执行的代码占总代码的比例,通常分为:

  • 行覆盖率(Line):代码行是否被执行。
  • 函数覆盖率(Function):每个函数是否被调用过。
  • 分支覆盖率(Branch):if/else、switch 等分支是否都被测试走过。
  • 语句覆盖率(Statement):每个语句是否被执行。

1. 运行覆盖率报告

Jest 自带覆盖率工具(底层为 istanbul),运行:

jest --coverage

或 Vitest:

vitest --coverage

会在终端输出一个精简表,同时在 coverage/ 目录生成 HTML 报告,可以直观查看哪些文件、哪些行未被覆盖。

2. 如何正确看待覆盖率数值

高覆盖率 ≠ 高质量测试。100% 覆盖率只能说明代码被跑了一遍,无法保障逻辑正确无误。片面追求覆盖率常导致团队写出大量“为了覆盖而覆盖”的浅层测试——只调用了函数但不做任何断言,这不仅没有价值,还增加了维护负担。

更务实的态度是:

  • 核心业务逻辑的目标覆盖率应在 80% 以上,尤其是 Service 层和工具函数。
  • 分支覆盖比行覆盖更重要:如果一段代码里有复杂的条件判断,确保所有分支都有测试用例。
  • 关键模块可用“突变测试”辅查(如 Stryker Mutator),它能主动修改代码来检查测试是否真的能捕获 Bug,比覆盖率更能反映测试质量。
  • 在 CI 流程中设置覆盖率门槛(例如 branches >= 80%),低于阈值构建失败,但不要设得太高以至于阻碍正常开发。
// Jest 配置示例(jest.config.js)
module.exports = {
  coverageThreshold: {
    global: {
      branches: 80,
      functions: 80,
      lines: 80,
      statements: 80
    }
  }
};

3. 避免覆盖率骗局

如果发现某个工具函数覆盖率为 100% 但线上仍有 Bug,往往是因为:缺少对边界值(null、undefined、空数组等)、异常路径与其他模块交互的测试。覆盖率报告只是测试工作的起点,而不是终点。


综合起来,断言、Mock、Stub、覆盖率这四个概念构成了 Node.js 单元测试的基石。断言负责“怎么验”,Mock 和 Stub 负责“隔离谁”,覆盖率则帮我们发现“漏了哪”。掌握它们的实际用法,就能用更少的测试代码获得更强的质量保障。在下一小节中,我们会把这些概念应用到具体的服务层和工具函数测试中,让整条测试链路真正运转起来。