为什么前端需要认真对待测试?
在前端开发中,“写测试"常常被视为可选项——项目紧、需求多、上线急,测试总是被排到最后,甚至直接砍掉。但当项目规模增长、多人协作、频繁迭代时,缺少测试的代价会成倍放大:
- 重构恐惧症:不敢改代码,怕一改就崩
- 回归Bug频发:新功能上线带出旧Bug,手动测试覆盖不全
- 代码质量不可控:依赖开发者自觉,缺乏客观的质量保障
Vitest的出现,让前端测试变得前所未有的简单和高效。它基于Vite构建,启动速度极快,API与Jest高度兼容,是目前最值得投入学习的前端测试框架。
一、Vitest是什么?为什么选它?
Vitest是由Vite团队打造的测试框架,2022年发布后迅速成为前端测试领域的新标杆。相比Jest,它的优势在于:
| 特性 |
Vitest |
Jest |
| 启动速度 |
⚡ 极快(基于Vite) |
🐢 较慢(需独立编译) |
| 配置复杂度 |
低(自动继承Vite配置) |
高(需单独配置转换器) |
| ESM支持 |
原生支持 |
需要额外配置 |
| API兼容性 |
兼容Jest API |
— |
| Watch模式 |
智能(基于Vite的HMR) |
一般 |
| 快照测试 |
✅ 支持 |
✅ 支持 |
简单说:如果你的项目已经用Vite,Vitest就是零配置的测试方案;如果用其他构建工具,Vitest也能显著提升测试体验。
二、5分钟快速上手
1. 安装
1
2
3
4
5
6
7
8
|
# npm
npm install -D vitest
# yarn
yarn add -D vitest
# pnpm
pnpm add -D vitest
|
2. 配置package.json
1
2
3
4
5
6
7
|
{
"scripts": {
"test": "vitest",
"test:run": "vitest run",
"test:coverage": "vitest run --coverage"
}
}
|
3. 编写第一个测试
假设我们有一个工具函数 utils/math.ts:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
// utils/math.ts
export function add(a: number, b: number): number {
return a + b;
}
export function multiply(a: number, b: number): number {
return a * b;
}
export function divide(a: number, b: number): number {
if (b === 0) {
throw new Error('除数不能为零');
}
return a / b;
}
|
对应的测试文件 utils/math.test.ts:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
|
// utils/math.test.ts
import { describe, it, expect } from 'vitest';
import { add, multiply, divide } from './math';
describe('数学工具函数', () => {
describe('add', () => {
it('应该正确计算两个数的和', () => {
expect(add(1, 2)).toBe(3);
});
it('应该处理负数', () => {
expect(add(-1, -2)).toBe(-3);
});
it('应该处理小数', () => {
expect(add(0.1, 0.2)).toBeCloseTo(0.3);
});
});
describe('multiply', () => {
it('应该正确计算乘积', () => {
expect(multiply(3, 4)).toBe(12);
});
it('应该处理零', () => {
expect(multiply(5, 0)).toBe(0);
});
});
describe('divide', () => {
it('应该正确计算除法', () => {
expect(divide(10, 2)).toBe(5);
});
it('除数为零时应该抛出错误', () => {
expect(() => divide(10, 0)).toThrow('除数不能为零');
});
});
});
|
4. 运行测试
1
2
3
4
5
6
7
8
|
# 开发模式(监听文件变化)
npm test
# 单次运行
npm run test:run
# 生成覆盖率报告
npm run test:coverage
|
三、进阶用法
1. Mock函数与模块
在实际项目中,测试经常需要模拟外部依赖。Vitest提供了强大的Mock能力:
1
2
3
4
5
6
7
8
9
10
|
// api/userService.ts
import { db } from './database';
export async function getUserById(id: string) {
const user = await db.query('SELECT * FROM users WHERE id = ?', [id]);
if (!user) {
throw new Error('用户不存在');
}
return user;
}
|
测试时Mock数据库:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
|
// api/userService.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { getUserById } from './userService';
import { db } from './database';
// Mock数据库模块
vi.mock('./database', () => ({
db: {
query: vi.fn(),
},
}));
describe('getUserById', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('应该返回存在的用户', async () => {
const mockUser = { id: '1', name: '张三', email: '[email protected]' };
vi.mocked(db.query).mockResolvedValue(mockUser);
const user = await getUserById('1');
expect(user).toEqual(mockUser);
expect(db.query).toHaveBeenCalledWith(
'SELECT * FROM users WHERE id = ?',
['1']
);
});
it('用户不存在时应该抛出错误', async () => {
vi.mocked(db.query).mockResolvedValue(null);
await expect(getUserById('999')).rejects.toThrow('用户不存在');
});
});
|
2. 异步测试
现代前端代码大量使用async/await,Vitest原生支持:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
|
// api/fetchData.test.ts
import { describe, it, expect, vi } from 'vitest';
import { fetchUserPosts } from './fetchData';
// Mock全局fetch
vi.stubGlobal('fetch', vi.fn());
describe('fetchUserPosts', () => {
it('应该成功获取用户文章列表', async () => {
const mockPosts = [
{ id: 1, title: 'Vitest入门', content: '...' },
{ id: 2, title: '前端测试最佳实践', content: '...' },
];
vi.mocked(fetch).mockResolvedValue({
ok: true,
json: async () => mockPosts,
} as Response);
const posts = await fetchUserPosts('user-1');
expect(posts).toHaveLength(2);
expect(posts[0].title).toBe('Vitest入门');
expect(fetch).toHaveBeenCalledWith('/api/users/user-1/posts');
});
it('网络请求失败时应该抛出错误', async () => {
vi.mocked(fetch).mockResolvedValue({
ok: false,
status: 500,
statusText: 'Internal Server Error',
} as Response);
await expect(fetchUserPosts('user-1')).rejects.toThrow('请求失败');
});
});
|
3. 快照测试
快照测试非常适合UI组件和配置输出的验证:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
|
// components/UserCard.test.tsx
import { describe, it, expect } from 'vitest';
import { render } from '@testing-library/react';
import { UserCard } from './UserCard';
describe('UserCard组件', () => {
it('应该正确渲染用户信息', () => {
const { container } = render(
<UserCard
name="张三"
avatar="/avatars/zhangsan.jpg"
role="前端工程师"
/>
);
expect(container).toMatchSnapshot();
});
it('应该显示VIP标识', () => {
const { getByText } = render(
<UserCard
name="李四"
avatar="/avatars/lisi.jpg"
role="后端工程师"
isVip={true}
/>
);
expect(getByText('VIP')).toBeInTheDocument();
});
});
|
4. 测试覆盖率
Vitest内置了覆盖率支持,基于c8/v8:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
|
// vitest.config.ts
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
exclude: [
'node_modules/',
'src/types/',
'**/*.d.ts',
],
thresholds: {
statements: 80,
branches: 75,
functions: 80,
lines: 80,
},
},
},
});
|
运行覆盖率报告:
1
|
npx vitest run --coverage
|
输出示例:
1
2
3
4
5
6
7
8
9
|
% Coverage report from v8
-------------------------|---------|----------|---------|---------|
File | % Stmts | % Branch | % Funcs | % Lines |
-------------------------|---------|----------|---------|---------|
All files | 85.23 | 78.45 | 82.10 | 85.23 |
src/utils/math.ts | 100.00 | 100.00 | 100.00 | 100.00 |
src/api/userService.ts | 90.00 | 80.00 | 90.00 | 90.00 |
src/api/fetchData.ts | 75.00 | 60.00 | 70.00 | 75.00 |
-------------------------|---------|----------|---------|---------|
|
四、从Jest迁移到Vitest
如果你的项目已经在用Jest,迁移成本很低——Vitest的API设计几乎完全兼容Jest。
迁移步骤
1. 安装Vitest
2. 修改导入
1
2
3
4
5
|
// 旧代码(Jest)
import { describe, it, expect, vi, beforeEach } from '@jest/globals';
// 新代码(Vitest)
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
实际上,Vitest会自动注入这些函数,你甚至可以省略import语句:
1
2
3
4
5
6
|
// Vitest中可以直接使用(无需导入)
describe('测试套件', () => {
it('测试用例', () => {
expect(1 + 1).toBe(2);
});
});
|
3. 替换配置文件
1
2
3
4
|
# 删除jest.config.js/ts
rm jest.config.js
# 创建vitest.config.ts
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
// vitest.config.ts
import { defineConfig } from 'vitest/config';
import vue from '@vitejs/plugin-vue'; // 或其他框架插件
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [
vue(), // 或 react()
],
test: {
globals: true, // 全局注入describe/it/expect
environment: 'jsdom', // 浏览器环境模拟
setupFiles: './tests/setup.ts', // 测试初始化文件
},
});
|
4. 更新package.json脚本
1
2
3
4
5
6
7
|
{
"scripts": {
"test": "vitest",
"test:run": "vitest run",
"test:ui": "vitest --ui"
}
}
|
五、最佳实践
1. 测试文件命名规范
1
2
3
4
5
6
7
8
9
10
|
src/
├── utils/
│ ├── math.ts
│ └── math.test.ts # 与源文件同目录,同名.test.ts
├── components/
│ ├── UserCard.tsx
│ └── UserCard.test.tsx
├── api/
│ ├── userService.ts
│ └── userService.test.ts
|
2. 测试结构:AAA模式
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
|
it('应该正确处理用户登录', async () => {
// Arrange(准备)
const credentials = { email: '[email protected]', password: '123456' };
const mockResponse = { token: 'abc123', user: { id: '1' } };
vi.mocked(fetch).mockResolvedValue({
ok: true,
json: async () => mockResponse,
} as Response);
// Act(执行)
const result = await login(credentials);
// Assert(断言)
expect(result.token).toBe('abc123');
expect(result.user.id).toBe('1');
expect(localStorage.setItem).toHaveBeenCalledWith('token', 'abc123');
});
|
3. 避免测试实现细节
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
// ❌ 不好的测试:测试实现细节
it('应该调用useState', () => {
const spy = vi.spyOn(React, 'useState');
render(<Counter />);
expect(spy).toHaveBeenCalled();
});
// ✅ 好的测试:测试行为和结果
it('应该正确显示计数器', () => {
const { getByText } = render(<Counter />);
expect(getByText('0')).toBeInTheDocument();
fireEvent.click(getByText('增加'));
expect(getByText('1')).toBeInTheDocument();
});
|
六、Vitest + React/Vue组件测试示例
React组件测试
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
|
// components/Counter.test.tsx
import { describe, it, expect } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { Counter } from './Counter';
describe('Counter组件', () => {
it('应该显示初始计数值', () => {
render(<Counter initialValue={0} />);
expect(screen.getByText('0')).toBeInTheDocument();
});
it('点击增加按钮应该递增计数', async () => {
render(<Counter initialValue={0} />);
fireEvent.click(screen.getByText('增加'));
expect(screen.getByText('1')).toBeInTheDocument();
fireEvent.click(screen.getByText('增加'));
expect(screen.getByText('2')).toBeInTheDocument();
});
it('点击减少按钮应该递减计数', () => {
render(<Counter initialValue={5} />);
fireEvent.click(screen.getByText('减少'));
expect(screen.getByText('4')).toBeInTheDocument();
});
});
|
Vue组件测试
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
|
// components/TodoList.test.ts
import { describe, it, expect } from 'vitest';
import { mount } from '@vue/test-utils';
import TodoList from './TodoList.vue';
describe('TodoList组件', () => {
it('应该渲染待办事项列表', () => {
const todos = [
{ id: 1, text: '学习Vitest', done: false },
{ id: 2, text: '编写测试', done: true },
];
const wrapper = mount(TodoList, {
props: { todos },
});
expect(wrapper.findAll('.todo-item')).toHaveLength(2);
expect(wrapper.text()).toContain('学习Vitest');
expect(wrapper.text()).toContain('编写测试');
});
it('点击复选框应该切换完成状态', async () => {
const todos = [
{ id: 1, text: '学习Vitest', done: false },
];
const wrapper = mount(TodoList, {
props: { todos },
});
await wrapper.find('.todo-checkbox').trigger('click');
expect(wrapper.emitted('toggle')).toHaveLength(1);
expect(wrapper.emitted('toggle')[0]).toEqual([1]);
});
});
|
总结
Vitest是目前前端测试的最佳选择之一,它的优势可以总结为:
- 极速启动:基于Vite,测试启动时间以毫秒计
- 零配置:自动继承Vite配置,无需额外设置转换器
- 现代特性:原生支持ESM、TypeScript、JSX
- 生态兼容:与Jest API兼容,迁移成本极低
- 功能完善:Mock、快照、覆盖率、UI模式一应俱全
测试不是负担,而是投资。 花时间写好测试,未来的你会感谢现在的自己。从今天开始,给你的项目加上Vitest吧。
参考资料