单元测试 (Jest/Vitest)
概述
单元测试是测试金字塔的基础,用于验证最小可测试单元(函数、模块)的正确性。
一、测试框架对比
| 特性 | Jest | Vitest |
|---|---|---|
| 运行速度 | 较慢 | 极快 (基于 Vite) |
| 配置复杂度 | 开箱即用 | 需 Vite 配置 |
| ESM 支持 | 需配置 | 原生支持 |
| 快照测试 | ✅ | ✅ |
| 并行执行 | ✅ | ✅ (更优) |
| Watch 模式 | ✅ | ✅ (HMR 级别) |
| 生态系统 | 成熟 | 快速发展 |
二、Jest 基础配置
安装
bash
npm install --save-dev jest @types/jest ts-jestjest.config.js
javascript
module.exports = {
// 测试环境
testEnvironment: 'node', // 或 'jsdom' 用于浏览器环境
// TypeScript 支持
preset: 'ts-jest',
// 测试文件匹配
testMatch: [
'**/__tests__/**/*.[jt]s?(x)',
'**/?(*.)+(spec|test).[jt]s?(x)'
],
// 忽略路径
testPathIgnorePatterns: ['/node_modules/', '/dist/'],
// 覆盖率配置
collectCoverageFrom: [
'src/**/*.{js,jsx,ts,tsx}',
'!src/**/*.d.ts'
],
coverageThreshold: {
global: {
branches: 80,
functions: 80,
lines: 80,
statements: 80
}
},
// 模块别名
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1'
},
// Setup 文件
setupFilesAfterEnv: ['<rootDir>/jest.setup.js']
};Vitest 配置
typescript
// vitest.config.ts
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'jsdom',
globals: true, // 全局 API (describe, it, expect)
setupFiles: ['./vitest.setup.ts'],
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
exclude: ['node_modules/', 'dist/']
},
// 并行与隔离
isolate: true,
pool: 'threads'
}
});三、Matcher 断言详解
基础匹配器
javascript
// 相等性
expect(2 + 2).toBe(4); // 严格相等 (===)
expect({ a: 1 }).toEqual({ a: 1 }); // 深度相等
expect({ a: 1, b: 2 }).toMatchObject({ a: 1 }); // 部分匹配
// 真值判断
expect(null).toBeNull();
expect(undefined).toBeUndefined();
expect(true).toBeTruthy();
expect(false).toBeFalsy();
expect(value).toBeDefined();
// 数字比较
expect(10).toBeGreaterThan(5);
expect(10).toBeGreaterThanOrEqual(10);
expect(10).toBeLessThan(20);
expect(0.1 + 0.2).toBeCloseTo(0.3); // 浮点数
// 字符串
expect('hello world').toMatch(/world/);
expect('hello').toContain('ell');
// 数组
expect([1, 2, 3]).toContain(2);
expect([{ a: 1 }, { b: 2 }]).toContainEqual({ a: 1 });
expect([1, 2, 3]).toHaveLength(3);异常匹配器
javascript
function throwError() {
throw new Error('Something went wrong');
}
expect(() => throwError()).toThrow();
expect(() => throwError()).toThrow(Error);
expect(() => throwError()).toThrow('Something went wrong');
expect(() => throwError()).toThrow(/wrong/);异步匹配器
javascript
// Promise
await expect(Promise.resolve('data')).resolves.toBe('data');
await expect(Promise.reject('error')).rejects.toBe('error');
// async/await
test('async test', async () => {
const data = await fetchData();
expect(data).toBe('expected');
});
// 回调 (使用 done)
test('callback test', (done) => {
fetchDataWithCallback((data) => {
expect(data).toBe('expected');
done();
});
});四、Mock 与 Spy
函数 Mock
javascript
// 创建 mock 函数
const mockFn = jest.fn();
mockFn('arg1', 'arg2');
// 验证调用
expect(mockFn).toHaveBeenCalled();
expect(mockFn).toHaveBeenCalledWith('arg1', 'arg2');
expect(mockFn).toHaveBeenCalledTimes(1);
// 返回值设置
const mockFn2 = jest.fn()
.mockReturnValue('default')
.mockReturnValueOnce('first call')
.mockReturnValueOnce('second call');
console.log(mockFn2()); // 'first call'
console.log(mockFn2()); // 'second call'
console.log(mockFn2()); // 'default'
// 异步返回
const asyncMock = jest.fn()
.mockResolvedValue('resolved')
.mockRejectedValueOnce(new Error('error'));
// 实现替换
const mockImpl = jest.fn((x) => x * 2);
expect(mockImpl(5)).toBe(10);模块 Mock
javascript
// 自动 mock 整个模块
jest.mock('./api');
import { fetchUser } from './api';
// 手动实现
jest.mock('./api', () => ({
fetchUser: jest.fn(() => Promise.resolve({ id: 1, name: 'Test' }))
}));
// 部分 mock
jest.mock('./utils', () => ({
...jest.requireActual('./utils'),
formatDate: jest.fn(() => '2024-01-01')
}));Spy
javascript
const obj = {
method: (x) => x + 1
};
// spyOn 保留原实现
const spy = jest.spyOn(obj, 'method');
obj.method(5); // 返回 6
expect(spy).toHaveBeenCalledWith(5);
// 替换实现
spy.mockImplementation((x) => x * 2);
obj.method(5); // 返回 10
// 恢复原实现
spy.mockRestore();Timer Mock
javascript
jest.useFakeTimers();
test('timer test', () => {
const callback = jest.fn();
setTimeout(callback, 1000);
// 快进时间
jest.advanceTimersByTime(1000);
expect(callback).toHaveBeenCalled();
});
// 运行所有 timer
jest.runAllTimers();
// 只运行待执行的 timer
jest.runOnlyPendingTimers();
// 恢复
jest.useRealTimers();五、测试组织
describe 与 it
javascript
describe('Calculator', () => {
let calculator;
// 每个测试前执行
beforeEach(() => {
calculator = new Calculator();
});
// 每个测试后执行
afterEach(() => {
calculator = null;
});
// 所有测试前执行一次
beforeAll(() => {
// 初始化数据库连接等
});
// 所有测试后执行一次
afterAll(() => {
// 清理
});
describe('add', () => {
it('should add two positive numbers', () => {
expect(calculator.add(1, 2)).toBe(3);
});
it('should handle negative numbers', () => {
expect(calculator.add(-1, -2)).toBe(-3);
});
});
// 跳过测试
describe.skip('subtract', () => {
// 这些测试会被跳过
});
// 只运行这个
describe.only('multiply', () => {
// 只运行这个 describe
});
});参数化测试
javascript
describe.each([
[1, 1, 2],
[1, 2, 3],
[2, 2, 4],
])('add(%i, %i)', (a, b, expected) => {
test(`returns ${expected}`, () => {
expect(add(a, b)).toBe(expected);
});
});
// 对象形式
test.each([
{ a: 1, b: 1, expected: 2 },
{ a: 1, b: 2, expected: 3 },
])('add($a, $b) = $expected', ({ a, b, expected }) => {
expect(add(a, b)).toBe(expected);
});六、快照测试
javascript
// 基础快照
test('snapshot', () => {
const tree = renderer.create(<Button>Click</Button>).toJSON();
expect(tree).toMatchSnapshot();
});
// 内联快照
test('inline snapshot', () => {
expect({ name: 'test' }).toMatchInlineSnapshot(`
{
"name": "test",
}
`);
});
// 更新快照
// jest --updateSnapshot 或 jest -u七、覆盖率报告
bash
# 生成覆盖率报告
jest --coverage
# 输出格式
# - text: 终端输出
# - html: 可视化 HTML 报告
# - lcov: CI 工具使用
# - json: 程序处理覆盖率指标
| 指标 | 含义 |
|---|---|
| Statements | 语句覆盖率 |
| Branches | 分支覆盖率 (if/else, switch) |
| Functions | 函数覆盖率 |
| Lines | 行覆盖率 |
八、最佳实践
1. AAA 模式
javascript
test('should calculate total price', () => {
// Arrange (准备)
const cart = new Cart();
cart.addItem({ price: 10, quantity: 2 });
cart.addItem({ price: 5, quantity: 1 });
// Act (执行)
const total = cart.getTotal();
// Assert (断言)
expect(total).toBe(25);
});2. 测试命名
javascript
// ❌ 不好
test('test1', () => {});
// ✅ 好 - 描述行为
test('should return user when id exists', () => {});
test('should throw error when id is invalid', () => {});
// ✅ Given-When-Then 格式
test('given valid credentials, when login, then return token', () => {});3. 单一职责
javascript
// ❌ 测试多个功能
test('user operations', () => {
// 测试创建
// 测试更新
// 测试删除
});
// ✅ 每个测试一个断言焦点
test('should create user', () => {});
test('should update user', () => {});
test('should delete user', () => {});面试高频题
Q1: Jest 和 Vitest 如何选择?
答:
- 新项目使用 Vite → 选 Vitest
- 现有 Jest 项目 → 继续用 Jest
- 需要极快的测试速度 → Vitest
- 需要成熟的生态 → Jest
Q2: Mock 和 Spy 的区别?
答:
- Mock: 完全替换函数实现
- Spy: 包装原函数,可以追踪调用同时保留原实现
Q3: 如何测试私有方法?
答:
- 不直接测试私有方法,通过公共接口间接测试
- 如果必须测试,可以通过模块 export 暴露(仅测试环境)
- 使用
rewire等工具访问
Q4: 如何提高测试速度?
答:
- 并行执行 (
--runInBand关闭并行用于调试) - 使用
--onlyChanged只测试变更文件 - 合理使用
beforeAll减少重复初始化 - Mock 外部依赖减少 I/O
- 使用 Vitest 替代 Jest