Vue 测试完全指南
Vue 测试完全指南
测试是保证代码质量的重要手段。本文详细介绍 Vue 3 应用的测试方法,包括单元测试、组件测试和端到端测试,使用 Vitest 和 Vue Test Utils 等现代测试工具,并结合真实场景讲解如何写出有价值、可维护的测试。
零、为什么要写测试
很多人觉得"写测试浪费时间",但从工程角度看,测试是回报率最高的投资之一。
一个类比:测试就像给代码上的一道道保险丝。没有测试,你每次改动都是"闭着眼睛开车",改 A 处坏了 B 处却毫不知情,直到用户投诉。有了测试,回归问题会在提交前就被拦下。
测试带来的收益(行业普遍数据):
| 维度 | 无测试 | 有良好测试 |
|------|--------|-----------|
| 重构信心 | 不敢改,怕改坏 | 放心大胆重构 |
| Bug 修复成本 | 上线后发现,成本高 | 开发阶段发现,成本低 |
| 回归风险 | 每次发版提心吊胆 | CI 自动拦截 |
| 文档作用 | 无 | 测试即"可执行文档" |
研究表明,一个 Bug 在需求阶段修复成本为 1,在开发阶段为 5,在测试阶段为 10,而到了线上则可能高达 100 倍以上。测试的本质是把 Bug 拦截在越早越好的阶段。
测试金字塔:合理的测试结构应该像金字塔——底层大量快速的单元测试,中层适量的组件/集成测试,顶层少量的端到端测试。
/\ E2E 测试(少,慢,贵,最接近真实)
/ \
/----\ 集成/组件测试(适量)
/ \
/--------\ 单元测试(多,快,便宜)一、测试基础
1. 测试工具介绍
Vitest:
Vue Test Utils:
Testing Library:
三者的关系与选择:
| 工具 | 角色 | 关注点 |
|------|------|--------|
| Vitest | 测试运行器 | 组织与执行测试、断言、Mock |
| Vue Test Utils | 组件挂载库 | 挂载组件、操作 wrapper |
| Testing Library | 组件挂载库(替代方案) | 从用户视角查询与交互 |
Vitest 是必选的运行器;组件测试可在 Vue Test Utils 和 Testing Library 之间二选一,前者更贴近 Vue 内部,后者更贴近用户视角。
为什么选 Vitest 而非 Jest?
| 维度 | Jest | Vitest |
|------|------|--------|
| 与 Vite 集成 | 需额外配置 | 原生,零配置复用 vite.config |
| 启动速度 | 较慢 | 快(复用 esbuild 转换) |
| ESM 支持 | 需转译 | 原生支持 |
| API 兼容 | 标准 | 兼容 Jest,迁移无痛 |
| watch 模式 | 有 | 更快(基于 HMR) |
2. 安装配置
安装 Vitest:
npm install -D vitest @vitejs/plugin-vue安装 Vue Test Utils:
npm install -D @vue/test-utils jsdom安装 Testing Library:
npm install -D @testing-library/vue @testing-library/jest-domVite 配置:
// vite.config.js
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
export default defineConfig({
plugins: [vue()],
test: {
globals: true,
environment: 'jsdom',
setupFiles: './src/test/setup.js',
coverage: {
reporter: ['text', 'json', 'html']
}
}
});setup 文件(全局初始化):
// src/test/setup.js
import '@testing-library/jest-dom';
import { config } from '@vue/test-utils';
// 全局注册组件、指令、插件的示例
config.global.stubs = {
// 默认 stub 掉路由链接,避免测试组件时报错
RouterLink: true
};测试脚本:
{
"scripts": {
"test": "vitest",
"test:run": "vitest run",
"test:ui": "vitest --ui",
"test:coverage": "vitest --coverage"
}
}vitest 默认进入 watch 模式(改代码自动重跑);CI 环境用 vitest run 单次执行。
二、单元测试
单元测试针对最小的可测试单元——纯函数、composables、store,它们不涉及 DOM,运行最快,应该占测试的大头。
1. 测试 Composables
// composables/useCounter.js
import { ref } from 'vue';
export function useCounter(initialValue = 0) {
const count = ref(initialValue);
const increment = () => {
count.value++;
};
const decrement = () => {
count.value--;
};
const reset = () => {
count.value = initialValue;
};
return { count, increment, decrement, reset };
}测试代码:
// composables/useCounter.test.js
import { describe, it, expect } from 'vitest';
import { useCounter } from './useCounter';
describe('useCounter', () => {
it('应该使用初始值初始化 count', () => {
const { count } = useCounter(10);
expect(count.value).toBe(10);
});
it('应该默认初始值为 0', () => {
const { count } = useCounter();
expect(count.value).toBe(0);
});
it('应该递增 count', () => {
const { count, increment } = useCounter();
increment();
expect(count.value).toBe(1);
});
it('应该递减 count', () => {
const { count, decrement } = useCounter(5);
decrement();
expect(count.value).toBe(4);
});
it('应该重置 count 到初始值', () => {
const { count, increment, reset } = useCounter(10);
increment();
increment();
reset();
expect(count.value).toBe(10);
});
});测试依赖生命周期的 Composable:如果 composable 内部用了 onMounted 等钩子,就不能裸调用,需要用 withSetup 辅助挂载到一个测试组件:
// test/withSetup.js
import { createApp } from 'vue';
export function withSetup(composable) {
let result;
const app = createApp({
setup() {
result = composable();
return () => {};
}
});
app.mount(document.createElement('div'));
return [result, app];
}// 使用示例
import { withSetup } from '../test/withSetup';
import { useMouse } from './useMouse';
it('鼠标移动应更新坐标', () => {
const [result, app] = withSetup(() => useMouse());
window.dispatchEvent(new MouseEvent('mousemove', { pageX: 10, pageY: 20 }));
expect(result.x.value).toBe(10);
expect(result.y.value).toBe(20);
app.unmount(); // 清理,触发 onUnmounted
});2. 测试工具函数
// utils/format.js
export function formatDate(date) {
if (!date) return '';
return new Date(date).toLocaleDateString('zh-CN');
}
export function formatCurrency(amount, currency = 'CNY') {
return new Intl.NumberFormat('zh-CN', {
style: 'currency',
currency
}).format(amount);
}
export function truncate(str, length = 50) {
if (!str) return '';
return str.length > length ? str.slice(0, length) + '...' : str;
}测试代码:
// utils/format.test.js
import { describe, it, expect } from 'vitest';
import { formatDate, formatCurrency, truncate } from './format';
describe('formatDate', () => {
it('应该格式化日期', () => {
const result = formatDate('2024-01-15');
expect(result).toMatch(/\d{4}\/\d{1,2}\/\d{1,2}/);
});
it('空值应该返回空字符串', () => {
expect(formatDate(null)).toBe('');
expect(formatDate(undefined)).toBe('');
expect(formatDate('')).toBe('');
});
});
describe('formatCurrency', () => {
it('应该格式化货币', () => {
expect(formatCurrency(1000)).toBe('¥1,000.00');
});
it('应该支持不同货币', () => {
expect(formatCurrency(1000, 'USD')).toBe('$1,000.00');
});
});
describe('truncate', () => {
it('应该截断长字符串', () => {
const result = truncate('a'.repeat(60), 50);
expect(result.length).toBe(53); // 50 + '...'
expect(result).endsWith('...');
});
it('短字符串不应该被截断', () => {
expect(truncate('hello')).toBe('hello');
});
});边界测试的重要性:注意上面对 formatDate 的空值测试。真实项目中大量 Bug 来自对 null/undefined/空字符串等边界的处理不当。一个好的测试习惯是:正常路径 1 个用例,边界与异常路径 N 个用例。
参数化测试(table-driven)减少重复:
import { describe, it, expect } from 'vitest';
import { truncate } from './format';
describe('truncate 参数化', () => {
it.each([
['hello', 50, 'hello'],
['', 50, ''],
[null, 50, ''],
['a'.repeat(60), 50, 'a'.repeat(50) + '...']
])('truncate(%s, %i) => %s', (input, len, expected) => {
expect(truncate(input, len)).toBe(expected);
});
});3. 测试 Pinia Store
// stores/counter.js
import { defineStore } from 'pinia';
export const useCounterStore = defineStore('counter', {
state: () => ({
count: 0,
name: 'counter'
}),
getters: {
doubleCount: (state) => state.count * 2
},
actions: {
increment() {
this.count++;
},
setCount(value) {
this.count = value;
}
}
});测试代码:
// stores/counter.test.js
import { describe, it, expect, beforeEach } from 'vitest';
import { setActivePinia, createPinia } from 'pinia';
import { useCounterStore } from './counter';
describe('useCounterStore', () => {
let store;
beforeEach(() => {
setActivePinia(createPinia());
store = useCounterStore();
});
it('应该初始化 state', () => {
expect(store.count).toBe(0);
expect(store.name).toBe('counter');
});
it('应该递增 count', () => {
store.increment();
expect(store.count).toBe(1);
});
it('应该设置 count', () => {
store.setCount(10);
expect(store.count).toBe(10);
});
it('应该计算 doubleCount', () => {
store.setCount(5);
expect(store.doubleCount).toBe(10);
});
});为什么每个测试都要 beforeEach 重建 Pinia? 因为 store 是单例,如果不重置,上一个测试对 state 的修改会"泄漏"到下一个测试,导致测试之间互相影响、结果随执行顺序变化。setActivePinia(createPinia()) 保证每个测试都从干净状态开始,这是"测试独立性"原则的体现。
测试含异步 action 的 store:
// stores/user.js
import { defineStore } from 'pinia';
import { fetchUser } from '@/api/user';
export const useUserStore = defineStore('user', {
state: () => ({ user: null, loading: false }),
actions: {
async loadUser(id) {
this.loading = true;
this.user = await fetchUser(id);
this.loading = false;
}
}
});// stores/user.test.js
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { setActivePinia, createPinia } from 'pinia';
import { useUserStore } from './user';
import { fetchUser } from '@/api/user';
vi.mock('@/api/user');
describe('useUserStore', () => {
beforeEach(() => {
setActivePinia(createPinia());
vi.clearAllMocks();
});
it('应该异步加载用户', async () => {
fetchUser.mockResolvedValue({ id: 1, name: '张三' });
const store = useUserStore();
await store.loadUser(1);
expect(fetchUser).toHaveBeenCalledWith(1);
expect(store.user).toEqual({ id: 1, name: '张三' });
expect(store.loading).toBe(false);
});
});三、组件测试
组件测试挂载真实组件到虚拟 DOM,验证渲染结果、交互行为、事件触发,是最能反映用户体验的一层测试。
1. 使用 Vue Test Utils
基础组件:
<!-- Button.vue -->
<script setup>
defineProps({
type: {
type: String,
default: 'primary',
validator: (value) => ['primary', 'secondary', 'danger'].includes(value)
},
disabled: {
type: Boolean,
default: false
}
});
defineEmits(['click']);
</script>
<template>
<button
:class="['btn', `btn-${type}`]"
:disabled="disabled"
@click="$emit('click')"
>
<slot></slot>
</button>
</template>测试代码:
// components/Button.test.js
import { describe, it, expect, vi } from 'vitest';
import { mount } from '@vue/test-utils';
import Button from './Button.vue';
describe('Button', () => {
it('应该渲染默认内容', () => {
const wrapper = mount(Button, {
slots: { default: '点击我' }
});
expect(wrapper.text()).toBe('点击我');
});
it('应该应用默认类型样式', () => {
const wrapper = mount(Button);
expect(wrapper.classes()).toContain('btn-primary');
});
it('应该应用自定义类型样式', () => {
const wrapper = mount(Button, {
props: { type: 'danger' }
});
expect(wrapper.classes()).toContain('btn-danger');
});
it('应该禁用按钮', () => {
const wrapper = mount(Button, {
props: { disabled: true }
});
expect(wrapper.attributes('disabled')).toBeDefined();
});
it('应该触发点击事件', async () => {
const wrapper = mount(Button);
await wrapper.trigger('click');
expect(wrapper.emitted('click')).toHaveLength(1);
});
it('禁用时不应该触发点击事件', async () => {
const wrapper = mount(Button, {
props: { disabled: true }
});
await wrapper.trigger('click');
expect(wrapper.emitted('click')).toBeUndefined();
});
});mount vs shallowMount:
| 方法 | 子组件 | 适用场景 |
|------|--------|----------|
| mount | 完整渲染子组件 | 集成行为、真实交互 |
| shallowMount | 子组件被 stub | 隔离测试当前组件、避免子组件副作用 |
import { shallowMount } from '@vue/test-utils';
// 只测本组件逻辑,不关心子组件内部渲染
const wrapper = shallowMount(ParentComponent);2. 测试带 Props 的组件
<!-- UserCard.vue -->
<script setup>
const props = defineProps({
user: {
type: Object,
required: true,
validator: (user) => user.id && user.name
}
});
defineEmits(['select']);
</script>
<template>
<div class="user-card" @click="$emit('select', user)">
<h3>{{ user.name }}</h3>
<p>{{ user.email }}</p>
<span v-if="user.role" class="role">{{ user.role }}</span>
</div>
</template>测试代码:
// components/UserCard.test.js
import { describe, it, expect, vi } from 'vitest';
import { mount } from '@vue/test-utils';
import UserCard from './UserCard.vue';
describe('UserCard', () => {
const mockUser = {
id: 1,
name: '张三',
email: 'zhangsan@example.com',
role: 'admin'
};
it('应该渲染用户信息', () => {
const wrapper = mount(UserCard, {
props: { user: mockUser }
});
expect(wrapper.text()).toContain('张三');
expect(wrapper.text()).toContain('zhangsan@example.com');
expect(wrapper.text()).toContain('admin');
});
it('应该隐藏角色(当没有 role 时)', () => {
const wrapper = mount(UserCard, {
props: { user: { id: 1, name: '李四', email: 'lisi@example.com' } }
});
expect(wrapper.find('.role').exists()).toBe(false);
});
it('应该触发 select 事件', async () => {
const wrapper = mount(UserCard, {
props: { user: mockUser }
});
await wrapper.trigger('click');
expect(wrapper.emitted('select')).toHaveLength(1);
expect(wrapper.emitted('select')[0]).toEqual([mockUser]);
});
it('更新 props 后应重新渲染', async () => {
const wrapper = mount(UserCard, {
props: { user: mockUser }
});
await wrapper.setProps({ user: { id: 2, name: '王五', email: 'w@x.com' } });
expect(wrapper.text()).toContain('王五');
});
});3. 测试带异步操作的组件
<!-- UserList.vue -->
<script setup>
import { ref, onMounted } from 'vue';
import { fetchUsers } from '@/api/user';
const users = ref([]);
const loading = ref(false);
const error = ref(null);
const loadUsers = async () => {
loading.value = true;
error.value = null;
try {
users.value = await fetchUsers();
} catch (e) {
error.value = e.message;
} finally {
loading.value = false;
}
};
onMounted(() => {
loadUsers();
});
</script>
<template>
<div>
<div v-if="loading">加载中...</div>
<div v-else-if="error">错误:{{ error }}</div>
<div v-else>
<div v-for="user in users" :key="user.id">
{{ user.name }}
</div>
</div>
</div>
</template>测试代码:
// components/UserList.test.js
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { mount, flushPromises } from '@vue/test-utils';
import UserList from './UserList.vue';
import { fetchUsers } from '@/api/user';
vi.mock('@/api/user');
describe('UserList', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('应该显示加载状态', () => {
fetchUsers.mockReturnValue(new Promise(() => {}));
const wrapper = mount(UserList);
expect(wrapper.text()).toContain('加载中...');
});
it('应该显示用户列表', async () => {
const mockUsers = [
{ id: 1, name: '张三' },
{ id: 2, name: '李四' }
];
fetchUsers.mockResolvedValue(mockUsers);
const wrapper = mount(UserList);
await flushPromises();
expect(wrapper.text()).toContain('张三');
expect(wrapper.text()).toContain('李四');
});
it('应该显示错误信息', async () => {
fetchUsers.mockRejectedValue(new Error('网络错误'));
const wrapper = mount(UserList);
await flushPromises();
expect(wrapper.text()).toContain('错误:网络错误');
});
});flushPromises 是什么? 它会"清空"所有已排队的微任务(Promise 回调),确保异步数据加载完成、组件完成重渲染后再断言。测试异步组件时忘记 await flushPromises 是最常见的错误来源,会导致断言在数据到来之前就执行、结果永远是加载态。
处理定时器的异步测试:
import { vi } from 'vitest';
it('防抖搜索应在 300ms 后触发', async () => {
vi.useFakeTimers();
const onSearch = vi.fn();
// ...触发输入...
vi.advanceTimersByTime(300); // 快进 300ms
expect(onSearch).toHaveBeenCalledOnce();
vi.useRealTimers();
});4. 测试带路由和 Pinia 的组件
真实组件常依赖路由和全局状态,挂载时需通过 global.plugins 注入:
import { mount } from '@vue/test-utils';
import { createRouter, createMemoryHistory } from 'vue-router';
import { createPinia } from 'pinia';
import Dashboard from './Dashboard.vue';
const router = createRouter({
history: createMemoryHistory(),
routes: [{ path: '/', component: { template: '<div/>' } }]
});
it('挂载带路由和 store 的组件', async () => {
router.push('/');
await router.isReady();
const wrapper = mount(Dashboard, {
global: {
plugins: [router, createPinia()]
}
});
expect(wrapper.exists()).toBe(true);
});四、使用 Testing Library
Testing Library 提倡"像用户一样测试"——不关心组件内部有什么 ref、什么 class,只关心"用户能看到什么、能点什么"。
1. 基础用法
// components/Counter.test.js
import { describe, it, expect } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/vue';
import Counter from './Counter.vue';
describe('Counter', () => {
it('应该渲染初始计数', () => {
render(Counter);
expect(screen.getByText('计数:0')).toBeInTheDocument();
});
it('应该递增计数', async () => {
render(Counter);
const button = screen.getByRole('button', { name: /增加/i });
await fireEvent.click(button);
expect(screen.getByText('计数:1')).toBeInTheDocument();
});
it('应该递减计数', async () => {
render(Counter, {
props: { initialValue: 5 }
});
const button = screen.getByRole('button', { name: /减少/i });
await fireEvent.click(button);
expect(screen.getByText('计数:4')).toBeInTheDocument();
});
});2. 查询方法
import { render, screen } from '@testing-library/vue';
// 常用查询方法
const {
getByRole, // 按角色查询(最推荐,贴近无障碍语义)
getByText, // 按文本查询
getByLabelText, // 按标签查询(表单)
getByPlaceholderText, // 按占位符查询
getByTestId // 按测试 ID 查询(兜底方案)
} = render(Component);
// 变体
screen.getBy...; // 找不到时抛出错误
screen.queryBy...; // 找不到时返回 null(用于断言不存在)
screen.findBy...; // 异步查询(返回 Promise,等元素出现)
screen.getAllBy...; // 返回所有匹配查询优先级建议(Testing Library 官方推荐顺序):
| 优先级 | 查询方式 | 理由 |
|--------|----------|------|
| 1 | getByRole | 最贴近用户和辅助技术 |
| 2 | getByLabelText | 表单场景语义清晰 |
| 3 | getByText | 非交互元素 |
| 4 | getByTestId | 前面都不适用时的兜底 |
3. 表单交互完整案例
// components/LoginForm.test.js
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/vue';
import LoginForm from './LoginForm.vue';
describe('LoginForm', () => {
it('填写并提交表单', async () => {
const onSubmit = vi.fn();
render(LoginForm, { props: { onSubmit } });
await fireEvent.update(screen.getByLabelText('用户名'), 'admin');
await fireEvent.update(screen.getByLabelText('密码'), '123456');
await fireEvent.click(screen.getByRole('button', { name: /登录/ }));
expect(onSubmit).toHaveBeenCalledWith({
username: 'admin',
password: '123456'
});
});
it('空用户名应显示校验错误', async () => {
render(LoginForm);
await fireEvent.click(screen.getByRole('button', { name: /登录/ }));
expect(screen.getByText('用户名不能为空')).toBeInTheDocument();
});
});Vue Test Utils vs Testing Library 对比:
| 维度 | Vue Test Utils | Testing Library |
|------|----------------|-----------------|
| 测试哲学 | 可访问内部(vm、props) | 只从用户视角 |
| 查询方式 | find(css 选择器) | getByRole/Text |
| 重构友好度 | 依赖结构,易碎 | 更稳定 |
| 学习曲线 | 熟悉 Vue 即可 | 需转变思维 |
| 适合 | Vue 特性细节 | 用户行为 |
五、端到端(E2E)测试
单元和组件测试都跑在模拟环境(jsdom)里,而 E2E 测试在真实浏览器中运行整个应用,验证从点击到接口返回的完整链路。主流工具是 Cypress 和 Playwright。
// e2e/login.spec.js(Playwright 示例)
import { test, expect } from '@playwright/test';
test('用户可以登录并进入仪表盘', async ({ page }) => {
await page.goto('http://localhost:5173/login');
await page.fill('input[name="username"]', 'admin');
await page.fill('input[name="password"]', '123456');
await page.click('button[type="submit"]');
// 断言跳转到仪表盘
await expect(page).toHaveURL(/.*dashboard/);
await expect(page.getByText('欢迎回来')).toBeVisible();
});三层测试对比:
| 类型 | 运行环境 | 速度 | 稳定性 | 覆盖范围 | 建议占比 |
|------|----------|------|--------|----------|----------|
| 单元测试 | Node/jsdom | 极快 | 高 | 单个函数/模块 | 70% |
| 组件测试 | jsdom | 快 | 较高 | 单组件行为 | 20% |
| E2E 测试 | 真实浏览器 | 慢 | 较低(易 flaky) | 完整流程 | 10% |
六、测试最佳实践
// AAA 结构示例
it('点击后计数加一', async () => {
// Arrange 准备
const wrapper = mount(Counter);
// Act 执行
await wrapper.find('button').trigger('click');
// Assert 断言
expect(wrapper.text()).toContain('1');
});反面教材(测试实现细节,脆弱):
// 不好:断言内部 data,重构就挂
expect(wrapper.vm.internalCount).toBe(1);
// 好:断言用户可见的输出
expect(wrapper.text()).toContain('计数:1');七、测试覆盖率
生成覆盖率报告:
npm run test:coverage配置覆盖率阈值:
// vite.config.js
export default defineConfig({
test: {
coverage: {
reporter: ['text', 'json', 'html'],
thresholds: {
lines: 80,
functions: 80,
branches: 80,
statements: 80
}
}
}
});覆盖率四个指标的含义:
| 指标 | 含义 | 说明 |
|------|------|------|
| statements | 语句覆盖率 | 有多少语句被执行 |
| branches | 分支覆盖率 | if/else、三元等分支是否都覆盖 |
| functions | 函数覆盖率 | 有多少函数被调用 |
| lines | 行覆盖率 | 有多少行被执行 |
关于覆盖率的理性认识:覆盖率是有用的参考,但不是目标本身。100% 覆盖率不代表没有 Bug——它只说明"每行都跑过了",不代表"每种情况都断言正确"。行业经验值是核心业务逻辑 80% 以上、工具函数接近 100%、UI 展示层可适当放宽。追求有意义的测试,而不是为了刷数字写无断言的测试。
排除不需要测的文件:
coverage: {
exclude: [
'node_modules/',
'**/*.config.js',
'**/*.test.js',
'src/main.js',
'src/router/**'
]
}十一、测试组合式函数(Composables)
Composable 是 Vue 3 逻辑复用的核心,也是最适合单元测试的部分——它是纯逻辑,无需渲染。
1. 测试无生命周期依赖的 composable
// composables/useCounter.js
import { ref } from 'vue';
export function useCounter(initial = 0) {
const count = ref(initial);
const increment = () => count.value++;
const decrement = () => count.value--;
const reset = () => (count.value = initial);
return { count, increment, decrement, reset };
}import { describe, it, expect } from 'vitest';
import { useCounter } from '@/composables/useCounter';
describe('useCounter', () => {
it('初始值正确', () => {
const { count } = useCounter(5);
expect(count.value).toBe(5);
});
it('increment 加一', () => {
const { count, increment } = useCounter();
increment();
expect(count.value).toBe(1);
});
it('reset 恢复初始值', () => {
const { count, increment, reset } = useCounter(10);
increment();
reset();
expect(count.value).toBe(10);
});
});2. 测试依赖生命周期的 composable
如果 composable 内部用了 onMounted、onUnmounted,直接调用会警告"没有活动的组件实例"。需要用一个宿主组件包裹。
import { mount } from '@vue/test-utils';
// 辅助函数:在组件上下文中运行 composable
function withSetup(composable) {
let result;
const wrapper = mount({
setup() {
result = composable();
return () => {}; // 空渲染
}
});
return [result, wrapper];
}
it('useMouse 挂载后监听鼠标', () => {
const [result, wrapper] = withSetup(() => useMouse());
expect(result.x.value).toBe(0);
// 触发 window 事件
window.dispatchEvent(new MouseEvent('mousemove', { pageX: 100, pageY: 50 }));
expect(result.x.value).toBe(100);
wrapper.unmount(); // 触发 onUnmounted 清理
});3. 测试带异步的 composable
// composables/useFetch.js
import { ref } from 'vue';
export function useFetch(url) {
const data = ref(null);
const error = ref(null);
const loading = ref(true);
fetch(url)
.then((r) => r.json())
.then((d) => (data.value = d))
.catch((e) => (error.value = e))
.finally(() => (loading.value = false));
return { data, error, loading };
}import { vi, it, expect } from 'vitest';
import { flushPromises } from '@vue/test-utils';
it('useFetch 成功获取数据', async () => {
// mock 全局 fetch
global.fetch = vi.fn().mockResolvedValue({
json: () => Promise.resolve({ name: 'Alice' })
});
const { data, loading } = useFetch('/api/user');
expect(loading.value).toBe(true);
await flushPromises(); // 等所有微任务完成
expect(loading.value).toBe(false);
expect(data.value).toEqual({ name: 'Alice' });
});十二、测试 Pinia Store
Pinia 官方提供了测试工具,让 store 测试变得简单。
1. 测试 store 的 state / getters / actions
// stores/cart.js
import { defineStore } from 'pinia';
export const useCartStore = defineStore('cart', {
state: () => ({ items: [] }),
getters: {
total: (state) => state.items.reduce((s, i) => s + i.price * i.qty, 0),
count: (state) => state.items.length
},
actions: {
addItem(item) {
const exist = this.items.find((i) => i.id === item.id);
if (exist) exist.qty++;
else this.items.push({ ...item, qty: 1 });
},
removeItem(id) {
this.items = this.items.filter((i) => i.id !== id);
}
}
});import { setActivePinia, createPinia } from 'pinia';
import { beforeEach, describe, it, expect } from 'vitest';
import { useCartStore } from '@/stores/cart';
describe('cart store', () => {
beforeEach(() => {
// 每个测试前创建全新 pinia,保证隔离
setActivePinia(createPinia());
});
it('添加商品', () => {
const cart = useCartStore();
cart.addItem({ id: 1, price: 100 });
expect(cart.count).toBe(1);
expect(cart.total).toBe(100);
});
it('重复添加只增加数量', () => {
const cart = useCartStore();
cart.addItem({ id: 1, price: 100 });
cart.addItem({ id: 1, price: 100 });
expect(cart.count).toBe(1);
expect(cart.total).toBe(200);
});
it('移除商品', () => {
const cart = useCartStore();
cart.addItem({ id: 1, price: 100 });
cart.removeItem(1);
expect(cart.count).toBe(0);
});
});2. 在组件测试中 mock store
用 @pinia/testing 的 createTestingPinia 可以自动 mock 所有 action,方便断言"是否被调用"。
import { createTestingPinia } from '@pinia/testing';
import { mount } from '@vue/test-utils';
import { vi } from 'vitest';
it('点击加入购物车调用 addItem', async () => {
const wrapper = mount(ProductCard, {
props: { product: { id: 1, price: 100 } },
global: {
plugins: [createTestingPinia({ createSpy: vi.fn })]
}
});
const cart = useCartStore();
await wrapper.find('.add-btn').trigger('click');
expect(cart.addItem).toHaveBeenCalledWith({ id: 1, price: 100 });
});createTestingPinia 默认会 stub 掉 action(不真正执行),只记录调用;若想让 action 真正执行,传 stubActions: false。
十三、测试 Vue Router 相关逻辑
1. 测试含路由的组件
import { createRouter, createMemoryHistory } from 'vue-router';
import { mount, flushPromises } from '@vue/test-utils';
const routes = [
{ path: '/', component: { template: '<div>home</div>' } },
{ path: '/detail/:id', name: 'detail', component: { template: '<div>detail</div>' } }
];
it('点击卡片跳转到详情页', async () => {
const router = createRouter({ history: createMemoryHistory(), routes });
router.push('/');
await router.isReady();
const wrapper = mount(ProductList, {
global: { plugins: [router] }
});
await wrapper.find('.card').trigger('click');
await flushPromises();
expect(router.currentRoute.value.name).toBe('detail');
expect(router.currentRoute.value.params.id).toBe('1');
});2. mock useRouter / useRoute
如果不想引入真实 router,可以直接 mock。
import { vi } from 'vitest';
const pushMock = vi.fn();
vi.mock('vue-router', () => ({
useRouter: () => ({ push: pushMock }),
useRoute: () => ({ params: { id: '42' }, query: {} })
}));
it('提交后跳转', async () => {
const wrapper = mount(Form);
await wrapper.find('form').trigger('submit');
expect(pushMock).toHaveBeenCalledWith('/success');
});十四、Mock 深入
Mock 是测试中最容易出错也最强大的部分。理解 Vitest 的各种 mock 手段是写好测试的关键。
1. vi.fn 函数 mock
import { vi, expect, it } from 'vitest';
it('回调被正确调用', () => {
const callback = vi.fn();
[1, 2, 3].forEach(callback);
expect(callback).toHaveBeenCalledTimes(3);
expect(callback).toHaveBeenNthCalledWith(1, 1, 0, [1, 2, 3]);
expect(callback).toHaveReturnedTimes(3);
});
it('可指定返回值', () => {
const fn = vi.fn().mockReturnValue(42);
expect(fn()).toBe(42);
const asyncFn = vi.fn().mockResolvedValue({ ok: true });
return expect(asyncFn()).resolves.toEqual({ ok: true });
});2. vi.mock 模块 mock
// 完全替换一个模块
import { vi } from 'vitest';
import { getUser } from '@/api/user';
vi.mock('@/api/user', () => ({
getUser: vi.fn().mockResolvedValue({ id: 1, name: 'Mock User' })
}));
it('使用 mock 的 API', async () => {
const user = await getUser(1);
expect(user.name).toBe('Mock User');
});部分 mock(保留其余真实实现):
vi.mock('@/utils', async (importOriginal) => {
const actual = await importOriginal();
return {
...actual, // 保留真实实现
formatDate: vi.fn(() => '2026-08-04') // 只 mock 这一个
};
});3. mock 定时器
import { vi, it, expect, beforeEach, afterEach } from 'vitest';
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
it('防抖 300ms 后执行', () => {
const fn = vi.fn();
const debounced = debounce(fn, 300);
debounced();
debounced();
expect(fn).not.toHaveBeenCalled();
vi.advanceTimersByTime(300); // 快进时间
expect(fn).toHaveBeenCalledTimes(1);
});4. mock 网络请求:用 MSW
相比直接 mock fetch,MSW(Mock Service Worker)在网络层拦截请求,更接近真实,且单测和 E2E 可复用同一套 mock。
import { setupServer } from 'msw/node';
import { http, HttpResponse } from 'msw';
const server = setupServer(
http.get('/api/users', () => {
return HttpResponse.json([{ id: 1, name: 'Alice' }]);
}),
http.post('/api/login', async ({ request }) => {
const body = await request.json();
if (body.password === 'wrong') {
return new HttpResponse(null, { status: 401 });
}
return HttpResponse.json({ token: 'abc' });
})
);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers()); // 每个测试后重置
afterAll(() => server.close());
it('登录成功拿到 token', async () => {
const res = await fetch('/api/login', {
method: 'POST',
body: JSON.stringify({ password: 'right' })
});
const data = await res.json();
expect(data.token).toBe('abc');
});5. spyOn 监视已有方法
import { vi } from 'vitest';
it('监视 console.error', () => {
const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
doSomethingThatLogsError();
expect(spy).toHaveBeenCalled();
spy.mockRestore(); // 恢复原方法
});十五、测试组件的 props、emit、slots
组件的对外接口就是 props、events、slots,这三者是组件测试的重点。
1. 测试 props
import { mount } from '@vue/test-utils';
it('根据 props 渲染标题', () => {
const wrapper = mount(Card, { props: { title: '标题', level: 2 } });
expect(wrapper.find('h2').text()).toBe('标题');
});
it('props 变化后视图更新', async () => {
const wrapper = mount(Badge, { props: { count: 1 } });
expect(wrapper.text()).toContain('1');
await wrapper.setProps({ count: 99 }); // 更新 props
expect(wrapper.text()).toContain('99');
});
it('props 校验:count 超过 99 显示 99+', () => {
const wrapper = mount(Badge, { props: { count: 200 } });
expect(wrapper.text()).toContain('99+');
});2. 测试 emit 事件
it('点击按钮触发 submit 事件', async () => {
const wrapper = mount(LoginForm);
await wrapper.find('input[name="user"]').setValue('alice');
await wrapper.find('input[name="pwd"]').setValue('123456');
await wrapper.find('form').trigger('submit');
// emitted() 返回所有触发过的事件
const submitEvents = wrapper.emitted('submit');
expect(submitEvents).toHaveLength(1);
// 检查事件携带的 payload
expect(submitEvents[0][0]).toEqual({ user: 'alice', pwd: '123456' });
});
it('输入为空时不触发 submit', async () => {
const wrapper = mount(LoginForm);
await wrapper.find('form').trigger('submit');
expect(wrapper.emitted('submit')).toBeUndefined();
});3. 测试 slots
it('渲染默认插槽内容', () => {
const wrapper = mount(Panel, {
slots: { default: '<p>正文内容</p>' }
});
expect(wrapper.html()).toContain('<p>正文内容</p>');
});
it('渲染具名插槽', () => {
const wrapper = mount(Panel, {
slots: {
header: '<h1>头部</h1>',
footer: '<div>底部</div>'
}
});
expect(wrapper.find('h1').text()).toBe('头部');
});
it('作用域插槽接收数据', () => {
const wrapper = mount(List, {
props: { items: [{ id: 1, name: 'A' }] },
slots: {
// 作用域插槽用函数接收 props
item: ({ item }) => `<span>${item.name}</span>`
}
});
expect(wrapper.text()).toContain('A');
});十六、测试 v-model 与表单
1. 测试原生表单元素
it('输入框双向绑定', async () => {
const wrapper = mount(SearchBox);
const input = wrapper.find('input');
await input.setValue('vue test');
expect(wrapper.vm.keyword).toBe('vue test');
});
it('复选框切换', async () => {
const wrapper = mount(Agreement);
const checkbox = wrapper.find('input[type="checkbox"]');
await checkbox.setValue(true);
expect(wrapper.find('button').attributes('disabled')).toBeUndefined();
});
it('下拉选择', async () => {
const wrapper = mount(CitySelect);
await wrapper.find('select').setValue('shanghai');
expect(wrapper.emitted('change')[0]).toEqual(['shanghai']);
});2. 测试自定义组件的 v-model
// 自定义组件通过 modelValue + update:modelValue 实现 v-model
it('自定义 v-model 组件', async () => {
const wrapper = mount(CustomInput, {
props: {
modelValue: 'init',
'onUpdate:modelValue': (e) => wrapper.setProps({ modelValue: e })
}
});
await wrapper.find('input').setValue('changed');
expect(wrapper.props('modelValue')).toBe('changed');
});3. 测试表单校验
it('邮箱格式错误显示提示', async () => {
const wrapper = mount(RegisterForm);
await wrapper.find('input[name="email"]').setValue('not-an-email');
await wrapper.find('input[name="email"]').trigger('blur');
expect(wrapper.find('.error-msg').text()).toContain('邮箱格式不正确');
});
it('校验全部通过才能提交', async () => {
const wrapper = mount(RegisterForm);
await wrapper.find('input[name="email"]').setValue('a@b.com');
await wrapper.find('input[name="pwd"]').setValue('12345678');
await wrapper.find('form').trigger('submit');
expect(wrapper.emitted('submit')).toBeTruthy();
});十七、测试 provide / inject
it('注入主题', () => {
const wrapper = mount(ThemedButton, {
global: {
provide: { theme: 'dark' } // 提供依赖注入
}
});
expect(wrapper.classes()).toContain('btn-dark');
});对于用 Symbol 作 key 的 provide,测试时需要导入同一个 Symbol:
import { THEME_KEY } from '@/keys';
const wrapper = mount(Comp, {
global: { provide: { [THEME_KEY]: 'light' } }
});十八、快照测试
快照测试记录组件渲染结果,之后每次运行都对比,防止意外的 UI 变化。
import { mount } from '@vue/test-utils';
import { expect, it } from 'vitest';
it('组件渲染结构快照', () => {
const wrapper = mount(PriceTag, { props: { amount: 99.9, currency: 'CNY' } });
expect(wrapper.html()).toMatchSnapshot();
});首次运行生成快照文件,后续渲染变化会导致测试失败并显示 diff。UI 确实改了就用 vitest -u 更新快照。
快照测试的取舍:
| 优点 | 缺点 |
|------|------|
| 写起来快,覆盖大范围 | 容易变脆(无关改动也失败) |
| 能发现意外结构变化 | 快照太大时难 review |
| 适合稳定的展示组件 | 不适合频繁变动的组件 |
建议: 快照要小而聚焦(用内联快照 toMatchInlineSnapshot 更好 review),不要对整个大页面打快照;核心逻辑仍应用显式断言,快照作为补充。
it('内联快照更易 review', () => {
const result = formatPrice(1234.5);
expect(result).toMatchInlineSnapshot('"¥1,234.50"');
});十九、测试 Teleport、Suspense 与异步组件
1. 测试 Teleport
Teleport 会把内容渲染到别处(如 body),断言时要注意查找范围。
import { mount } from '@vue/test-utils';
it('弹窗 Teleport 到 body', async () => {
const wrapper = mount(Modal, {
props: { visible: true },
attachTo: document.body
});
// Teleport 的内容在 document 里,不在 wrapper 内
expect(document.body.querySelector('.modal-content')).not.toBeNull();
wrapper.unmount();
});2. 测试异步组件与 Suspense
import { flushPromises, mount } from '@vue/test-utils';
it('异步组件加载完成后渲染', async () => {
const wrapper = mount(AsyncWrapper);
// 初始显示 fallback
expect(wrapper.text()).toContain('加载中');
await flushPromises(); // 等异步组件解析
expect(wrapper.text()).toContain('真实内容');
});二十、Testing Library 风格测试
@testing-library/vue 提倡"像用户一样测试"——通过可见文本、角色、标签查找元素,而非 class/id,测试更贴近真实使用,也更抗重构。
import { render, screen, fireEvent } from '@testing-library/vue';
import { it, expect } from 'vitest';
import Counter from '@/components/Counter.vue';
it('用户点击增加计数', async () => {
render(Counter);
// 通过可访问角色和名称查找,而非 CSS 选择器
const button = screen.getByRole('button', { name: /增加/ });
expect(screen.getByText('当前:0')).toBeInTheDocument();
await fireEvent.click(button);
expect(screen.getByText('当前:1')).toBeInTheDocument();
});
it('通过 label 查找输入框', async () => {
render(LoginForm);
const input = screen.getByLabelText('用户名');
await fireEvent.update(input, 'alice');
expect(input.value).toBe('alice');
});Vue Test Utils vs Testing Library:
| 维度 | Vue Test Utils | Testing Library |
|------|----------------|-----------------|
| 查找方式 | find(css) / vm 内部 | getByRole/Text/Label |
| 测试视角 | 偏组件实现 | 偏用户行为 |
| 抗重构性 | 较弱(依赖结构) | 强(依赖可见性) |
| 无障碍 | 不强调 | 天然引导 a11y |
| 学习曲线 | 平缓 | 需转变思路 |
两者可以共存:核心交互用 Testing Library 保证抗重构,需要访问组件内部状态时用 Vue Test Utils。
二十一、端到端测试深入(Playwright)
E2E 在真实浏览器里跑完整流程,是最接近用户的测试。
1. 一个完整的登录到下单流程
import { test, expect } from '@playwright/test';
test('用户登录并下单', async ({ page }) => {
await page.goto('/login');
// 用可访问性定位,稳定且贴近用户
await page.getByLabel('用户名').fill('testuser');
await page.getByLabel('密码').fill('password123');
await page.getByRole('button', { name: '登录' }).click();
// 断言跳转到首页
await expect(page).toHaveURL('/dashboard');
await expect(page.getByText('欢迎,testuser')).toBeVisible();
// 加入购物车
await page.goto('/products');
await page.getByRole('button', { name: '加入购物车' }).first().click();
await expect(page.getByTestId('cart-count')).toHaveText('1');
// 结算
await page.getByRole('link', { name: '购物车' }).click();
await page.getByRole('button', { name: '去结算' }).click();
await expect(page.getByText('订单提交成功')).toBeVisible();
});2. 网络拦截与 mock
test('接口失败时显示错误提示', async ({ page }) => {
// 拦截接口返回 500
await page.route('**/api/orders', (route) =>
route.fulfill({ status: 500, body: 'Server Error' })
);
await page.goto('/checkout');
await page.getByRole('button', { name: '提交订单' }).click();
await expect(page.getByText('提交失败,请重试')).toBeVisible();
});3. 等待策略(避免 flaky 测试)
// 反例:写死 sleep,慢了会失败、快了浪费时间
// await page.waitForTimeout(3000);
// 正例:等待具体条件
await expect(page.getByRole('list')).toBeVisible();
await page.waitForResponse((res) => res.url().includes('/api/data') && res.ok());
await page.waitForLoadState('networkidle');4. 视觉回归与多设备
test('首页视觉快照', async ({ page }) => {
await page.goto('/');
await expect(page).toHaveScreenshot('home.png', { maxDiffPixels: 100 });
});
// playwright.config.js 中配置多个设备 project
// projects: [{ name: 'chromium' }, { name: 'Mobile Safari', use: devices['iPhone 13'] }]二十二、测试与 CI 集成
测试的价值在于自动化执行、拦截问题。接入 CI 是必要一环。
# .github/workflows/test.yml
name: Test
on: [push, pull_request]
jobs:
unit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20, cache: 'npm' }
- run: npm ci
- run: npm run test:unit -- --coverage
- uses: codecov/codecov-action@v4 # 上传覆盖率
e2e:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20, cache: 'npm' }
- run: npm ci
- run: npx playwright install --with-deps
- run: npm run build && npm run test:e2e
- uses: actions/upload-artifact@v4
if: failure()
with: { name: playwright-report, path: playwright-report/ }CI 中的测试策略:
二十三、TDD:测试驱动开发实战
TDD 的节奏是"红-绿-重构":先写会失败的测试(红),写最少代码让它通过(绿),再优化代码(重构)。下面用一个"手机号脱敏"函数演示。
第一步:红(写失败的测试)
import { describe, it, expect } from 'vitest';
import { maskPhone } from '@/utils/maskPhone';
describe('maskPhone', () => {
it('中间四位替换为星号', () => {
expect(maskPhone('13812345678')).toBe('138****5678');
});
});
// 此时 maskPhone 还不存在,测试失败(红)第二步:绿(最少代码通过)
// utils/maskPhone.js
export function maskPhone(phone) {
return phone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2');
}
// 测试通过(绿)第三步:补充边界用例(回到红)
it('非法输入原样返回', () => {
expect(maskPhone('123')).toBe('123');
expect(maskPhone('')).toBe('');
});
it('处理 null/undefined 不报错', () => {
expect(maskPhone(null)).toBe('');
});第四步:完善实现(回到绿)
export function maskPhone(phone) {
if (!phone || typeof phone !== 'string') return '';
return phone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2');
}第五步:重构(保持绿)
代码已经简洁,无需重构。若逻辑复杂可在测试保护下放心重构。
TDD 的价值:
二十四、测试组织与命名规范
1. AAA 模式(Arrange-Act-Assert)
每个测试分三段:准备数据、执行动作、断言结果,结构清晰。
it('折扣计算正确', () => {
// Arrange 准备
const order = { total: 200, vipLevel: 2 };
// Act 执行
const result = calcDiscount(order);
// Assert 断言
expect(result).toBe(180);
});2. 描述性命名
// 反例:看不出测什么
it('test1', () => {});
it('works', () => {});
// 正例:描述"在什么条件下,应该有什么行为"
it('当用户未登录时,访问受保护页面应重定向到登录页', () => {});
it('当购物车为空时,结算按钮应被禁用', () => {});3. 用 describe 分组、beforeEach 复用
describe('购物车', () => {
let cart;
beforeEach(() => {
cart = createCart(); // 每个测试前重建,保证隔离
});
describe('添加商品', () => {
it('添加新商品数量为 1', () => { /* ... */ });
it('添加已有商品数量累加', () => { /* ... */ });
});
describe('删除商品', () => {
it('删除后商品消失', () => { /* ... */ });
});
});4. 常用断言速查
expect(value).toBe(3); // 严格相等(基本类型)
expect(obj).toEqual({ a: 1 }); // 深度相等(对象)
expect(arr).toContain(2); // 数组包含
expect(fn).toThrow('错误信息'); // 抛出异常
expect(value).toBeTruthy(); // 真值
expect(value).toBeNull(); // 为 null
expect(num).toBeGreaterThan(5); // 数值比较
expect(str).toMatch(/正则/); // 正则匹配
expect(mockFn).toHaveBeenCalledWith('x'); // 调用参数
await expect(promise).resolves.toBe(1); // Promise 成功
await expect(promise).rejects.toThrow(); // Promise 失败二十五、测试反模式与常见坑
1. 测试实现细节而非行为
// 反例:测内部方法名和内部状态,重构就挂
expect(wrapper.vm.handleClick).toBeDefined();
expect(wrapper.vm._internalCount).toBe(1);
// 正例:测用户可见的行为
await wrapper.find('button').trigger('click');
expect(wrapper.text()).toContain('已点击');2. 测试之间互相依赖
// 反例:test B 依赖 test A 留下的状态,顺序一变就失败
let sharedUser;
it('创建用户', () => { sharedUser = createUser(); });
it('修改用户', () => { sharedUser.name = 'new'; }); // 危险
// 正例:每个测试自给自足
it('修改用户', () => {
const user = createUser();
user.name = 'new';
expect(user.name).toBe('new');
});3. 没有断言的"假测试"
// 反例:只执行不断言,永远绿灯,毫无价值
it('渲染组件', () => {
mount(MyComponent); // 没有 expect
});4. 过度 mock 导致测了个寂寞
把所有依赖都 mock 掉,测试通过但真实场景全是 bug。mock 应只针对外部边界(网络、时间、随机数),核心逻辑要真实执行。
5. 忽略异步
// 反例:没等异步完成就断言
it('加载数据', () => {
const wrapper = mount(List);
expect(wrapper.text()).toContain('数据'); // 此时还没加载完,失败
});
// 正例:await flushPromises 或 nextTick
it('加载数据', async () => {
const wrapper = mount(List);
await flushPromises();
expect(wrapper.text()).toContain('数据');
});常见坑速查表:
| 坑 | 现象 | 解决 |
|----|------|------|
| 忘记 await | 断言在异步前跑 | await trigger/setValue/flushPromises |
| 状态泄漏 | 测试顺序影响结果 | beforeEach 重建、setActivePinia |
| 测实现细节 | 重构后大量测试失败 | 测行为、用可见断言 |
| 没重置 mock | mock 调用次数累加 | afterEach vi.clearAllMocks |
| DOM 不更新 | setProps 后取值是旧的 | await setProps |
| 定时器不触发 | 依赖真实时间 | vi.useFakeTimers + advanceTimersByTime |
二十六、测试自定义指令与插件
1. 测试自定义指令
import { mount } from '@vue/test-utils';
import { vPermission } from '@/directives/permission';
it('无权限时元素被移除', () => {
const wrapper = mount(
{ template: '<button v-permission="[\'admin\']">删除</button>' },
{
global: {
directives: { permission: vPermission },
provide: { permissions: ['user'] } // 无 admin 权限
}
}
);
expect(wrapper.find('button').exists()).toBe(false);
});2. 测试全局插件
it('插件注册全局属性', () => {
const wrapper = mount(Comp, {
global: {
plugins: [myPlugin],
mocks: { $t: (key) => key } // mock i18n 的 $t
}
});
expect(wrapper.text()).toContain('welcome');
});二十七、测试错误处理
1. 测试 onErrorCaptured 错误边界
it('子组件报错时错误边界捕获', async () => {
const Boom = { setup() { throw new Error('boom'); return () => null; } };
const wrapper = mount(ErrorBoundary, {
slots: { default: Boom }
});
await flushPromises();
expect(wrapper.text()).toContain('出错了');
});2. 测试异步错误
it('接口失败显示错误态', async () => {
global.fetch = vi.fn().mockRejectedValue(new Error('Network'));
const wrapper = mount(DataList);
await flushPromises();
expect(wrapper.find('.error').text()).toContain('加载失败');
});二十八、测试可访问性(a11y)
结合 axe-core 可以在测试中自动检查无障碍问题。
import { render } from '@testing-library/vue';
import { axe } from 'vitest-axe';
it('表单无障碍无违规', async () => {
const { container } = render(LoginForm);
const results = await axe(container);
expect(results).toHaveNoViolations(); // 检查 label、对比度、role 等
});二十九、基准测试(性能测试)
Vitest 内置 bench,可以测函数性能,防止性能回退。
import { bench, describe } from 'vitest';
describe('数组去重性能', () => {
const arr = Array.from({ length: 10000 }, (_, i) => i % 100);
bench('Set 去重', () => {
[...new Set(arr)];
});
bench('filter + indexOf 去重', () => {
arr.filter((x, i) => arr.indexOf(x) === i);
});
});
// 运行 vitest bench 会输出每种实现的 ops/sec 对比三十、测试策略与投入产出
不是所有代码都值得同样力度的测试。按价值和风险分配测试投入。
| 代码类型 | 测试力度 | 原因 |
|----------|----------|------|
| 工具函数/纯逻辑 | 高(接近 100%) | 便宜、稳定、复用广 |
| 核心业务逻辑 | 高(80%+) | 出错代价大 |
| composable/store | 高 | 逻辑集中、复用 |
| 通用组件 | 中高 | 影响面广 |
| 业务组件 | 中 | 变动频繁 |
| 纯展示 UI | 低 | 变动多、价值低 |
| 三方库封装 | 中 | 测边界即可 |
测试金字塔的比例参考: 单元测试约 70%,组件/集成测试约 20%,E2E 约 10%。底层多而快,顶层少而关键。倒金字塔(大量 E2E、少量单测)会导致测试又慢又脆。
三十一、常见问题 FAQ
Q:单元测试和集成测试怎么区分?
单元测试测单个函数/组件的孤立行为,依赖都 mock;集成测试测多个单元协作(如组件+store+路由),更接近真实。界限不必太较真,重点是覆盖有价值的场景。
Q:私有方法要测吗?
不要直接测私有方法。私有方法是实现细节,应通过公开接口间接覆盖。如果私有方法复杂到想单独测,说明它该被抽成独立的可测函数。
Q:测试要不要连数据库/真实接口?
单元和组件测试不要,用 mock/MSW。真实接口交给少量 E2E 或专门的契约测试,避免测试慢且不稳定。
Q:覆盖率要追求 100% 吗?
不必。100% 覆盖率成本高且有边际递减,还可能诱导写无意义的测试。核心逻辑高覆盖、整体 80% 左右是务实的目标。
Q:测试写不动怎么办?
如果一个组件/函数很难测,往往是设计问题(职责太多、耦合太重)。难测是重构的信号,把逻辑抽离、依赖注入后就好测了。
三十二、面试高频问答
Q1:前端为什么要写测试?
测试能在早期拦截 bug(越早修复成本越低)、支撑重构、充当活文档、提升代码设计质量。虽然写测试有成本,但长期看维护成本更低、迭代更稳。
Q2:测试金字塔是什么?
自底向上是单元测试、集成/组件测试、E2E 测试。越底层越多、越快、越便宜;越顶层越少、越慢、越真实。合理比例约 70/20/10,避免倒金字塔。
Q3:如何测试异步逻辑?
用 async/await 配合 flushPromises(等微任务)、nextTick(等 DOM 更新);定时器用 vi.useFakeTimers 配合 advanceTimersByTime;网络用 mock 或 MSW。
Q4:Vue Test Utils 和 Testing Library 有什么区别?
VTU 偏组件实现,可访问 vm 内部;Testing Library 偏用户行为,通过可见文本/角色查找,抗重构、引导无障碍。可结合使用。
Q5:什么是 mock?什么时候该 mock?
mock 是用可控的替身替换真实依赖。应对外部边界(网络、时间、随机、第三方)做 mock,核心业务逻辑要真实执行。过度 mock 会让测试失去意义。
Q6:如何避免 flaky(不稳定)测试?
不用固定 sleep 而用条件等待;保证测试相互隔离、每次重置状态;固定时间和随机数;避免依赖网络和真实时序。
三十三、Vitest 配置详解
一套合理的配置是高效测试的基础。
// vitest.config.js
import { defineConfig } from 'vitest/config';
import vue from '@vitejs/plugin-vue';
import { fileURLToPath } from 'node:url';
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
}
},
test: {
globals: true, // 无需 import describe/it/expect
environment: 'jsdom', // 提供 DOM 环境
setupFiles: ['./tests/setup.js'], // 全局初始化
css: true, // 处理组件里的 css import
clearMocks: true, // 每个测试自动清 mock
coverage: {
provider: 'v8',
reporter: ['text', 'html', 'lcov'],
thresholds: {
statements: 80,
branches: 75,
functions: 80,
lines: 80
}
}
}
});1. jsdom vs happy-dom
| 环境 | 特点 | 适用 |
|------|------|------|
| jsdom | 功能全、社区大、稍慢 | 需要完整 DOM API |
| happy-dom | 轻量、快约 2~3 倍 | 大多数组件测试 |
| node | 无 DOM | 纯逻辑/工具函数 |
可以按文件指定环境:
// @vitest-environment happy-dom
// 文件顶部注释指定该文件用 happy-dom2. 全局 setup 文件
// tests/setup.js
import { config } from '@vue/test-utils';
import { vi, afterEach } from 'vitest';
import '@testing-library/jest-dom'; // 扩展 DOM 断言
// 全局 mock 常用组件/指令
config.global.stubs = {
transition: false, // 让 transition 直接渲染
'router-link': true // stub 掉 router-link
};
// mock 浏览器 API(jsdom 没有的)
global.IntersectionObserver = vi.fn(() => ({
observe: vi.fn(),
unobserve: vi.fn(),
disconnect: vi.fn()
}));
global.matchMedia = vi.fn().mockImplementation((q) => ({
matches: false, media: q, addEventListener: vi.fn(), removeEventListener: vi.fn()
}));
afterEach(() => {
vi.clearAllMocks();
});jsdom 没有实现 IntersectionObserver、matchMedia、ResizeObserver、scrollTo 等 API,用到它们的组件必须在 setup 里 mock,否则测试会报错。
三十四、常用运行命令与调试
# 监听模式(开发时用,改文件自动重跑)
npx vitest
# 单次运行(CI 用)
npx vitest run
# 只跑某个文件
npx vitest run tests/cart.test.js
# 按名称过滤
npx vitest -t "购物车"
# 带覆盖率
npx vitest run --coverage
# 更新快照
npx vitest run -u
# UI 模式(浏览器里可视化查看)
npx vitest --ui
# 基准测试
npx vitest bench调试测试的技巧
import { mount } from '@vue/test-utils';
it('调试渲染结果', () => {
const wrapper = mount(MyComponent);
// 打印当前渲染的 HTML,快速看到实际结构
console.log(wrapper.html());
// Testing Library 的 screen.debug() 更友好
});it.only('只跑这个', () => {});
it.skip('暂时跳过', () => {});
it.todo('待补充:边界情况');三十五、真实组件测试案例:分页器
把前面的知识综合到一个真实组件的完整测试。
<!-- Pagination.vue -->
<script setup>
import { computed } from 'vue';
const props = defineProps({
total: { type: Number, required: true },
pageSize: { type: Number, default: 10 },
modelValue: { type: Number, default: 1 }
});
const emit = defineEmits(['update:modelValue']);
const totalPages = computed(() => Math.ceil(props.total / props.pageSize));
const canPrev = computed(() => props.modelValue > 1);
const canNext = computed(() => props.modelValue < totalPages.value);
function go(page) {
if (page < 1 || page > totalPages.value) return;
emit('update:modelValue', page);
}
</script>
<template>
<div class="pagination">
<button class="prev" :disabled="!canPrev" @click="go(modelValue - 1)">上一页</button>
<span class="info">{{ modelValue }} / {{ totalPages }}</span>
<button class="next" :disabled="!canNext" @click="go(modelValue + 1)">下一页</button>
</div>
</template>import { mount } from '@vue/test-utils';
import { describe, it, expect } from 'vitest';
import Pagination from '@/components/Pagination.vue';
describe('Pagination 分页器', () => {
it('根据 total 和 pageSize 计算总页数', () => {
const wrapper = mount(Pagination, { props: { total: 95, pageSize: 10 } });
expect(wrapper.find('.info').text()).toBe('1 / 10');
});
it('第一页时上一页按钮禁用', () => {
const wrapper = mount(Pagination, { props: { total: 100, modelValue: 1 } });
expect(wrapper.find('.prev').attributes('disabled')).toBeDefined();
expect(wrapper.find('.next').attributes('disabled')).toBeUndefined();
});
it('最后一页时下一页按钮禁用', () => {
const wrapper = mount(Pagination, { props: { total: 100, modelValue: 10 } });
expect(wrapper.find('.next').attributes('disabled')).toBeDefined();
});
it('点击下一页触发 update:modelValue', async () => {
const wrapper = mount(Pagination, { props: { total: 100, modelValue: 3 } });
await wrapper.find('.next').trigger('click');
expect(wrapper.emitted('update:modelValue')[0]).toEqual([4]);
});
it('点击上一页触发正确页码', async () => {
const wrapper = mount(Pagination, { props: { total: 100, modelValue: 3 } });
await wrapper.find('.prev').trigger('click');
expect(wrapper.emitted('update:modelValue')[0]).toEqual([2]);
});
it('禁用状态下点击不触发事件', async () => {
const wrapper = mount(Pagination, { props: { total: 100, modelValue: 1 } });
await wrapper.find('.prev').trigger('click');
expect(wrapper.emitted('update:modelValue')).toBeUndefined();
});
it('边界:total 为 0 时总页数为 0', () => {
const wrapper = mount(Pagination, { props: { total: 0 } });
expect(wrapper.find('.info').text()).toBe('1 / 0');
});
});这个案例覆盖了 props 计算、条件禁用、事件触发、边界值等,是一个"测行为而非实现"的良好示范:我们从不检查组件内部的 canPrev 变量,而是检查用户可见的 disabled 属性和触发的事件。
三十六、测试国际化与主题等横切能力
1. 测试 i18n
import { createI18n } from 'vue-i18n';
import { mount } from '@vue/test-utils';
const i18n = createI18n({
legacy: false,
locale: 'zh',
messages: {
zh: { greeting: '你好' },
en: { greeting: 'Hello' }
}
});
it('根据语言渲染文案', async () => {
const wrapper = mount(Greeting, { global: { plugins: [i18n] } });
expect(wrapper.text()).toContain('你好');
i18n.global.locale.value = 'en';
await wrapper.vm.$nextTick();
expect(wrapper.text()).toContain('Hello');
});2. 测试 keep-alive 组件的 activated/deactivated
import { mount } from '@vue/test-utils';
it('组件被激活时刷新数据', async () => {
const refreshSpy = vi.fn();
const Comp = {
setup() {
onActivated(refreshSpy);
return () => h('div', 'content');
}
};
// 用一个可切换的宿主包裹 keep-alive
const Host = {
components: { Comp },
data: () => ({ show: true }),
template: '<keep-alive><Comp v-if="show" /></keep-alive>'
};
const wrapper = mount(Host);
expect(refreshSpy).toHaveBeenCalledTimes(1);
});三十七、快照文件管理与变异测试
1. 快照文件组织
快照默认生成在测试文件同级的 __snapshots__ 目录,应纳入版本控制,让团队共享同一基线。review 快照变更时要像 review 代码一样确认变化是否符合预期,切忌无脑 vitest -u。
tests/
Pagination.test.js
__snapshots__/
Pagination.test.js.snap # 提交到 git2. 变异测试(Mutation Testing)
覆盖率只能说明代码"跑过",变异测试则检验测试"是否真的能发现 bug"。工具(如 Stryker)会故意在源码里制造小变异(如把 > 改成 >=、删掉一行),然后跑测试,若测试仍全绿说明这块测试无效。
# 用 Stryker 对 Vue 项目做变异测试
npx stryker run
# 输出 mutation score:被测试"杀死"的变异比例,越高说明测试越有效| 指标 | 衡量 | 局限 |
|------|------|------|
| 覆盖率 | 代码是否被执行 | 不保证断言有效 |
| 变异分数 | 测试能否发现改动 | 运行慢、成本高 |
变异测试成本较高,通常只对核心模块跑,作为覆盖率的补充,用来发现"看着覆盖了其实没断言到位"的测试盲区。
三十八、契约测试与集成边界
前后端分离项目中,前端 mock 的接口格式可能与后端真实返回不一致,导致"测试全绿但联调全红"。契约测试用来约束这条边界。
// 用一份 schema 同时约束 mock 和真实响应
import { z } from 'zod';
const UserSchema = z.object({
id: z.number(),
name: z.string(),
email: z.string().email(),
createdAt: z.string()
});
it('接口返回符合契约', async () => {
const res = await fetch('/api/user/1').then((r) => r.json());
// 校验真实响应结构,字段缺失或类型错会失败
expect(() => UserSchema.parse(res)).not.toThrow();
});实践建议: 把响应 schema 抽成前后端共享的单一来源,mock 数据也用它生成,从根本上杜绝 mock 与真实脱节。这样单测里的 mock 永远和契约一致,联调时的惊吓大幅减少。
| 测试类型 | 守护的边界 | 何时跑 |
|----------|-----------|--------|
| 单元测试 | 函数内部逻辑 | 每次改动 |
| 组件测试 | 组件对外接口 | 每次改动 |
| 契约测试 | 前后端数据格式 | 接口变更时 |
| E2E 测试 | 完整用户流程 | PR / 发布前 |
三十九、测试思维总结
写好测试的心法可以浓缩为几条:
把这些原则落到日常,测试就会从"额外负担"变成"加速器":改代码时有测试兜底,重构时有测试护航,联调时有契约把关,发布时有 E2E 守门。一个测试健全的项目,迭代速度反而更快,因为每一次改动的信心都来自绿色的测试,而不是提心吊胆的手动点点点。
最后给出一个务实的落地路线:
按这个顺序推进,团队能用最小的前期投入建立起测试文化,之后再逐步向上补齐,避免一上来就追求大而全导致半途而废。
记住:测试的终极目标不是覆盖率数字,也不是测试用例的数量,而是"让每一次交付都值得信任"。
当你能在周五下午放心地合并一个改动并部署上线,而不必担心周末被叫起来救火时,你的测试就真正发挥了价值。
这份从容,正是所有测试投入最好的回报。
总结
测试不是负担,而是让你能"放心大胆改代码"的底气。合理的测试策略应遵循金字塔原则:大量快速的单元测试打底,适量组件测试验证交互,少量 E2E 测试守住关键流程。工具上,Vitest 做运行器,Vue Test Utils 或 Testing Library 做组件测试,Playwright/Cypress 做端到端。
| 层级 | 工具 | 测什么 | 特点 |
|------|------|--------|------|
| 单元测试 | Vitest | 纯函数、composables、store | 快、多、便宜 |
| 组件测试 | Vue Test Utils / Testing Library | 组件渲染与交互 | 贴近用户体验 |
| E2E 测试 | Playwright / Cypress | 完整业务流程 | 真实但慢 |
| 覆盖率 | Vitest coverage | 度量测试完整度 | 参考而非目标 |
核心原则一句话:测试行为而非实现,保持独立与简洁,把 Bug 拦在越早越好的阶段。