Vue 测试深度指南:Vitest + Vue Test Utils + Playwright E2E 与 CI 集成

Vue 3 应用从单元测试到 E2E 的完整测试策略:Vitest + Vue Test Utils 组件测试(mount/emits/slots/async)、Pinia Store Mocking、MSW API 拦截、Playwright 端到端测试、Cypress 组件测试、覆盖率标准与 GitHub Actions CI 集成。

测试是 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、StoreVitest + Vue Test Utils< 1 分钟数百个
集成测试组件组合、页面模块、API 拦截Vitest + MSW + mount1-3 分钟数十个
E2E 测试关键用户路径、跨页面流程Playwright3-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 作为补充层,不替代功能测试,但在设计系统迭代和性能优化阶段提供不可替代的量化反馈。

相关阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「frontend」更多文章

  1. GraphQL BFF 与微前端:多前端团队的 Schema 分片与协作模式
  2. 前端 GraphQL 客户端集成:Apollo Client、Relay 与 urql 选型
  3. 前端性能调试实战:从首屏加载到运行时瓶颈