单元测试
约 2623 字大约 9 分钟
测试单元测试JestVitest
2026-04-16
单元测试简介
单元测试(Unit Testing)是针对程序最小可测试单元进行验证的测试方法。在前端领域,这个最小单元通常是函数或组件。
为什么要写单元测试
- 快速发现 bug:单元测试能在开发早期发现问题,降低修复成本
- 重构信心:有测试覆盖的代码可以安全重构,不用担心破坏现有功能
- 文档作用:测试代码本身就是最好的 API 文档,展示函数的使用方式
- 促进设计:为了便于测试,代码通常会设计得更加模块化、低耦合
// 未测试的代码:难以信任
function formatUserName(user: User): string {
return `${user.lastName}${user.firstName}`;
}
// 有测试的代码:可信赖、可文档化
describe('formatUserName', () => {
it('应该返回正确的姓名格式', () => {
const user = { lastName: '张', firstName: '三' };
expect(formatUserName(user)).toBe('张三');
});
it('应该处理空名字的情况', () => {
const user = { lastName: '', firstName: '' };
expect(formatUserName(user)).toBe('无名');
});
});好的单元测试的特性(A-TRIP)
Automatic(自动化)
测试应该能够自动运行,无需人工干预。通过命令行或 CI/CD 流水线自动执行。
# 自动运行所有测试
npm test
# 监听模式,文件变化时自动重新运行
npm test -- --watchThorough(全面)
测试应该覆盖各种场景,包括:
- 正常路径(Happy Path)
- 边界条件(Boundary Conditions)
- 异常情况(Error Cases)
describe('Array.prototype.indexOf', () => {
it('应该返回目标元素的索引', () => {
expect([1, 2, 3].indexOf(2)).toBe(1);
});
it('应该处理不存在的情况', () => {
expect([1, 2, 3].indexOf(4)).toBe(-1);
});
it('应该处理空数组', () => {
expect([].indexOf(1)).toBe(-1);
});
it('应该处理负数索引', () => {
expect([1, 2, 3].indexOf(1, -1)).toBe(-1);
});
});Repeatable(可重复)
每次运行测试都应该得到相同的结果,不受外部环境影响。
// 不好:依赖当前时间,每次运行结果不同
it('应该显示当前年份', () => {
expect(getYear()).toBe(2026); // 明年就失败了
});
// 好:使用固定的时间模拟
it('应该显示固定的年份', () => {
jest.useFakeTimers();
jest.setSystemTime(new Date('2026-04-16'));
expect(getYear()).toBe(2026);
jest.useRealTimers();
});Independent(独立)
每个测试应该独立运行,不依赖其他测试的状态。
// 不好:测试之间共享状态
let count = 0;
it('第一次测试增加计数', () => {
count++;
expect(count).toBe(1);
});
it('第二次测试期望计数为1', () => {
// 如果上一个测试失败,这个测试也会受影响
expect(count).toBe(1);
});
// 好:每个测试都是独立的
describe('increment', () => {
it('应该将计数增加到1', () => {
let count = 0;
increment();
expect(count).toBe(1);
});
it('每次调用应该增加1', () => {
let count = 0;
increment();
increment();
expect(count).toBe(2);
});
});Professional(专业)
测试代码也应该像生产代码一样保持高质量:
- 清晰的测试描述
- 合理的结构组织
- 避免重复代码
// 使用 describe 组织和命名
describe('UserService', () => {
describe('createUser', () => {
it('应该创建有效的用户', () => { /* ... */ });
it('应该拒绝无效的邮箱格式', () => { /* ... */ });
it('应该拒绝过短的用户名', () => { /* ... */ });
});
describe('deleteUser', () => {
it('应该成功删除存在的用户', () => { /* ... */ });
it('应该拒绝删除不存在的用户', () => { /* ... */ });
});
});Jest / Vitest 入门
Jest 和 Vitest 是最流行的 JavaScript 测试框架,两者 API 高度兼容。
基本结构
// sum.ts
export const sum = (a: number, b: number): number => a + b;
// sum.test.ts
import { sum } from './sum';
describe('sum', () => {
it('应该返回两个数的和', () => {
expect(sum(1, 2)).toBe(3);
});
test('负数相加', () => {
expect(sum(-1, -1)).toBe(-2);
});
});常用匹配器(expect)
// 相等
expect(value).toBe(5); // 原始值比较(===)
expect(value).toEqual({ a: 1 }); // 深比较(递归检查对象/数组)
expect(value).toStrictEqual({ a: 1 }); // 严格相等(undefined 敏感)
// 真假
expect(value).toBeTruthy(); // 真值(!== 0, !== '', !== null, !== undefined)
expect(value).toBeFalsy(); // 假值(0, '', null, undefined, false)
expect(value).toBeNull(); // null
expect(value).toBeUndefined(); // undefined
expect(value).toBeDefined(); // 已定义(非 undefined)
// 数字
expect(value).toBeGreaterThan(3);
expect(value).toBeGreaterThanOrEqual(3);
expect(value).toBeLessThan(10);
expect(value).toBeLessThanOrEqual(10);
expect(value).toBeCloseTo(0.3); // 浮点数比较
// 字符串
expect(str).toMatch(/pattern/);
expect(str).toContain('子串');
// 数组
expect(arr).toContain(item);
expect(arr).toHaveLength(3);
expect(arr).toEqual(expect.arrayContaining([1, 2]));
// 对象
expect(obj).toHaveProperty('name');
expect(obj).toMatchObject({ name: '张三' });
// 异常
expect(() => {
throw new Error('出错了');
}).toThrow();
expect(() => {
throw new Error('出错了');
}).toThrow('出错了');
// 否定
expect(value).not.toBe(5);
expect(arr).not.toContain(4);生命周期钩子
describe('UserService', () => {
let userService: UserService;
// 在所有测试之前执行一次
beforeAll(() => {
userService = new UserService();
userService.connect();
});
// 在所有测试之后执行一次
afterAll(() => {
userService.disconnect();
});
// 在每个测试之前执行
beforeEach(() => {
userService.clearUsers();
});
// 在每个测试之后执行
afterEach(() => {
jest.clearAllMocks();
});
it('应该创建用户', async () => {
await userService.createUser({ name: '张三' });
const users = await userService.getUsers();
expect(users).toHaveLength(1);
});
it('应该删除用户', async () => {
await userService.deleteUser(1);
const users = await userService.getUsers();
expect(users).toHaveLength(0);
});
});异步测试
// Promise 方式
test('应该获取用户数据', () => {
return fetchUser(1).then(user => {
expect(user.name).toBe('张三');
});
});
// Async/Await 方式(推荐)
test('应该获取用户数据', async () => {
const user = await fetchUser(1);
expect(user.name).toBe('张三');
});
// 使用 resolves / rejects
test('应该成功获取用户', async () => {
await expect(fetchUser(1)).resolves.toEqual({ id: 1, name: '张三' });
});
test('应该拒绝无效请求', async () => {
await expect(fetchUser(-1)).rejects.toThrow('Invalid ID');
});
// 并行异步测试
test('应该并行获取多个用户', async () => {
const [user1, user2] = await Promise.all([
fetchUser(1),
fetchUser(2)
]);
expect(user1).toEqual({ id: 1, name: '张三' });
expect(user2).toEqual({ id: 2, name: '李四' });
});Mock 与 Stub
Mock(模拟)是单元测试中的核心概念,用于隔离被测试的代码。
jest.fn()
创建模拟函数,可以追踪调用情况和返回值。
// 基本用法
const mockFn = jest.fn();
mockFn('hello');
console.log(mockFn('hello')); // undefined(未配置返回值)
// 配置返回值
mockFn.mockReturnValue('result');
console.log(mockFn('hello')); // 'result'
// 使用 mockReturnValueOnce 实现多次调用不同返回值
const fetchUser = jest.fn()
.mockReturnValueOnce({ id: 1, name: '张三' })
.mockReturnValueOnce({ id: 2, name: '李四' })
.mockReturnValue({ id: 3, name: '王五' }); // 默认返回值
// 模拟异步函数
const fetchData = jest.fn().mockResolvedValue({ data: 'success' });
const fetchError = jest.fn().mockRejectedValue(new Error('Failed'));
// 检查调用情况
test('应该调用正确的参数', () => {
const callback = jest.fn();
processUser(1, callback);
expect(callback).toHaveBeenCalledTimes(1);
expect(callback).toHaveBeenCalledWith(1);
expect(callback).toHaveBeenCalledWith(expect.any(Number));
});jest.spyOn()
监视对象上的方法调用,同时保留原实现。
const obj = {
getName: () => '张三',
};
// 监视方法
const spy = jest.spyOn(obj, 'getName');
obj.getName();
obj.getName();
expect(spy).toHaveBeenCalledTimes(2);
// 临时替换实现
jest.spyOn(obj, 'getName').mockReturnValue('李四');
expect(obj.getName()).toBe('李四');
// 恢复原实现
spy.mockRestore();
expect(obj.getName()).toBe('张三');jest.mock()
模拟整个模块。
// jest.mock() 会自动提升到文件顶部
jest.mock('./api', () => ({
getUser: jest.fn().mockResolvedValue({ id: 1, name: '张三' }),
createUser: jest.fn().mockResolvedValue({ id: 2, name: '李四' }),
}));
import { getUser, createUser } from './api';
test('应该获取用户', async () => {
const user = await getUser(1);
expect(user).toEqual({ id: 1, name: '张三' });
});
test('应该创建用户', async () => {
const user = await createUser({ name: '李四' });
expect(user).toEqual({ id: 2, name: '李四' });
});使用 mockImplementation 灵活模拟
const mockFn = jest.fn();
// 完全自定义实现
mockFn.mockImplementation((a: number, b: number) => {
return a + b;
});
expect(mockFn(1, 2)).toBe(3);
// 模拟类方法
class Calculator {
add(a: number, b: number): number {
return a + b;
}
}
const calculator = new Calculator();
jest.spyOn(calculator, 'add').mockImplementation((a, b) => a * b);
expect(calculator.add(2, 3)).toBe(6);前端单元测试策略
组件测试(Vue Test Utils)
import { mount } from '@vue/test-utils';
import TodoList from './TodoList.vue';
import TodoItem from './TodoItem.vue';
describe('TodoList.vue', () => {
it('应该渲染待办项列表', () => {
const wrapper = mount(TodoList, {
props: {
todos: [
{ id: 1, text: '学习 Vue', done: false },
{ id: 2, text: '学习测试', done: true },
],
},
});
expect(wrapper.findAllComponents(TodoItem)).toHaveLength(2);
expect(wrapper.text()).toContain('学习 Vue');
expect(wrapper.text()).toContain('学习测试');
});
it('应该添加新待办项', async () => {
const wrapper = mount(TodoList);
await wrapper.find('input').setValue('新待办项');
await wrapper.find('button').trigger('click');
expect(wrapper.emitted('add-todo')).toBeTruthy();
expect(wrapper.emitted('add-todo')[0]).toEqual(['新待办项']);
});
it('应该切换待办项状态', async () => {
const wrapper = mount(TodoList, {
props: {
todos: [{ id: 1, text: '测试', done: false }],
},
});
await wrapper.find('input[type="checkbox"]').setChecked();
expect(wrapper.emitted('toggle')[0]).toEqual([1]);
});
});组件测试(React Testing Library)
import { render, screen, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import LoginForm from './LoginForm';
describe('LoginForm', () => {
it('应该渲染登录表单', () => {
render(<LoginForm onSubmit={jest.fn()} />);
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
expect(screen.getByLabelText(/password/i)).toBeInTheDocument();
expect(screen.getByRole('button', { name: /login/i })).toBeInTheDocument();
});
it('应该提交表单数据', async () => {
const onSubmit = jest.fn();
render(<LoginForm onSubmit={onSubmit} />);
await userEvent.type(screen.getByLabelText(/email/i), 'test@example.com');
await userEvent.type(screen.getByLabelText(/password/i), 'password123');
await userEvent.click(screen.getByRole('button', { name: /login/i }));
expect(onSubmit).toHaveBeenCalledWith({
email: 'test@example.com',
password: 'password123',
});
});
it('应该显示验证错误', async () => {
render(<LoginForm onSubmit={jest.fn()} />);
await userEvent.click(screen.getByRole('button', { name: /login/i }));
expect(screen.getByText(/email 是必填项/i)).toBeInTheDocument();
});
});工具函数测试
// utils/format.ts
export const formatDate = (date: Date, locale: string = 'zh-CN'): string => {
return new Intl.DateTimeFormat(locale).format(date);
};
export const debounce = <T extends (...args: any[]) => any>(
fn: T,
delay: number
): ((...args: Parameters<T>) => void) => {
let timeoutId: ReturnType<typeof setTimeout>;
return (...args) => {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => fn(...args), delay);
};
};
// utils/format.test.ts
import { formatDate, debounce } from './format';
describe('formatDate', () => {
it('应该格式化日期为中文', () => {
const date = new Date('2026-04-16');
expect(formatDate(date, 'zh-CN')).toBe('2026/4/16');
});
it('应该使用默认 locale', () => {
const date = new Date('2026-04-16');
expect(formatDate(date)).toBeTruthy();
});
});
describe('debounce', () => {
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
it('应该延迟执行函数', () => {
const fn = jest.fn();
const debouncedFn = debounce(fn, 1000);
debouncedFn();
expect(fn).not.toHaveBeenCalled();
jest.advanceTimersByTime(1000);
expect(fn).toHaveBeenCalledTimes(1);
});
it('应该只执行最后一次调用', () => {
const fn = jest.fn();
const debouncedFn = debounce(fn, 1000);
debouncedFn();
debouncedFn();
debouncedFn();
jest.advanceTimersByTime(1000);
expect(fn).toHaveBeenCalledTimes(1);
});
});快照测试
import { render } from '@vue/test-utils';
import { markRaw } from 'vue';
import UserCard from './UserCard.vue';
// Vue 组件快照测试
describe('UserCard.vue', () => {
it('应该渲染正确的内容', () => {
const wrapper = mount(UserCard, {
props: {
user: { name: '张三', email: 'zhangsan@example.com' },
},
});
expect(wrapper.html()).toMatchSnapshot();
});
});
// React 组件快照测试
import { render } from '@testing-library/react';
import Info from './Info';
describe('Info', () => {
it('应该渲染正确的 UI', () => {
const { container } = render(<Info title="标题" description="描述" />);
expect(container).toMatchSnapshot();
});
});测试覆盖率
测试覆盖率衡量测试对代码的覆盖程度。
生成覆盖率报告
# Jest
npm test -- --coverage
# Vitest
npx vitest run --coverage
# 配置coverage阈值
// vitest.config.ts
export default defineConfig({
test: {
coverage: {
provider: 'v8',
thresholds: {
lines: 80,
functions: 80,
branches: 70,
statements: 80,
},
},
},
});覆盖率指标
| 指标 | 含义 | 说明 |
|---|---|---|
| Statements | 语句覆盖率 | 每个语句是否被执行 |
| Branches | 分支覆盖率 | 每个条件分支是否被覆盖 |
| Functions | 函数覆盖率 | 每个函数是否被调用 |
| Lines | 行覆盖率 | 每行代码是否被执行 |
解读覆盖率报告
--------------------|---------|----------|---------|---------|
File | % Stmts | % Branch | % Funcs | % Lines |
--------------------|---------|----------|---------|---------|
src/utils/format | 100 | 100 | 100 | 100 |
src/components/ | 85.23 | 72.50 | 100.00 | 85.23 |
--------------------|---------|----------|---------|---------|
All files | 89.50 | 78.33 | 100.00 | 89.50 |覆盖率误区
- 高覆盖率不等于高质量测试:可能测试了代码但没有验证正确性
- 不要盲目追求 100%:聚焦于关键业务逻辑和边界情况
- 关注分支覆盖率:低分支覆盖率可能遗漏重要逻辑路径
// 覆盖率100%但逻辑错误的例子
const isAdult = (age: number): boolean => {
if (age >= 18) {
return true;
}
if (age < 18) { // 这个else其实没必要,但增加了覆盖率
return false;
}
return false; // 永远不会被执行
};
// 测试只验证了年龄>=18的情况,没有验证边界
it('应该返回true当年龄>=18', () => {
expect(isAdult(18)).toBe(true);
});