前端测试全景指南:Vitest + Vue Test Utils + Playwright
引言
测试不是可选的装饰,而是工程质量的底线。前端测试的复杂度在于:UI 的不确定性、异步的普遍性、以及浏览器环境的多样性。本文将从测试金字塔出发,系统性地覆盖 Vitest 单元测试、Vue Test Utils 组件测试、MSW API 模拟、Playwright E2E 测试,以及 CI 集成与覆盖率策略。
一、测试金字塔与前端测试策略
1.1 传统金字塔 vs 前端奖杯
前端测试更适合 Testing Trophy(奖杯模型):
| 层级 | 占比 | 工具 | 速度 |
|---|---|---|---|
| 单元测试 | 30% | Vitest | 极快 |
| 集成/组件测试 | 50% | Vitest + VTU | 快 |
| E2E 测试 | 20% | Playwright | 慢 |
核心原则:将更多测试放在组件集成层,而非过度追求 E2E 覆盖率。
二、Vitest 配置与高级用法
2.1 基础配置
// vitest.config.ts
import { defineConfig } from 'vitest/config'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
export default defineConfig({
plugins: [vue()],
resolve: {
alias: { '@': resolve(__dirname, './src') },
},
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./tests/setup.ts'],
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html', 'lcov'],
exclude: ['node_modules/', 'tests/', '**/*.config.ts', '**/*.d.ts'],
thresholds: { lines: 80, functions: 80, branches: 75, statements: 80 },
},
},
})
2.2 setup 文件与全局 mock
// tests/setup.ts
import { vi, beforeAll, afterAll } from 'vitest'
import { server } from './mocks/server'
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
afterAll(() => server.close())
afterEach(() => { server.resetHandlers(); vi.restoreAllMocks() })
// mock window.matchMedia
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn().mockImplementation((query: string) => ({
matches: false, media: query, onchange: null,
addListener: vi.fn(), removeListener: vi.fn(),
addEventListener: vi.fn(), removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
})
2.3 高级:参数化测试与快照
import { describe, it, expect } from 'vitest'
import { formatPrice } from '@/utils/format'
describe('formatPrice', () => {
it.each([
[0, '¥0.00'],
[99.5, '¥99.50'],
[1000000, '¥1,000,000.00'],
[-50, '-¥50.00'],
])('formatPrice(%i) → %s', (input, expected) => {
expect(formatPrice(input)).toBe(expected)
})
it('返回正确结构', () => {
expect(formatPrice(99)).toMatchInlineSnapshot(`"¥99.00"`)
})
})
2.4 vi.mock 深入
// 自动 mock 整个模块
vi.mock('@/api/auth', () => ({
login: vi.fn(),
logout: vi.fn(),
getToken: vi.fn(() => 'mock-token'),
}))
// 工厂函数:访问原始模块
vi.mock('@/config', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/config')>()
return { ...actual, API_URL: 'http://test-api.local' }
})
三、Vue Test Utils 组件测试
3.1 基础挂载与查询
import { mount } from '@vue/test-utils'
import UserCard from '@/components/UserCard.vue'
describe('UserCard', () => {
const mockUser = { id: 1, name: 'Alice', avatar: '/avatar.png', isAdmin: false }
it('显示用户名和头像', () => {
const wrapper = mount(UserCard, { props: { user: mockUser } })
expect(wrapper.find('h3').text()).toBe('Alice')
expect(wrapper.find('img').attributes('src')).toBe('/avatar.png')
})
it('点击 Follow 触发事件', async () => {
const wrapper = mount(UserCard, { props: { user: mockUser } })
await wrapper.find('button').trigger('click')
expect(wrapper.emitted('follow')).toHaveLength(1)
expect(wrapper.emitted('follow')![0]).toEqual([1])
})
})
3.2 测试 slots、provide/inject、路由
// 测试插槽
it('渲染默认插槽', () => {
const wrapper = mount(LayoutCard, {
slots: { default: '<p>正文内容</p>', header: '<span>标题</span>' },
})
expect(wrapper.html()).toContain('正文内容')
})
// provide / inject
it('注入 theme', () => {
const wrapper = mount(ThemedButton, {
global: { provide: { theme: 'dark' } },
})
expect(wrapper.classes()).toContain('dark-theme')
})
// 路由 mock
it('路由跳转', async () => {
const router = createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/', component: { template: '<div>Home</div>' } },
{ path: '/about', component: { template: '<div>About</div>' } },
],
})
const wrapper = mount(NavBar, { global: { plugins: [router] } })
await router.push('/about')
await router.isReady()
expect(wrapper.find('.active').text()).toBe('About')
})
3.3 测试异步组件
import { mount, flushPromises } from '@vue/test-utils'
it('异步数据加载', async () => {
const wrapper = mount(AsyncProfile, {
global: { mocks: { $fetch: vi.fn().mockResolvedValue({ name: 'Bob' }) } },
})
expect(wrapper.find('.loading').exists()).toBe(true)
await flushPromises()
expect(wrapper.find('.loading').exists()).toBe(false)
expect(wrapper.text()).toContain('Bob')
})
3.4 stub 子组件
it('stub 重型子组件', () => {
const wrapper = mount(Dashboard, {
global: {
stubs: {
HeavyChart: true,
UserProfile: { template: '<div class="stub-profile" />' },
},
},
})
expect(wrapper.find('.stub-profile').exists()).toBe(true)
})
四、MSW Mock API
4.1 定义 handlers
import { http, HttpResponse } from 'msw'
export const handlers = [
http.get('/api/users/:id', ({ params }) => {
return HttpResponse.json({ id: Number(params.id), name: 'Mocked User' })
}),
http.post('/api/login', async ({ request }) => {
const body = await request.json() as { username: string; password: string }
if (body.username === 'admin') return HttpResponse.json({ token: 'admin-token' })
return HttpResponse.json({ error: 'Invalid credentials' }, { status: 401 })
}),
]
4.2 按场景覆盖
import { server } from './mocks/server'
it('处理服务器错误', async () => {
server.use(
http.get('/api/users/1', () => HttpResponse.json({ error: 'Not Found' }, { status: 404 }))
)
const wrapper = mount(UserProfile)
await flushPromises()
expect(wrapper.find('.error-message').text()).toContain('Not Found')
})
五、Playwright E2E 测试
5.1 配置
import { defineConfig, devices } from '@playwright/test'
export default defineConfig({
testDir: './e2e',
fullyParallel: true,
retries: process.env.CI ? 2 : 0,
reporter: [['html'], ['junit', { outputFile: 'test-results/junit.xml' }]],
use: {
baseURL: 'http://localhost:3000',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
{ name: 'webkit', use: { ...devices['Desktop Safari'] } },
{ name: 'mobile', use: { ...devices['iPhone 15'] } },
],
webServer: { command: 'pnpm dev', port: 3000, reuseExistingServer: !process.env.CI, timeout: 30_000 },
})
5.2 Page Object Model
export class LoginPage {
readonly usernameInput: Locator
readonly passwordInput: Locator
readonly submitButton: Locator
readonly errorMessage: Locator
constructor(page: Page) {
this.usernameInput = page.getByLabel('用户名')
this.passwordInput = page.getByLabel('密码')
this.submitButton = page.getByRole('button', { name: '登录' })
this.errorMessage = page.getByTestId('login-error')
}
async goto() { await this.page.goto('/login') }
async login(username: string, password: string) {
await this.usernameInput.fill(username)
await this.passwordInput.fill(password)
await this.submitButton.click()
}
}
5.3 网络 Mock 与视觉回归
test('mock API 响应', async ({ page }) => {
await page.route('**/api/users', async (route) => {
await route.fulfill({
status: 200, contentType: 'application/json',
body: JSON.stringify([{ id: 1, name: 'Mocked' }]),
})
})
await page.goto('/users')
await expect(page.getByText('Mocked')).toBeVisible()
})
test('主题切换视觉对比', async ({ page }) => {
await page.goto('/')
await page.getByRole('switch', { name: '切换主题' }).click()
await expect(page).toHaveScreenshot('theme-dark.png', { maxDiffPixelRatio: 0.01, threshold: 0.2 })
})
六、CI 集成与覆盖率
6.1 GitHub Actions
name: CI
on: [push, pull_request]
jobs:
unit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v2
with: { version: 9 }
- uses: actions/setup-node@v4
with: { node-version: 20, cache: pnpm }
- run: pnpm install --frozen-lockfile
- run: pnpm test:coverage
- uses: codecov/codecov-action@v4
e2e:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: pnpm install --frozen-lockfile
- run: pnpm exec playwright install --with-deps chromium
- run: pnpm test:e2e
env: { CI: true }
- uses: actions/upload-artifact@v4
if: failure()
with: { name: playwright-report, path: playwright-report/ }
6.2 覆盖率策略
| 文件类型 | 建议覆盖率 | 优先级 |
|---|---|---|
| utils/纯函数 | 95%+ | 最高 |
| store/composable | 85%+ | 高 |
| 组件(展示型) | 60-70% | 中 |
| 组件(容器型) | 70-80% | 高 |
| 页面/路由 | E2E 覆盖 | 中 |
七、最佳实践总结
- 测试行为而非实现:通过公开接口断言,避免依赖私有状态。
- 避免 snapshot 滥用:照片式快照价值低且脆弱,优先用语义化断言。
- MSW 优于 axios mock:在 HTTP 层模拟,让上层所有代码都走真实路径。
- E2E 测试关键路径:登录、结账、核心 CRUD,避免覆盖过多分支。
- 并行 + 重试:CI 中 E2E 必须
retries: 2,单测保持 0 重试暴露问题。 - 测试数据隔离:每个测试用独立的数据库事务或测试夹具,不依赖执行顺序。
测试体系的建设是渐进的:先覆盖 critical path 的 E2E,再补单元测试兜底,最后追求覆盖率指标。先有测试,再谈重构。
Comments | 0条评论