Node.js 单元测试完全指南:Jest 实战
Jest 简介与安装
Jest 是 Facebook 开源的 JavaScript 测试框架,以零配置、运行速度快、内置 Mock 支持著称,是 Node.js 生态中最流行的测试工具。
npm install --save-dev jest
# 安装匹配器(更友好的断言错误提示)
npm install --save-dev @types/jest
配置 package.json:
{
"scripts": {
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage"
},
"jest": {
"testEnvironment": "node",
"coverageDirectory": "coverage",
"collectCoverageFrom": ["src/**/*.js", "!src/index.js"],
"testMatch": ["**/__tests__/**/*.js", "**/?(*.)+(spec|test).js"]
}
}
基础测试用例
// src/calculator.js
function add(a, b) {
return a + b;
}
function subtract(a, b) {
return a - b;
}
function divide(a, b) {
if (b === 0) throw new Error('除数不能为零');
return a / b;
}
function factorial(n) {
if (n < 0) throw new Error('负数没有阶乘');
if (n === 0 || n === 1) return 1;
return n * factorial(n - 1);
}
module.exports = { add, subtract, divide, factorial };
// __tests__/calculator.test.js
// 待测试模块
const { add, subtract, divide, factorial } = require('../src/calculator');
describe('计算器模块', () => {
// ========== 基础功能测试 ==========
describe('add()', () => {
test('两个正数相加', () => {
expect(add(2, 3)).toBe(5);
});
test('负数加正数', () => {
expect(add(-1, 1)).toBe(0);
});
test('浮点数相加(精度问题)', () => {
// 处理 JavaScript 浮点数精度问题
expect(add(0.1, 0.2)).toBeCloseTo(0.3, 5);
});
});
describe('divide()', () => {
test('正常除法', () => {
expect(divide(10, 2)).toBe(5);
});
test('除以零抛出错误', () => {
expect(() => divide(10, 0)).toThrow('除数不能为零');
});
test('零除以任何数', () => {
expect(divide(0, 5)).toBe(0);
});
});
describe('factorial()', () => {
test('0 的阶乘为 1', () => {
expect(factorial(0)).toBe(1);
});
test('5 的阶乘为 120', () => {
expect(factorial(5)).toBe(120);
});
test('负数抛出错误', () => {
expect(() => factorial(-1)).toThrow('负数没有阶乘');
});
});
});
Mock 技术
// src/userService.js
const User = require('../models/User');
const { hashPassword } = require('../utils/password');
const mailer = require('../utils/mailer'); // 发送邮件
class UserService {
async createUser({ email, name, password }) {
const existing = await User.findOne({ email });
if (existing) throw new Error('用户已存在');
const passwordHash = await hashPassword(password);
const user = await User.create({ email, name, passwordHash });
// 发送欢迎邮件
await mailer.sendWelcomeEmail(user.email, user.name);
return user;
}
async deleteUser(userId) {
return await User.findByIdAndDelete(userId);
}
}
module.exports = new UserService();
// __tests__/userService.test.js
const userService = require('../src/userService');
const User = require('../models/User');
const mailer = require('../utils/mailer');
// ====== Mock 配置 ======
// Mock User 模型
jest.mock('../models/User', () => ({
findOne: jest.fn(),
create: jest.fn(),
findByIdAndDelete: jest.fn()
}));
// Mock mailer(仅 mock sendWelcomeEmail,其他方法不变)
jest.mock('../utils/mailer', () => ({
sendWelcomeEmail: jest.fn().mockResolvedValue(undefined),
sendPasswordResetEmail: jest.fn().mockResolvedValue(undefined)
}));
describe('UserService', () => {
beforeEach(() => {
// 每个测试前清理所有 mock 调用记录
jest.clearAllMocks();
});
describe('createUser()', () => {
test('成功创建用户并发送欢迎邮件', async () => {
const userData = { email: 'test@example.com', name: 'Test', password: 'password123' };
const mockUser = { _id: 'user123', ...userData };
User.findOne.mockResolvedValue(null); // 用户不存在
User.create.mockResolvedValue(mockUser);
const result = await userService.createUser(userData);
expect(result).toEqual(mockUser);
expect(User.findOne).toHaveBeenCalledWith({ email: userData.email });
expect(User.create).toHaveBeenCalledWith({
email: userData.email,
name: userData.name,
passwordHash: expect.any(String)
});
expect(mailer.sendWelcomeEmail).toHaveBeenCalledWith(
userData.email,
userData.name
);
});
test('用户已存在时抛出错误', async () => {
User.findOne.mockResolvedValue({ email: 'existing@example.com' });
await expect(
userService.createUser({ email: 'existing@example.com', name: 'Test', password: 'pass' })
).rejects.toThrow('用户已存在');
// 确保没有创建用户
expect(User.create).not.toHaveBeenCalled();
expect(mailer.sendWelcomeEmail).not.toHaveBeenCalled();
});
});
describe('deleteUser()', () => {
test('成功删除用户', async () => {
const mockUser = { _id: 'user123', email: 'test@example.com' };
User.findByIdAndDelete.mockResolvedValue(mockUser);
const result = await userService.deleteUser('user123');
expect(result).toEqual(mockUser);
expect(User.findByIdAndDelete).toHaveBeenCalledWith('user123');
});
});
});
异步代码测试
// __tests__/async.test.js
const { promisify } = require('util');
const fs = require('fs');
const readFile = promisify(fs.readFile);
// ===== Promise 测试 =====
test('Promise 风格的异步测试', async () => {
const data = await readFile('./package.json', 'utf8');
const pkg = JSON.parse(data);
expect(pkg.name).toBeDefined();
});
// 或者用 .resolves / .rejects 匹配器
test('Promise 结果匹配', async () => {
await expect(Promise.resolve(42)).resolves.toBe(42);
await expect(Promise.reject(new Error('error'))).rejects.toThrow('error');
});
// ===== 错误处理测试 =====
async function fetchData(shouldFail = false) {
if (shouldFail) throw new Error('Network error');
return { id: 1, name: 'Alice' };
}
test('异步函数抛出错误', async () => {
await expect(fetchData(true)).rejects.toThrow('Network error');
});
test('异步函数正常返回', async () => {
const data = await fetchData(false);
expect(data.name).toBe('Alice');
});
覆盖率报告
# 运行测试并生成覆盖率报告
npm run test:coverage
# 覆盖率报告会自动输出:
# - 终端彩色表格(每个文件的行/分支/函数/语句覆盖率)
# - HTML 报告在 coverage/lcov-report/index.html
// 覆盖率阈值配置(package.json jest 配置)
{
"jest": {
"coverageThreshold": {
"global": {
"branches": 70,
"functions": 70,
"lines": 70,
"statements": 70
}
}
}
}
常见问题
Q1: <code>jest.clearAllMocks()</code> 和 <code>jest.resetAllMocks()</code> 有什么区别?
clearAllMocks() 只清除 mock 的调用记录和返回值,但保留 mock 的配置(mockImplementation 等);resetAllMocks() 更彻底,同时重置 mock 的实现,通常需要配合 restoreAllMocks() 恢复真实方法。一般在 beforeEach 中用 clearAllMocks() 即可。
Q2: 如何测试不导出内部的私有方法?
设计良好的单元测试只测试公开 API(模块的导出)。如果某个私有逻辑很复杂,可以将其提取到独立的模块/函数中单独测试;或者使用重构将逻辑外移。不要为了"测试覆盖率"而强行测试私有方法。
Q3: Jest 的 <code>--watch</code> 模式如何只运行相关测试?
运行 jest --watch 后,Jest 会监控 git 变化只运行变更文件的测试。按 w 可以看到更多选项:按文件名运行(p)、按测试名运行(t)、取消 watch 模式(q")。
延伸阅读
- <a href="https://jestjs.io/zh-Hans/">Jest 官方文档</a>
- <a href="https://jestjs.io/zh-Hans/docs/api">Jest API 参考</a>
- <a href="https://martinfowler.com/articles/practical-test-pyramid.html">测试金字塔:如何合理分配测试层次</a>