测试是 Vue 应用质量保障的核心环节。Vue 生态提供了 Vue Test Utils(官方组件测试库)与 Vitest(Vite 原生测试框架)的无缝集成,同时辅以 Pinia、MSW、Playwright 等工具链,可构建从单元测试到端到端测试的完整金字塔体系。本文将系统讲解环境配置、组件测试、状态管理 Mock、API 拦截、E2E 自动化及 CI 集成的完整实践路径,并补充依赖 Stub、路由与 Teleport 测试、Composable 单元测试、覆盖率策略与视觉回归测试等进阶内容,帮助团队落地高置信度的测试流程。
一、测试环境配置
搭建 Vue 测试环境需要同时覆盖组件运行时、DOM 模拟和断言工具。推荐以 Vitest 作为测试引擎,jsdom 或 happy-dom 提供浏览器环境模拟,@vue/test-utils 负责组件挂载与交互触发,@testing-library/jest-dom 则扩展了 DOM 相关的语义化断言。
npm install -D vitest @vue/test-utils jsdom happy-dom \
@testing-library/jest-dom msw @playwright/test
在 vitest.config.ts 中,通过 @vitejs/plugin-vue 解析单文件组件,并指定 globals 为 true 以便直接使用 describe、it、expect 等全局 API,免去每个测试文件的重复导入。
// 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'],
include: ['src/**/*.spec.ts', 'tests/**/*.spec.ts'],
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
exclude: ['node_modules/', 'tests/'],
},
},
})
setup.ts 负责全局初始化,例如扩展 jest-dom 断言、配置 MSW 服务器、设置 Pinia 激活等。将环境初始化集中在此文件,可避免每个测试套件重复编写样板代码。
// tests/setup.ts
import '@testing-library/jest-dom'
import { config } from '@vue/test-utils'
// 全局组件注册
config.global.components = { BaseButton: () => import('@/components/BaseButton.vue') }
二、组件测试:Vue Test Utils
Vue Test Utils 的 mount 函数会将组件渲染为真实或虚拟的 DOM 树,返回的 wrapper 对象封装了查询、触发、属性访问等能力。合理的测试应当聚焦输入(Props、Slots、注入依赖)与输出(DOM 渲染、事件发射、副作用调用)之间的映射关系,避免过度测试内部实现细节。
2.1 基础挂载与查询
mount 接收第二个配置对象,可传入 props、slots、全局 provide 等数据,模拟组件在实际运行时的外部依赖。
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import Counter from './Counter.vue'
describe('Counter', () => {
it('renders initial count', () => {
const wrapper = mount(Counter, {
props: { initial: 10 },
})
expect(wrapper.text()).toContain('10')
})
it('increments when clicked', async () => {
const wrapper = mount(Counter)
await wrapper.find('button').trigger('click')
expect(wrapper.text()).toContain('1')
})
it('renders correctly with snapshot', () => {
const wrapper = mount(Counter, { props: { initial: 5 } })
expect(wrapper.element).toMatchSnapshot()
})
})
2.2 Props 与 Emits 测试
Emits 是组件向父级通信的核心机制。测试时应验证事件是否被正确触发,以及 payload 是否符合预期。对于支持 v-model 的组件,update:modelValue 事件的测试尤为关键。
import { mount } from '@vue/test-utils'
import InputComponent from './InputComponent.vue'
describe('InputComponent', () => {
it('emits update event on input', async () => {
const wrapper = mount(InputComponent, {
props: { modelValue: '' },
})
await wrapper.find('input').setValue('hello')
expect(wrapper.emitted('update:modelValue')).toBeTruthy()
expect(wrapper.emitted('update:modelValue')![0]).toEqual(['hello'])
})
it('emits custom event with payload', async () => {
const wrapper = mount(InputComponent)
await wrapper.find('input').trigger('blur')
expect(wrapper.emitted('blur')).toHaveLength(1)
})
})
2.3 Slots 测试
Slots 测试确保具名插槽和默认插槽的内容被正确渲染。可以通过字符串模板或渲染函数传入插槽内容,并通过 wrapper.html() 或 find 断言目标元素是否存在。
it('renders default and named slots', () => {
const wrapper = mount(Modal, {
slots: {
default: '<p data-testid="body">Modal content</p>',
footer: '<button data-testid="ok">OK</button>',
},
})
expect(wrapper.find('[data-testid="body"]').exists()).toBe(true)
expect(wrapper.find('[data-testid="ok"]').exists()).toBe(true)
})
2.4 异步组件测试
当组件内部依赖 watch、生命周期钩子或异步 API 时,Vue 的响应式更新与 Promise 解析存在时序差异。flushPromises 可等待组件内所有微任务完成,nextTick 则确保 DOM 已同步更新。
import { mount, flushPromises } from '@vue/test-utils'
import UserProfile from './UserProfile.vue'
describe('UserProfile', () => {
it('loads async data and renders user name', async () => {
const wrapper = mount(UserProfile)
await flushPromises()
expect(wrapper.text()).toContain('Alice')
})
it('shows loading state then data', async () => {
const wrapper = mount(UserProfile)
expect(wrapper.text()).toContain('Loading')
await flushPromises()
expect(wrapper.text()).toContain('Alice')
expect(wrapper.text()).not.toContain('Loading')
})
})
三、Pinia Store Mocking
Pinia 作为 Vue 官方推荐的状态管理方案,其基于函数的 Store 设计天然便于测试隔离。每个测试用例前调用 setActivePinia(createPinia()) 可保证 Store 独立、状态互不污染。对于跨 Store 联动场景,可以在 createPinia 时预先注入 mock 的依赖 Store。
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useUserStore } from '@/stores/user'
import { useCartStore } from '@/stores/cart'
describe('UserStore', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
it('updates user state after login', () => {
const store = useUserStore()
store.login({ name: 'Alice', id: '1' })
expect(store.isLoggedIn).toBe(true)
expect(store.user?.name).toBe('Alice')
})
it('clears user state after logout', () => {
const store = useUserStore()
store.login({ name: 'Alice', id: '1' })
store.logout()
expect(store.isLoggedIn).toBe(false)
expect(store.user).toBeNull()
})
it('interacts with another store', () => {
const user = useUserStore()
const cart = useCartStore()
user.login({ name: 'Alice', id: '1' })
cart.addItem({ id: 101, name: 'Book' })
expect(cart.items).toHaveLength(1)
})
})
若需部分覆盖 Store 的行为,可在初始化时结合 vi.spyOn 或 actions 的 mock 实现,从而精确控制待测路径的外部依赖。
四、MSW:API Mocking
Modern Service Worker(MSW)允许在测试线程中拦截 HTTP 请求,相比 mock axios 或 fetch 实现,MSW 更贴近真实网络层行为,且支持浏览器与服务端双端复用。单元测试中通常使用 setupServer,E2E 测试中则可使用 setupWorker。
// tests/mocks/handlers.ts
import { http, HttpResponse } from 'msw'
export const handlers = [
http.get('/api/user', () => {
return HttpResponse.json({ id: '1', name: 'Alice', email: 'alice@example.com' })
}),
http.post('/api/login', async ({ request }) => {
const body = await request.json() as { email: string; password: string }
if (body.password === 'correct') {
return HttpResponse.json({ token: 'abc123' })
}
return HttpResponse.json({ error: 'Unauthorized' }, { status: 401 })
}),
]
// tests/setup.ts
import { setupServer } from 'msw/node'
import { handlers } from './mocks/handlers'
const server = setupServer(...handlers)
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
afterEach(() => server.resetHandlers())
afterAll(() => server.close())
通过 onUnhandledRequest: ’error’ 配置,任何未被 handlers 捕获的请求将直接抛出异常,有效防止测试遗漏 API 依赖。resetHandlers 保证每个测试的隔离性,避免前一个用例的临时覆盖影响后续测试。
五、Playwright E2E
端到端测试验证应用在真实浏览器中的完整用户流程。Playwright 以其自动等待、多浏览器并行执行和 Trace Viewer 调试器著称。编写 E2E 测试时,应以用户视角描述行为,优先使用角色选择器或测试 ID,避免依赖易变的 CSS 类名。
import { test, expect } from '@playwright/test'
test.describe('Authentication', () => {
test('user login flow redirects to dashboard', async ({ page }) => {
await page.goto('/login')
await page.fill('[name="email"]', 'test@example.com')
await page.fill('[name="password"]', 'password')
await page.click('button[type="submit"]')
await expect(page).toHaveURL('/dashboard')
await expect(page.locator('h1')).toContainText('Dashboard')
})
test('invalid credentials show error', async ({ page }) => {
await page.goto('/login')
await page.fill('[name="email"]', 'test@example.com')
await page.fill('[name="password"]', 'wrong')
await page.click('button[type="submit"]')
await expect(page.locator('[role="alert"]')).toContainText('Invalid credentials')
})
test('authenticated user can access profile', async ({ page }) => {
await page.goto('/login')
await page.fill('[name="email"]', 'test@example.com')
await page.fill('[name="password"]', 'password')
await page.click('button[type="submit"]')
await page.goto('/profile')
await expect(page.locator('text=Profile')).toBeVisible()
})
})
利用 test.beforeEach 可抽取公共登录逻辑,利用 storageState 可在不同测试文件间共享已登录状态,从而显著缩短 E2E 执行时间。
六、CI 集成
将测试流程嵌入 CI 是防止回归的最后一道闸门。GitHub Actions 中应采用矩阵策略并行运行单元测试与 E2E 测试,利用服务容器部署测试数据库或后端服务,并为 Playwright 提供预装浏览器依赖的运行环境。
# .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: actions/upload-artifact@v4
with:
name: coverage-report
path: coverage/
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 test:e2e
- uses: actions/upload-artifact@v4
if: failure()
with:
name: playwright-report
path: playwright-report/
单元测试失败时立即阻断合并,E2E 失败时则生成 Playwright 报告供人工审查。 upload-artifact 允许开发者在本地通过 npx playwright show-report 复现失败场景。
七、shallowMount 策略与依赖组件 Stub
shallowMount 是 Vue Test Utils 提供的浅渲染函数,它会自动将子孙组件替换为 stub,仅保证自身模板和逻辑的可见性。使用时机为:待测组件高度依赖大型子组件的 props 与事件,而子组件的独立测试已在别处覆盖时,可通过 shallowMount 降低测试噪音、提升运行速度。
import { mount, shallowMount } from '@vue/test-utils'
import PageContainer from './PageContainer.vue'
import HeavyChart from './HeavyChart.vue'
describe('PageContainer', () => {
it('shallow renders without heavy chart logic', () => {
const wrapper = shallowMount(PageContainer)
expect(wrapper.findComponent(HeavyChart).exists()).toBe(true)
// HeavyChart 内部的复杂生命周期不会执行
})
it('full mounts with chart integration', () => {
const wrapper = mount(PageContainer)
expect(wrapper.findComponent(HeavyChart).exists()).toBe(true)
})
})
当需要更精细地控制 Stub 行为时,可在 mount 的 global.stubs 中自定义替代组件,用以验证特定 props 的传递和事件的接收。
const wrapper = mount(PageContainer, {
global: {
stubs: {
RouterLink: {
props: ['to'],
template: '<a :href="to"><slot /></a>',
},
HeavyChart: {
emits: ['zoom'],
template: '<div data-testid="chart-stub" @click="$emit(\'zoom\', 2)">Chart</div>',
},
},
},
})
Stub 策略的取舍应当遵循:对普遍使用的第三方库(如 vue-router 的 RouterLink、nuxt 的 NuxtLink)进行全局 stub,对业务子组件根据测试粒度需求选择 shallowMount 或局部 stub。避免盲目全量 mount 导致的无关渲染拖慢测试套件。
八、Vue Router / Teleport / Suspense 测试技巧
现代 Vue 应用大量使用 Vue Router 进行页面导航、Teleport 进行 DOM 挂载、Suspense 进行异步依赖等待。这三者在测试中都有特殊处理要点。
8.1 Vue Router 测试
测试依赖路由的组件时,应创建内存路由实例并注入到全局配置中,避免浏览器地址栏变更带来的副作用。同时可通过 push 方法模拟导航,再通过 flushPromises 等待路由解析完成。
import { createRouter, createMemoryHistory } from 'vue-router'
import { mount, flushPromises } from '@vue/test-utils'
import Dashboard from './Dashboard.vue'
const routes = [
{ path: '/', component: { template: '<div>Home</div>' } },
{ path: '/dashboard', component: Dashboard },
]
const router = createRouter({
history: createMemoryHistory(),
routes,
})
describe('Dashboard', () => {
beforeEach(async () => {
router.push('/')
await router.isReady()
})
it('renders after routing to /dashboard', async () => {
const wrapper = mount(Dashboard, {
global: { plugins: [router] },
})
await router.push('/dashboard')
await flushPromises()
expect(wrapper.text()).toContain('Dashboard')
})
})
8.2 Teleport 测试
Teleport 将内容挂载到 body 或其他指定目标,导致内容不在 wrapper.element 内部。测试中需通过 document.body 进行查询,或在挂载时指定 attachTo 选项以允许测试后自动清理。
import { mount } from '@vue/test-utils'
import ModalWithTeleport from './ModalWithTeleport.vue'
describe('ModalWithTeleport', () => {
it('teleports content to body', async () => {
const wrapper = mount(ModalWithTeleport, {
attachTo: document.body,
props: { open: true },
})
expect(document.body.querySelector('[data-testid="modal-backdrop"]')).not.toBeNull()
expect(wrapper.find('[data-testid="modal-backdrop"]').exists()).toBe(false)
wrapper.unmount()
})
})
务必在测试末尾调用 wrapper.unmount() 或 cleanup,否则残留的 teleport 元素会污染后续测试的 DOM 环境。
8.3 Suspense 测试
Suspense 常用于异步组件或 setup 中的顶层 await。测试中可通过 await mount(…) 直接等待异步依赖解析,或者使用 Suspense 包装器进行更精细的控制。
import { defineAsyncComponent, Suspense } from 'vue'
import { mount } from '@vue/test-utils'
const AsyncComp = defineAsyncComponent(() => import('./AsyncSection.vue'))
describe('AsyncSection', () => {
it('resolves inside Suspense', async () => {
const wrapper = mount(Suspense, {
slots: { default: AsyncComp, fallback: '<div>Loading</div>' },
})
expect(wrapper.text()).toContain('Loading')
await new Promise(resolve => setTimeout(resolve, 10)) // 模拟等待
expect(wrapper.text()).toContain('Loaded data')
})
})
九、Composable 函数单元测试
Vue 3 Composition API 将可复用逻辑封装为 Composable 函数,这类函数不依赖 DOM,可直接在 Node 环境中测试。测试重点在于响应式状态的转换时机、watch 与 computed 的派生逻辑、以及与外部 API 的交互边界。
// composables/useCounter.ts
import { ref, computed } from 'vue'
export function useCounter(initial = 0) {
const count = ref(initial)
const double = computed(() => count.value * 2)
const increment = () => { count.value++ }
const decrement = () => { count.value-- }
return { count, double, increment, decrement }
}
import { describe, it, expect } from 'vitest'
import { useCounter } from './useCounter'
describe('useCounter', () => {
it('initializes with default value', () => {
const { count } = useCounter()
expect(count.value).toBe(0)
})
it('increments and decrements', () => {
const { count, increment, decrement } = useCounter(5)
increment()
expect(count.value).toBe(6)
decrement()
expect(count.value).toBe(5)
})
it('computes double correctly', () => {
const { count, double, increment } = useCounter(3)
expect(double.value).toBe(6)
increment()
expect(double.value).toBe(8)
})
})
对于依赖生命周期或 DOM 事件的 Composable(如 useIntersectionObserver、useEventListener),需在测试中使用 jsdom 环境并提供对应的 DOM Mock,或者将感知逻辑拆分为更纯粹的数据层。
十、Coverage 阈值配置与测试策略矩阵(单元/集成/E2E 分工)
测试金字塔理论指出:单元测试应数量最多、执行最快、成本最低;集成测试覆盖模块协作路径;E2E 测试聚焦核心用户旅程。Vitest 的覆盖率报告可配置分支、函数、行、语句四项阈值,防止代码在重构中丢失覆盖。
// vitest.config.ts
export default defineConfig({
test: {
coverage: {
provider: 'v8',
thresholds: {
branches: 80,
functions: 90,
lines: 85,
statements: 85,
},
include: ['src/**'],
exclude: ['src/**/*.d.ts', 'src/main.ts'],
},
},
})
以下为推荐的测试分工矩阵:
| 层级 | 范围 | 工具 | 执行时间 | 数量目标 |
|---|---|---|---|---|
| 单元测试 | 工具函数、Composable、Store | Vitest + Vue Test Utils | < 1 分钟 | 数百个 |
| 集成测试 | 组件组合、页面模块、API 拦截 | Vitest + MSW + mount | 1-3 分钟 | 数十个 |
| E2E 测试 | 关键用户路径、跨页面流程 | Playwright | 3-10 分钟 | 10-30 个 |
单元测试追求高覆盖率,但无需追求百分之百,因为边界条件、样式细节和浏览器兼容性更适合由集成和 E2E 捕获。应将有限精力投入到高风险、高复用模块的测试建设中。
十一、Visual Regression Testing 与 Lighthouse CI
视觉回归测试通过像素级对比捕获 UI 意外变更,Lighthouse CI 则监控系统性能、可访问性和 SEO 指标。两者结合可形成覆盖功能、外观与性能的多维质量门禁。
11.1 Playwright Visual Comparison
Playwright 内置 toHaveScreenshot 断言,可在首屏渲染完成后截取全页或局部快照,并与基线对比。
import { test, expect } from '@playwright/test'
test('homepage visual regression', async ({ page }) => {
await page.goto('/')
await page.waitForLoadState('networkidle')
await expect(page).toHaveScreenshot('homepage.png', {
fullPage: true,
threshold: 0.2,
})
})
首次运行会生成基准快照,后续 CI 执行中若差异超过 threshold 则宣告失败,开发者需人工审查差异图并决定是否更新基准。建议将快照存储在版本控制中,并通过 –update-snapshots 标志在预期变更时批量更新。
11.2 Lighthouse CI
Lighthouse CI 可集成到 GitHub Actions,对部署预览或生产环境进行自动评分。
# .github/workflows/lighthouse.yml
name: Lighthouse CI
on: [push]
jobs:
lighthouse:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm install -g @lhci/cli@0.14.x
- run: npm ci && npm run build
- run: lhci autorun
env:
LHCI_GITHUB_APP_TOKEN: ${{ secrets.LHCI_GITHUB_APP_TOKEN }}
在 lighthouserc.cjs 中配置性能预算,例如 First Contentful Paint 必须小于 1.8 秒, Largest Contentful Paint 小于 2.5 秒,任何突破都会阻断合并。
module.exports = {
ci: {
assert: {
preset: 'lighthouse:recommended',
assertions: {
'first-contentful-paint': ['warn', { maxNumericValue: 1800 }],
'largest-contentful-paint': ['error', { maxNumericValue: 2500 }],
},
},
},
}
视觉回归与 Lighthouse CI 作为补充层,不替代功能测试,但在设计系统迭代和性能优化阶段提供不可替代的量化反馈。
相关阅读
- Vue 详解 — 核心概念
- Vue Composition API 完全指南 — Composition API 测试模式
- React 测试指南 — 跨框架测试对比
- 前端性能优化指南 — 与 Lighthouse CI 配合使用
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。