单元测试的核心并不仅仅是“跑一遍被测函数”,而是通过精确的验证手段,确认代码在各种输入和边界条件下都能产生预期的行为。这一节将拆解四个在 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. 编写断言时的实践经验
- 一个测试最好只针对一个行为,避免多个无关的断言混在一起。
- 使用精确匹配器而不是万能
toBeDefined:expect(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 负责“隔离谁”,覆盖率则帮我们发现“漏了哪”。掌握它们的实际用法,就能用更少的测试代码获得更强的质量保障。在下一小节中,我们会把这些概念应用到具体的服务层和工具函数测试中,让整条测试链路真正运转起来。