前言
很多 Vue 项目都有一个共同点:
✅ 功能能跑
❌ 一改就崩
❌ 不敢重构
❌ 上线提心吊胆
问题不在于代码写得不好,而在于:
缺少自动化测试。
在 Vue3 生态中,测试体系已经非常成熟:
| 测试类型 | 工具 | 作用 |
|---|---|---|
| 单元测试 | Vitest | 测函数、Composable |
| 组件测试 | Vue Test Utils + Vitest | 测组件行为 |
| E2E 测试 | Cypress | 测用户完整操作流程 |
| 覆盖率 | c8 / Istanbul | 评估测试完整性 |
这一篇,我们从零搭建一个企业级 Vue3 测试方案。
一、为什么需要测试?
1️⃣ 没有测试的代价
-
😰 修复一个 Bug,引入三个新 Bug
-
😰 重构代码像拆炸弹
-
😰 回归测试全靠人工点点点
-
😰 上线前集体加班
2️⃣ 测试金字塔
/\
/ \ E2E 测试(少量)
/----\
/ \ 集成测试(适量)
/--------\
/ \ 单元测试(大量)
/------------\
📌 原则:
-
单元测试:快、多、便宜
-
E2E 测试:慢、少、贵
-
越往上,越接近真实用户
二、Vitest:Vue3 的官方测试伴侣
1️⃣ 为什么选 Vitest?
| 对比项 | Jest | Vitest |
|---|---|---|
| 速度 | 较慢 | ✅ 极快(ESM 原生) |
| Vite 集成 | 需配置 | ✅ 开箱即用 |
| Vue 支持 | 需插件 | ✅ 原生支持 |
| 热更新 | ❌ | ✅ |
| 并发执行 | ❌ | ✅ |
📌 Vitest 是为 Vite 量身定制的测试框架。
2️⃣ 安装与配置
pnpm add -D vitest @vue/test-utils jsdom @testing-library/vue
// vitest.config.ts
import { defineConfig } from 'vitest/config'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./tests/setup.ts']
}
})
// package.json
{
"scripts": {
"test": "vitest",
"test:coverage": "vitest run --coverage"
}
}
3️⃣ 测试文件约定
tests/
├── unit/ # 单元测试
│ └── useCounter.test.ts
├── components/ # 组件测试
│ └── Counter.spec.vue
├── e2e/ # E2E 测试
│ └── auth.cy.ts
└── setup.ts # 测试环境配置
三、单元测试:Composable 测试
1️⃣ 测试 useCounter
// composables/useCounter.ts
import { ref } from 'vue'
export function useCounter(initial = 0) {
const count = ref(initial)
const inc = () => count.value++
const dec = () => count.value--
return { count, inc, dec }
}
// tests/unit/useCounter.test.ts
import { describe, it, expect } from 'vitest'
import { useCounter } from '@/composables/useCounter'
describe('useCounter', () => {
it('should initialize with 0', () => {
const { count } = useCounter()
expect(count.value).toBe(0)
})
it('should increment count', () => {
const { count, inc } = useCounter()
inc()
expect(count.value).toBe(1)
})
it('should decrement count', () => {
const { count, dec } = useCounter(5)
dec()
expect(count.value).toBe(4)
})
})
2️⃣ 测试异步逻辑
// composables/useFetch.ts
import { ref } from 'vue'
export function useFetch<T>(url: string) {
const data = ref<T | null>(null)
const loading = ref(false)
const fetchData = async () => {
loading.value = true
const res = await fetch(url)
data.value = await res.json()
loading.value = false
}
return { data, loading, fetchData }
}
// tests/unit/useFetch.test.ts
import { describe, it, expect, vi } from 'vitest'
import { useFetch } from '@/composables/useFetch'
// Mock fetch
global.fetch = vi.fn()
describe('useFetch', () => {
it('should fetch data successfully', async () => {
const mockData = { id: 1, name: 'Tom' }
;(fetch as any).mockResolvedValueOnce({
json: async () => mockData
})
const { data, loading, fetchData } = useFetch('/api/user')
await fetchData()
expect(loading.value).toBe(false)
expect(data.value).toEqual(mockData)
})
})
四、组件测试:Vue Test Utils
1️⃣ 测试一个计数器组件
<!-- components/Counter.vue -->
<template>
<div>
<button @click="dec">-</button>
<span>{{ count }}</span>
<button @click="inc">+</button>
</div>
</template>
<script setup lang="ts">
import { useCounter } from '@/composables/useCounter'
const { count, inc, dec } = useCounter()
</script>
// tests/components/Counter.spec.ts
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import Counter from '@/components/Counter.vue'
describe('Counter.vue', () => {
it('renders initial count', () => {
const wrapper = mount(Counter)
expect(wrapper.text()).toContain('0')
})
it('increments when plus button clicked', async () => {
const wrapper = mount(Counter)
await wrapper.findAll('button')[1].trigger('click')
expect(wrapper.text()).toContain('1')
})
it('decrements when minus button clicked', async () => {
const wrapper = mount(Counter)
await wrapper.findAll('button')[0].trigger('click')
expect(wrapper.text()).toContain('-1')
})
})
2️⃣ 测试 Props 和 Emits
<!-- components/UserCard.vue -->
<template>
<div @click="handleClick">
{{ user.name }}
</div>
</template>
<script setup lang="ts">
defineProps<{ user: { name: string } }>()
const emit = defineEmits<{ (e: 'select', name: string): void }>()
const handleClick = () => emit('select', 'Tom')
</script>
// tests/components/UserCard.spec.ts
import { mount } from '@vue/test-utils'
import UserCard from '@/components/UserCard.vue'
it('emits select event when clicked', async () => {
const wrapper = mount(UserCard, {
props: {
user: { name: 'Tom' }
}
})
await wrapper.trigger('click')
expect(wrapper.emitted('select')?.[0]).toEqual(['Tom'])
})
五、Pinia 测试
1️⃣ 测试 Store
// stores/user.ts
import { defineStore } from 'pinia'
export const useUserStore = defineStore('user', {
state: () => ({
user: null as { name: string } | null
}),
actions: {
setUser(user: { name: string }) {
this.user = user
}
}
})
// tests/stores/user.test.ts
import { setActivePinia, createPinia } from 'pinia'
import { useUserStore } from '@/stores/user'
describe('User Store', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
it('sets user correctly', () => {
const store = useUserStore()
store.setUser({ name: 'Tom' })
expect(store.user?.name).toBe('Tom')
})
})
2️⃣ 组件中使用 Store 的测试
// tests/components/UserProfile.spec.ts
import { mount } from '@vue/test-utils'
import { createPinia } from 'pinia'
import UserProfile from '@/components/UserProfile.vue'
import { useUserStore } from '@/stores/user'
it('displays user name from store', () => {
const pinia = createPinia()
const wrapper = mount(UserProfile, {
global: {
plugins: [pinia]
}
})
const store = useUserStore()
store.setUser({ name: 'Tom' })
expect(wrapper.text()).toContain('Tom')
})
六、E2E 测试:Cypress
1️⃣ 安装与配置
pnpm add -D cypress
// package.json
{
"scripts": {
"cy:open": "cypress open",
"cy:run": "cypress run"
}
}
2️⃣ 登录流程测试
// cypress/e2e/auth.cy.ts
describe('Authentication', () => {
it('should login successfully', () => {
cy.visit('/login')
cy.get('[data-testid=username]').type('admin')
cy.get('[data-testid=password]').type('123456')
cy.get('[data-testid=submit]').click()
cy.url().should('include', '/dashboard')
cy.contains('Welcome, admin').should('be.visible')
})
it('should show error on invalid credentials', () => {
cy.visit('/login')
cy.get('[data-testid=submit]').click()
cy.contains('用户名或密码错误').should('be.visible')
})
})
3️⃣ 测试数据属性(推荐 ✅)
<input data-testid="username" />
<button data-testid="submit" />
📌 比 class / id 更稳定
七、测试覆盖率
1️⃣ 生成覆盖率报告
pnpm test:coverage
2️⃣ 覆盖率配置
// vitest.config.ts
export default defineConfig({
test: {
coverage: {
reporter: ['text', 'json', 'html'],
exclude: [
'node_modules/',
'tests/',
'**/*.spec.ts',
'**/*.test.ts'
]
}
}
})
八、测试最佳实践
| 实践 | 说明 |
|---|---|
| AAA 模式 | Arrange(准备)→ Act(执行)→ Assert(断言) |
| 测试行为,不是实现 | 关注输入输出,不关注内部逻辑 |
| 避免测试私有方法 | 通过公有 API 间接测试 |
| 使用 data-testid | 提高选择器稳定性 |
| 测试隔离 | 每个测试独立,不依赖其他测试 |
| 模拟外部依赖 | API、定时器、WebSocket |
九、常见误区
| 误区 | 真相 |
|---|---|
| 测试覆盖率 100% | 成本高,收益递减 |
| 测试越多越好 | 维护成本会上升 |
| E2E 测试替代单元测试 | 两者互补,不可替代 |
| 测试拖慢开发 | 长期来看节省调试时间 |
十、面试高频问答
Q1:单元测试和 E2E 测试的区别?
单元测试测函数/组件,E2E 测完整用户流程。
Q2:为什么 Vue3 推荐 Vitest?
因为 Vite 原生支持,速度更快,配置更简单。
Q3:如何测试 Vue 组件?
使用 Vue Test Utils 挂载组件,模拟用户交互,断言结果。
十一、总结(工程级)
-
测试是项目质量的护城河
-
Vitest 是 Vue3 单元测试的首选
-
组件测试关注行为和交互
-
E2E 测试保障核心业务流程
-
测试不是为了找 Bug,而是为了防止 Bug
📢 下期预告
👉 第 30 篇:Vue3 生态全景图及未来趋势(SSR/Nuxt)