Jest 最佳实践
src/ utils/ formatDate.js tests/ formatDate.test.js components/ Button.jsx tests/ Button.test.jsx
- **优点**:测试与源码紧密关联,容易定位。
- **适用场景**:组件或工具函数的单元测试。
#### 1.2 独立 `tests/` 目录与镜像结构
为整个项目创建一个顶层的 `tests/` 目录,并复制 `src/` 的目录结构。
src/ utils/formatDate.js components/Button.jsx tests/ utils/formatDate.test.js components/Button.test.jsx
- **优点**:清晰分离生产代码与测试代码,构建时可轻松排除测试。
- **适用场景**:大型项目或希望测试与源码物理分离的团队。
#### 1.3 命名约定
- 测试文件以 `.test.js` 或 `.spec.js` 结尾。
- 描述性名称:`userService.fetchUser.test.js` 优于 `user.test.js`。
- 对同一模块的多种测试场景,可使用 `.test` 复数或子目录。
#### 1.4 避免单个测试文件过大
当某文件的测试超过 200 行时,考虑按功能或行为拆分成多个测试文件。例如,对 `apiClient` 可按 HTTP 方法或业务域拆分:
api/ tests/ apiClient.get.test.js apiClient.post.test.js apiClient.auth.test.js
### 二、编写可读性高的测试描述
测试即文档,良好的命名让测试意图一目了然。
#### 2.1 使用 `describe` 进行分组
用嵌套 `describe` 表达测试的上下文层次。
```javascript
describe('UserService', () => {
describe('getUserById', () => {
describe('when user exists', () => {
it('should return the user object', () => { /* ... */ });
});
describe('when user does not exist', () => {
it('should throw NotFoundError', () => { /* ... */ });
});
});
});
2.2 it 语句的语义化描述
遵循 “should ... when ...” 模式,使描述具有行为驱动开发 (BDD) 风格。
- 正面:
'should return the formatted date when input is valid' - 负面:
'should throw an error when passing null' - 边缘:
'should return null when input is undefined'
避免技术实现细节,侧重行为。
2.3 避免测试代码中的逻辑
测试中不应出现条件语句、循环或复杂计算。每个测试只验证一种行为,保持代码线性、无分支。
// ❌ 不推荐
it('processes array', () => {
const results = process(input);
results.forEach((item, index) => {
if (index === 0) expect(item).toBe(1);
else expect(item).toBeGreaterThan(1);
});
});
// ✅ 推荐
it('should return 1 as the first element', () => {
const results = process(input);
expect(results[0]).toBe(1);
});
it('should return numbers greater than 1 for other elements', () => {
const results = process(input);
for (let i = 1; i < results.length; i++) {
expect(results[i]).toBeGreaterThan(1);
}
});
三、断言策略
选择正确的断言匹配器,让测试输出提供清晰的失败信息。
3.1 精确匹配 vs 灵活匹配
- 基本数据类型:使用
.toBe()进行严格相等。 - 对象/数组:使用
.toEqual()进行深度比较。 - 部分匹配:对对象子集使用
.toMatchObject()。 - 包含检查:对数组或字符串使用
.toContain()或.toContainEqual()。 - 异常:使用
.toThrow()并检查错误类或消息。
// 错误对象匹配
await expect(asyncFunction()).rejects.toThrow(ValidationError);
await expect(asyncFunction()).rejects.toThrow('Invalid email');
// 深层部分匹配
expect(response).toMatchObject({
status: 200,
data: { user: { id: expect.any(Number) } }
});
3.2 使用对称匹配器提高可读性
利用 Jest 内建的非对称匹配器 expect.any(constructor)、expect.stringContaining() 等,让测试意图更清晰。
expect(saveMock).toHaveBeenCalledWith({
id: expect.any(String),
createdAt: expect.any(Date),
name: 'Alice'
});
3.3 避免过度的快照测试
快照测试适合验证序列化输出不变的场景(如组件渲染结果、配置文件)。但对于复杂的动态数据,快照会变得庞大且难以审查。
- 使用小粒度快照,只针对确定性部分。
- 对业务逻辑用断言代替快照。
- 定期修剪和更新快照,确保文件包含有效快照。
四、模拟 (Mock) 最佳实践
模拟外部依赖是单元测试的核心。正确地使用 Mock 可以隔离被测单元并加速测试。
4.1 优先使用 jest.fn() 和 jest.spyOn()
jest.fn()创建独立 mock 函数。jest.spyOn(object, method)用于监视现有方法并可选地模拟实现。
const mockCallback = jest.fn();
someAsyncTask(mockCallback);
expect(mockCallback).toHaveBeenCalledWith('success');
4.2 每次测试后清理 Mock
在 afterEach 中重置 mock 状态,避免测试间相互影响。
afterEach(() => {
jest.clearAllMocks(); // 清空调用记录和实例
// 或 jest.resetAllMocks() 同时清除实现和返回值
// 或 jest.restoreAllMocks() 恢复原始实现(针对 spyOn)
});
4.3 避免全局 Mock 污染
尽量在 setupFiles 或单个测试套件中 Mock 模块,而不是在全项目范围 mock 核心库(如 fs、axios),除非有统一配置的需求。局部 mock 更易于理解和维护。
// 在测试文件顶部 mock 模块
jest.mock('../weatherService');
对于多次使用的 mock,可以抽成 __mocks__ 目录下的手动 mock 文件,便于共享。
4.4 模拟时保持实现的一致性
自定义 mock 实现应当遵循真实 API 的行为,否则测试可能给出虚假的信心。
// ✅ 返回合理的数据形状
jest.mock('../api', () => ({
fetchUser: jest.fn().mockResolvedValue({ id: 1, name: 'Test' })
}));
// ❌ 返回不符合约定的值
fetchUser.mockResolvedValue('not an object');
4.5 对时间依赖的函数使用假计时器
用 jest.useFakeTimers() 控制 setTimeout、Date 等,避免测试不稳定和真实等待。
beforeEach(() => {
jest.useFakeTimers();
jest.setSystemTime(new Date('2023-01-01'));
});
afterEach(() => {
jest.useRealTimers();
});
it('should expire token after 1 hour', () => {
const token = createToken();
jest.advanceTimersByTime(3600_000);
expect(isTokenValid(token)).toBe(false);
});
五、异步测试
正确处理异步操作是 Jest 测试中的常见陷阱。
5.1 使用 async/await 与 .resolves / .rejects
it('resolves with user data', async () => {
await expect(fetchUser(1)).resolves.toEqual({ id: 1, name: 'Alice' });
});
it('rejects with error message', async () => {
await expect(fetchUser(999)).rejects.toThrow('User not found');
});
5.2 确保断言被调用
忘记 await 或返回 Promise 会导致测试提前结束而漏报错误。
// ❌ 错误:测试立即通过,但实际断言可能未执行
it('does not work', () => {
fetchUser(1).then(data => {
expect(data.id).toBe(1);
});
});
// ✅ 正确:返回 promise
it('works', () => {
return fetchUser(1).then(data => {
expect(data.id).toBe(1);
});
});
5.3 测试事件循环中的微任务
jest.runAllTimers() 适用于宏任务(setTimeout),await 通常已覆盖微任务。对于复杂的 Promise 链,直接 await 目标函数即可。
六、测试覆盖率与质量
覆盖率是衡量测试完备性的一个指标,但不是唯一目标。
6.1 设定合理阈值
在 jest.config.js 中设置覆盖率阈值,但避免盲目追求 100%。
coverageThreshold: {
global: {
branches: 80,
functions: 80,
lines: 80,
statements: 80
}
}
6.2 关注核心逻辑的覆盖率
优先保证业务逻辑、数据转换、权限控制等的测试覆盖。对于一些纯样板代码(如接口定义、简单 getter/setter)可以适当放宽。
6.3 审查未覆盖的行
使用 jest --coverage 生成 HTML 报告,定期检查未覆盖的代码分支,判断是缺少测试还是死代码。
七、配置与性能优化
Jest 的默认配置已经非常友好,但大型项目中可以微调。
7.1 利用 --findRelatedTests
只运行与变更文件相关的测试,提升开发时反馈速度。
jest --findRelatedTests src/utils/formatDate.js
7.2 谨慎使用 --maxWorkers
在 CI 环境中,限制 worker 数量以避免内存过载。通常设为 2 或 50%。
jest --maxWorkers=2