第 133 章:Vitest 单元测试
学习目标
- 掌握 Vitest 在 Vue 3 项目中的配置
- 学会组件测试、composable 测试、store 测试
- 理解 mock、spy、覆盖率统计
- 在 CI 中集成测试
一、为什么需要测试
二、Vitest 是什么
- 基于 Vite 的测试框架
- 兼容 Jest API
- 极快的启动与运行
- 原生支持 ESM / TypeScript / Vue
三、安装与配置
3.1 安装
bash
pnpm add -D vitest @vue/test-utils happy-dom @vitest/coverage-v83.2 配置
typescript
// vitest.config.ts
import { defineConfig } from 'vitest/config';
import vue from '@vitejs/plugin-vue';
import path from 'path';
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': path.resolve(__dirname, 'src')
}
},
test: {
environment: 'happy-dom',
globals: true,
setupFiles: ['./test/setup.ts'],
coverage: {
provider: 'v8',
reporter: ['text', 'html', 'json'],
include: ['src/**/*.{ts,vue}'],
exclude: ['**/*.test.ts', '**/*.spec.ts', 'src/main.ts']
}
}
});3.3 global 类型
typescript
// tsconfig.json
{
"compilerOptions": {
"types": ["vitest/globals"]
}
}3.4 包脚本
json
{
"scripts": {
"test": "vitest",
"test:run": "vitest run",
"test:watch": "vitest --watch",
"test:coverage": "vitest run --coverage",
"test:ui": "vitest --ui"
}
}四、基础语法
4.1 测试结构
typescript
import { describe, it, expect } from 'vitest';
describe('分组', () => {
it('测试 1', () => {
expect(1 + 1).toBe(2);
});
it('测试 2', () => {
expect('hello').toMatch(/hello/);
});
});4.2 常用断言
typescript
// 基础
expect(1).toBe(1); // ===
expect({ a: 1 }).toEqual({ a: 1 }); // 深度比较
expect('hello').toMatch(/hello/);
expect([1, 2]).toContain(1);
// 布尔
expect(true).toBeTruthy();
expect(false).toBeFalsy();
expect(null).toBeNull();
expect(undefined).toBeUndefined();
// 数字
expect(0.1 + 0.2).toBeCloseTo(0.3);
expect(10).toBeGreaterThan(5);
// 异常
expect(() => { throw new Error('x') }).toThrow('x');
// Promise
await expect(Promise.resolve(1)).resolves.toBe(1);
await expect(Promise.reject('x')).rejects.toBe('x');4.3 钩子
typescript
describe('hooks', () => {
beforeAll(() => { /* 所有用例前 */ });
beforeEach(() => { /* 每个用例前 */ });
afterEach(() => { /* 每个用例后 */ });
afterAll(() => { /* 所有用例后 */ });
it('test', () => {});
});4.4 跳过与仅运行
typescript
describe('skip', () => {
it.skip('跳过', () => {});
it.todo('待完成');
it.runIf(true)('条件运行', () => {});
it.concurrent('并发', () => {});
});五、组件测试
5.1 基础
typescript
import { mount } from '@vue/test-utils';
import { describe, it, expect } from 'vitest';
import MyButton from './MyButton.vue';
describe('MyButton', () => {
it('renders', () => {
const wrapper = mount(MyButton, {
props: { type: 'primary' },
slots: { default: 'Click' }
});
expect(wrapper.text()).toBe('Click');
expect(wrapper.classes()).toContain('my-button--primary');
});
it('emits click', async () => {
const wrapper = mount(MyButton, {
props: { type: 'primary' }
});
await wrapper.trigger('click');
expect(wrapper.emitted('click')).toBeTruthy();
expect(wrapper.emitted('click')?.[0]).toBeDefined();
});
it('disabled', async () => {
const wrapper = mount(MyButton, {
props: { disabled: true }
});
await wrapper.trigger('click');
expect(wrapper.emitted('click')).toBeFalsy();
});
});5.2 props 与 slots
typescript
import { mount } from '@vue/test-utils';
import MyCard from './MyCard.vue';
it('slots', () => {
const wrapper = mount(MyCard, {
slots: {
default: '<p>Body</p>',
header: '<h1>Title</h1>',
footer: '<button>OK</button>'
}
});
expect(wrapper.html()).toContain('Title');
expect(wrapper.html()).toContain('Body');
});
it('props', () => {
const wrapper = mount(MyCard, {
props: { title: 'Hello' }
});
expect(wrapper.props('title')).toBe('Hello');
});5.3 v-model 测试
typescript
import { mount } from '@vue/test-utils';
import MyInput from './MyInput.vue';
it('v-model', async () => {
const wrapper = mount(MyInput, {
props: { modelValue: 'init' }
});
await wrapper.setValue('changed');
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['changed']);
});5.4 异步组件
typescript
import { mount, flushPromises } from '@vue/test-utils';
it('async', async () => {
const wrapper = mount(AsyncComponent);
await flushPromises();
expect(wrapper.find('.data').exists()).toBe(true);
});5.5 提供 Provide
typescript
import { mount } from '@vue/test-utils';
import ChildComp from './ChildComp.vue';
const wrapper = mount(ChildComp, {
global: {
provide: {
'my-key': 'my-value'
}
}
});5.6 Router
typescript
import { mount } from '@vue/test-utils';
import { createRouter, createMemoryHistory } from 'vue-router';
import MyComp from './MyComp.vue';
const router = createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/', component: MyComp }
]
});
beforeEach(() => router.push('/'));
beforeEach(() => router.isReady());
it('with router', async () => {
const wrapper = mount(MyComp, {
global: { plugins: [router] }
});
expect(wrapper.find('h1').text()).toBe('Home');
});5.7 Pinia
typescript
import { mount } from '@vue/test-utils';
import { createPinia, setActivePinia } from 'pinia';
import { useUserStore } from '@/stores/user';
import MyComp from './MyComp.vue';
beforeEach(() => {
setActivePinia(createPinia());
});
it('with pinia', () => {
const store = useUserStore();
store.name = 'Tom';
const wrapper = mount(MyComp);
expect(wrapper.text()).toContain('Tom');
});六、Composable 测试
6.1 基础
typescript
import { useCounter } from './useCounter';
describe('useCounter', () => {
it('initial', () => {
const { count } = useCounter(10);
expect(count.value).toBe(10);
});
it('increment', () => {
const { count, increment } = useCounter();
increment();
expect(count.value).toBe(1);
});
});6.2 生命周期
typescript
import { defineComponent, h } from 'vue';
import { mount } from '@vue/test-utils';
import { useMouse } from './useMouse';
it('useMouse onMounted', () => {
const Comp = defineComponent({
setup() {
const { x, y } = useMouse();
return { x, y };
},
render() {
return h('div', `${x.value},${y.value}`);
}
});
const wrapper = mount(Comp);
expect(wrapper.text()).toBe('0,0');
});6.3 异步 composable
typescript
import { useFetch } from './useFetch';
import { flushPromises } from '@vue/test-utils';
it('fetch', async () => {
const { data, loading, execute } = useFetch('/api/user');
expect(loading.value).toBe(true);
await flushPromises();
expect(data.value).toEqual({ name: 'Tom' });
});七、Mock
7.1 mock 函数
typescript
import { vi } from 'vitest';
const fn = vi.fn();
fn('hello');
expect(fn).toHaveBeenCalledWith('hello');
expect(fn).toHaveBeenCalledTimes(1);
fn.mockReturnValue(42);
fn.mockReturnValueOnce(1).mockReturnValueOnce(2);7.2 mock 模块
typescript
import { vi } from 'vitest';
vi.mock('./api', () => ({
fetchUser: vi.fn().mockResolvedValue({ id: 1, name: 'Tom' })
}));
import { fetchUser } from './api';
fetchUser(); // 返回 { id: 1, name: 'Tom' }7.3 spy
typescript
import { vi } from 'vitest';
const obj = { method: () => 'real' };
const spy = vi.spyOn(obj, 'method');
spy.mockReturnValue('mocked');
expect(obj.method()).toBe('mocked');
expect(spy).toHaveBeenCalled();7.4 mock 时间
typescript
import { vi } from 'vitest';
vi.useFakeTimers();
setTimeout(() => {
console.log('done');
}, 1000);
vi.advanceTimersByTime(1000);
expect(console.log).toHaveBeenCalledWith('done');
vi.useRealTimers();7.5 partial mock
typescript
import { vi } from 'vitest';
vi.mock('./utils', async () => {
const actual = await vi.importActual('./utils');
return {
...(actual as any),
heavyFunc: vi.fn()
};
});八、快照测试
8.1 组件快照
typescript
import { mount } from '@vue/test-utils';
import MyComp from './MyComp.vue';
it('snapshot', () => {
const wrapper = mount(MyComp);
expect(wrapper.html()).toMatchSnapshot();
});8.2 更新快照
bash
pnpm test -- -u九、覆盖率
9.1 配置
typescript
coverage: {
provider: 'v8',
reporter: ['text', 'html'],
include: ['src/**/*.{ts,vue}'],
thresholds: {
lines: 80,
functions: 80,
branches: 80,
statements: 80
}
}9.2 命令
bash
pnpm test:coverage9.3 报告
coverage/index.html- HTML 报告coverage/coverage-final.json- JSON 数据
十、调试
10.1 调试单个测试
typescript
it.only('debug', () => {
// 仅运行这个
});10.2 console 输出
typescript
it('debug', () => {
console.log('debug info');
// 输出在控制台
});10.3 断点调试
bash
# VSCode 配置
{
"type": "node",
"request": "launch",
"name": "Vitest",
"program": "${workspaceRoot}/node_modules/vitest/vitest.mjs",
"args": ["run", "--reporter=verbose"]
}十一、VueUse 测试
typescript
import { mount } from '@vue/test-utils';
import { useDark, useToggle } from '@vueuse/core';
it('useDark', () => {
const wrapper = mount({
setup() {
const isDark = useDark();
const toggle = useToggle(isDark);
return { isDark, toggle };
},
template: '<button @click="toggle()">{{ isDark }}</button>'
});
wrapper.find('button').trigger('click');
expect(wrapper.text()).toBe('true');
});十二、E2E 测试(Vitest + Playwright)
bash
pnpm add -D @playwright/testtypescript
import { test, expect } from '@playwright/test';
test('homepage', async ({ page }) => {
await page.goto('http://localhost:3000');
await expect(page.locator('h1')).toHaveText('Hello Vue');
});十三、CI 集成
yaml
# .github/workflows/test.yml
name: Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm test:run
- run: pnpm test:coverage
- uses: codecov/codecov-action@v4
with:
files: ./coverage/coverage-final.json十四、测试策略
| 层级 | 数量 | 速度 | 工具 |
|---|---|---|---|
| 单元 | 多 | 快 | Vitest |
| 集成 | 中 | 中 | Vitest + Vue Test Utils |
| E2E | 少 | 慢 | Playwright |
十五、本章小结
| 概念 | 关键 |
|---|---|
| mount | @vue/test-utils 挂载组件 |
| describe/it | BDD 风格测试 |
| expect | 断言 |
| vi.mock | mock 模块 |
| vi.fn | mock 函数 |
| vi.spyOn | spy 方法 |
| 快照 | 防止意外变更 |
| 覆盖率 | v8 / istanbul |
动手练习
- 组件测试:为你的 Button 组件编写完整测试
- Composable:为 useFetch 编写测试(mocks)
- store 测试:测试 Pinia store 的 action
- 覆盖率:为项目设置 80% 覆盖率门槛
- CI 集成:在 GitHub Actions 中运行测试
推荐阅读
- 📖 Vitest 官方文档 — 完整指南
- 📖 Vue Test Utils — 组件测试
- 🌐 Vue 测试模式 — 官方推荐
- 📖 Playwright — E2E 测试
下一章:第 134 章:组件库发布 →