Jest 实战:JavaScript/TypeScript 单元测试与 TDD
Jest 实战:JavaScript/TypeScript 单元测试与 TDD
为什么需要单元测试
单元测试能保障代码质量,减少回归缺陷,让重构更有信心。Jest 是 Facebook 开源的测试框架,零配置、内置断言库、支持快照和模拟,是目前最流行的 JavaScript 测试工具。结合测试驱动开发(TDD),可在编写实现代码前先定义预期行为,强制思考设计,产出高内聚、低耦合的模块。
环境准备与安装
确保本地已安装 Node.js(推荐 v16+),然后通过 npm 或 yarn 初始化项目:
mkdir jest-tdd-demo && cd jest-tdd-demo
npm init -y
npm install --save-dev jest
如果使用 TypeScript,额外安装类型声明和 ts-jest:
npm install --save-dev typescript @types/jest ts-jest
在项目根目录创建 jest.config.js,对于 TypeScript 项目配置预设:
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
};
在 package.json 中添加测试脚本:
"scripts": {
"test": "jest"
}
运行 npm test,Jest 会自动寻找 __tests__ 目录或文件名包含 .test.js / .spec.js 的文件。
编写第一个测试
创建 math.js,实现待测的加法函数:
function add(a, b) {
return a + b;
}
module.exports = { add };
同级目录新建 math.test.js:
const { add } = require('./math');
test('adds 1 + 2 to equal 3', () => {
expect(add(1, 2)).toBe(3);
});
test(name, fn) 定义一个测试用例,expect(value) 配合匹配器 toBe 断言结果。执行 npm test,Jest 输出绿色对勾表示通过。
常用匹配器
Jest 提供丰富的匹配器,覆盖大部分断言场景:
toBe:严格相等(===),适用于基本类型。toEqual:递归检查对象或数组的每个字段。not:取反,例如expect(result).not.toBeNull()。toBeNull/toBeUndefined/toBeDefined:判断 null 或 undefined。toBeTruthy/toBeFalsy:检查布尔上下文转换。toContain:数组或可迭代对象是否包含某个元素。toMatch:字符串匹配正则。toThrow:检查函数是否抛出错误。
示例:
test('object assignment', () => {
const data = { one: 1 };
data['two'] = 2;
expect(data).toEqual({ one: 1, two: 2 });
});
test('null values', () => {
const n = null;
expect(n).toBeNull();
expect(n).toBeDefined();
expect(n).not.toBeUndefined();
expect(n).toBeFalsy();
});
测试异步代码
Jest 原生支持 Promise 和 async/await。确保在测试中返回 Promise,或使用 async/await,否则 Jest 会过早结束测试。
function fetchData() {
return new Promise((resolve) => {
setTimeout(() => resolve('peanut butter'), 100);
});
}
test('the data is peanut butter', () => {
return fetchData().then((data) => {
expect(data).toBe('peanut butter');
});
});
test('using async/await', async () => {
const data = await fetchData();
expect(data).toBe('peanut butter');
});
test('the fetch fails with an error', async () => {
expect.assertions(1); // 确保至少执行一次断言
try {
await fetchDataThatRejects();
} catch (error) {
expect(error.message).toMatch('error');
}
});
对于回调风格的异步 API,可使用 done 回调参数,但更推荐使用 Promise 包装。
Mock 函数与模块
模拟(Mock)函数用于隔离被测单元,观察调用情况、参数和返回值,也可替换模块实现。
手动模拟函数
test('mock function called', () => {
const mockCallback = jest.fn((x) => 42 + x);
[0, 1].forEach(mockCallback);
expect(mockCallback.mock.calls.length).toBe(2);
expect(mockCallback.mock.calls[0][0]).toBe(0);
expect(mockCallback.mock.results[0].value).toBe(42);
});
jest.fn() 创建模拟函数,通过 .mock 属性检查调用记录。
模块模拟
假设 api.js 依赖网络请求,测试时不希望真实调用。在测试文件内使用 jest.mock() 自动模拟整个模块。
const axios = require('axios');
jest.mock('axios');
test('should fetch users', async () => {
const users = [{ name: 'Bob' }];
axios.get.mockResolvedValue({ data: users });
const result = await fetchUsers();
expect(result).toEqual(users);
expect(axios.get).toHaveBeenCalledWith('/users');
});
Jest 会将所有导出替换为模拟实现,并可控制返回值。
局部模拟与间谍
使用 jest.spyOn(object, methodName) 保留原始实现但监视调用,可用于部分模拟。
快照测试
快照测试捕获组件或数据结构的渲染输出,确保 UI 不会意外改变。多用于 React 组件或可序列化的对象。
test('renders correctly', () => {
const tree = renderer.create(<MyComponent />).toJSON();
expect(tree).toMatchSnapshot();
});
首次运行生成快照文件,后续与当前输出对比,不一致则提示更新快照(npm test -- -u)。快照应作为代码的一部分提交版本控制。
测试驱动开发(TDD)实践
TDD 遵循 Red-Green-Refactor 循环:
- Red:写一个会失败的测试。
- Green:编写最少代码让测试通过。
- Refactor:改进代码结构且保持测试通过。
这种节奏强制先思考接口设计,再实现逻辑,确保每一行代码都有对应的测试。
实战:开发简易字符串工具库
目标:实现一个 stringUtils 模块,包含 capitalize 和 reverse 函数。全程采用 TDD。
1. 编写第一个测试 (Red)
创建 stringUtils.test.js:
const { capitalize } = require('./stringUtils');
test('capitalize makes first letter uppercase', () => {
expect(capitalize('hello')).toBe('Hello');
});
运行 npm test,测试失败(模块不存在)。
2. 让测试通过 (Green)
创建 stringUtils.js:
function capitalize(str) {
if (!str) return '';
return str.charAt(0).toUpperCase() + str.slice(1);
}
module.exports = { capitalize };
测试通过。
3. 重构 目前代码简洁,暂不需要重构。但可再写一个测试覆盖空字符串等边界情况,然后优化实现。
4. 重复 TDD 追加新功能
编写 reverse 的测试(Red):
test('reverse reverses a string', () => {
expect(reverse('abc')).toBe('cba');
});
扩大模块导出,添加空实现,通过测试后完善代码,再重构。
测试覆盖率与最佳实践
- 使用
jest --coverage生成覆盖率报告,关注分支和语句覆盖率。 - 测试描述应采用“应该...”句式,提高可读性。
- 使用
beforeEach/afterEach设置和清理测试环境,避免测试间共享状态。 - 避免测试实现细节(如内部私有方法),应测试公有接口的行为。
- 复杂逻辑拆分为多个小测试,每个测试只验证一个行为。
- 将模拟限制在最小范围,避免过度模拟导致测试失去价值。
集成到开发流程
- 在 CI/CD 环境运行
npm test,设置覆盖率阈值做质量门禁。 - 搭配 Git 预提交钩子(如 husky)自动运行相关测试。
- 定期审查快照,确保更新是预期变更。
Jest + TDD 不仅提升代码健壮性,更重塑开发思维,让测试成为设计的助手,而非事后的负担。