Contents

Vitest实战:从零搭建现代前端测试体系的完整指南

为什么前端需要认真对待测试?

在前端开发中,“写测试"常常被视为可选项——项目紧、需求多、上线急,测试总是被排到最后,甚至直接砍掉。但当项目规模增长、多人协作、频繁迭代时,缺少测试的代价会成倍放大:

  • 重构恐惧症:不敢改代码,怕一改就崩
  • 回归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

1
npm install -D 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是目前前端测试的最佳选择之一,它的优势可以总结为:

  1. 极速启动:基于Vite,测试启动时间以毫秒计
  2. 零配置:自动继承Vite配置,无需额外设置转换器
  3. 现代特性:原生支持ESM、TypeScript、JSX
  4. 生态兼容:与Jest API兼容,迁移成本极低
  5. 功能完善:Mock、快照、覆盖率、UI模式一应俱全

测试不是负担,而是投资。 花时间写好测试,未来的你会感谢现在的自己。从今天开始,给你的项目加上Vitest吧。


参考资料