WSの小屋

前端测试全景指南: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 覆盖

七、最佳实践总结

  1. 测试行为而非实现:通过公开接口断言,避免依赖私有状态。
  2. 避免 snapshot 滥用:照片式快照价值低且脆弱,优先用语义化断言。
  3. MSW 优于 axios mock:在 HTTP 层模拟,让上层所有代码都走真实路径。
  4. E2E 测试关键路径:登录、结账、核心 CRUD,避免覆盖过多分支。
  5. 并行 + 重试:CI 中 E2E 必须 retries: 2,单测保持 0 重试暴露问题。
  6. 测试数据隔离:每个测试用独立的数据库事务或测试夹具,不依赖执行顺序。

测试体系的建设是渐进的:先覆盖 critical path 的 E2E,再补单元测试兜底,最后追求覆盖率指标。先有测试,再谈重构

Comments | 0条评论