Vue 测试完全指南
中等 🟡Vue 生态
4 个标签
预计阅读时间:24 分钟
Vue测试VitestVue Test Utils
Vue 测试完全指南
测试是保证代码质量的重要手段。本文详细介绍 Vue 3 应用的测试方法,包括单元测试、组件测试和端到端测试,使用 Vitest 和 Vue Test Utils 等现代测试工具。
一、测试基础
1. 测试工具介绍
Vitest:
•Vite 原生的测试框架
•兼容 Jest API
•极速的测试运行速度
•内置覆盖率报告
Vue Test Utils:
•Vue 官方组件测试库
•支持 Vue 3
•提供丰富的组件测试 API
Testing Library:
•用户中心的测试理念
•鼓励测试行为而非实现
•更好的可维护性
2. 安装配置
安装 Vitest:
bashCode
npm install -D vitest @vitejs/plugin-vue安装 Vue Test Utils:
bashCode
npm install -D @vue/test-utils jsdom安装 Testing Library:
bashCode
npm install -D @testing-library/vue @testing-library/jest-domVite 配置:
javascriptCode
// 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']
}
}
});测试脚本:
jsonCode
{
"scripts": {
"test": "vitest",
"test:ui": "vitest --ui",
"test:coverage": "vitest --coverage"
}
}二、单元测试
1. 测试 Composables
javascriptCode
// 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 };
}测试代码:
javascriptCode
// 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);
});
});2. 测试工具函数
javascriptCode
// 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;
}测试代码:
javascriptCode
// 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');
});
});3. 测试 Pinia Store
javascriptCode
// 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;
}
}
});测试代码:
javascriptCode
// 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);
});
});三、组件测试
1. 使用 Vue Test Utils
基础组件:
vueCode
<!-- 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>测试代码:
javascriptCode
// 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();
});
});2. 测试带 Props 的组件
vueCode
<!-- 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>测试代码:
javascriptCode
// 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]);
});
});3. 测试带异步操作的组件
vueCode
<!-- 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>测试代码:
javascriptCode
// 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('错误:网络错误');
});
});四、使用 Testing Library
1. 基础用法
javascriptCode
// 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. 查询方法
javascriptCode
import { render, screen } from '@testing-library/vue';
// 常用查询方法
const {
getByRole, // 按角色查询
getByText, // 按文本查询
getByLabelText, // 按标签查询
getByPlaceholderText, // 按占位符查询
getByTestId // 按测试 ID 查询
} = render(Component);
// 变体
screen.getBy...; // 找不到时抛出错误
screen.queryBy...; // 找不到时返回 null
screen.findBy...; // 异步查询
screen.getAllBy...; // 返回所有匹配五、测试最佳实践
1.测试行为而非实现:关注组件做什么,而不是怎么做
2.使用有意义的测试名称:描述测试的目的
3.保持测试独立:每个测试应该独立运行
4.使用 beforeEach 清理:确保测试环境干净
5.Mock 外部依赖:隔离测试单元
6.测试边界条件:空值、错误状态等
7.保持测试简洁:一个测试只验证一件事
六、测试覆盖率
生成覆盖率报告:
bashCode
npm run test:coverage配置覆盖率阈值:
javascriptCode
// vite.config.js
export default defineConfig({
test: {
coverage: {
reporter: ['text', 'json', 'html'],
thresholds: {
lines: 80,
functions: 80,
branches: 80,
statements: 80
}
}
}
});