Vue.js: 单元测试与 E2E

最后更新:2026-08-26

单元测试是保证代码质量的基石——组件重构不怕坏、CI 自动跑测试、新人敢动手改代码。Vue 3 推荐 Vitest(新一代测试框架,比 Jest 快 10x)+ Vue Test Utils(官方组件测试工具)。

E2E 测试模拟真实用户操作——Playwright 是当前最流行的 E2E 工具(比 Cypress 更快、跨浏览器)。本课覆盖单元 + E2E 完整测试体系。

1. 你将学到


2. 一个"重构恐惧症"团队的痛点

(1) 痛点:1 个组件被改坏,全站崩溃

Alice 的团队维护着 100+ 组件的电商后台:

JS
// ❌ 翻车版:没测试,Alice 不敢重构
// ProductCard.vue 改了 1 行,整个首页白屏
// 没人发现,第 2 天用户投诉才发现
// 团队:"别动这个组件,我们不知道它会破坏什么"

产品经理 Charlie:

"Alice,我们需要测试。要能放心地重构。需要 1)组件的单元测试,2)关键流程的 E2E 测试。"

(2) Vitest + Vue Test Utils 解法

JS
// tests/unit/ProductCard.test.js
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import ProductCard from '@/components/ProductCard.vue'

describe('ProductCard', () => {
  it('renders product name and price', () => {
    const product = { id: 1, name: 'iPhone', price: 999 }
    const wrapper = mount(ProductCard, { props: { product } })
    
    expect(wrapper.text()).toContain('iPhone')
    expect(wrapper.text()).toContain('$999')
  })
  
  it('emits add-to-cart event', async () => {
    const wrapper = mount(ProductCard, { 
      props: { product: { id: 1, name: 'iPhone', price: 999 } } 
    })
    
    await wrapper.find('button').trigger('click')
    
    expect(wrapper.emitted('add-to-cart')).toBeTruthy()
    expect(wrapper.emitted('add-to-cart')[0]).toEqual([1])
  })
})

跑测试:

BASH
$ npx vitest run
✓ ProductCard > renders product name and price
✓ ProductCard > emits add-to-cart event
2 passed

5 秒跑完,0 个误报。改完代码立即知道有没有破坏。

(3) 收益

加上测试后:


3. Vitest 配置

(1) 安装

BASH
npm install -D vitest @vue/test-utils @vitest/coverage-v8 jsdom

(2) 基础配置

TS
// vitest.config.ts
import { defineConfig } from 'vitest/config'
import vue from '@vitejs/plugin-vue'
import path from 'path'

export default defineConfig({
  plugins: [vue()],
  
  test: {
    // 测试环境
    environment: 'jsdom',  // 或 'happy-dom' / 'node'
    
    // 匹配文件
    include: ['tests/**/*.test.ts', 'src/**/*.test.ts'],
    
    // 覆盖率
    coverage: {
      provider: 'v8',
      reporter: ['text', 'html', 'json'],
      exclude: ['node_modules/', 'tests/']
    },
    
    // 全局 setup
    setupFiles: ['./tests/setup.ts']
  },
  
  resolve: {
    alias: {
      '@': path.resolve(__dirname, 'src')
    }
  }
})

(3) 4 大核心 API

TS
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'

// 1. describe:测试套件
describe('Calculator', () => { })

// 2. it:单个测试
it('adds 1 + 1 to equal 2', () => { })

// 3. expect:断言
expect(1 + 1).toBe(2)
expect(arr).toContain('apple')
expect(obj).toMatchObject({ name: 'Alice' })

// 4. vi:mock / spy
vi.fn()                  // mock 函数
vi.mock('./api')         // mock 模块
vi.spyOn(obj, 'method')  // spy 方法

(4) Vitest vs Jest

维度 Jest Vitest
启动速度 3-5s < 1s
监听模式 2-3s < 100ms
ESM 支持 需配置 原生
TypeScript 需 ts-jest 原生
兼容性 庞大 Jest-compatible API
推荐度 ⭐⭐⭐⭐⭐

4. Vue Test Utils 组件测试

(1) 5 大核心 API

JS
import { mount, shallowMount, RouterLinkStub } from '@vue/test-utils'

// 1. mount:完整挂载(子组件也挂载)
const wrapper = mount(Component, { props: { ... } })

// 2. shallowMount:浅挂载(子组件不挂载,stubbed)
const wrapper = shallowMount(Component, { props: { ... } })

// 3. 查找元素
wrapper.find('button')              // 第一个匹配
wrapper.findAll('li')              // 所有匹配
wrapper.findComponent(MyComponent)  // 找子组件
wrapper.get('#submit')             // 必须找到(找不到报错)

// 4. 触发事件
await wrapper.find('button').trigger('click')
await wrapper.find('input').setValue('hello')

// 5. 断言
expect(wrapper.text()).toContain('Hello')
expect(wrapper.find('h1').text()).toBe('Title')
expect(wrapper.emitted('add-to-cart')).toBeTruthy()

(2) 5 大测试场景

JS
// 1. props 测试
it('renders props correctly', () => {
  const wrapper = mount(MyComponent, { props: { title: 'Hello' } })
  expect(wrapper.text()).toContain('Hello')
})

// 2. emit 测试
it('emits event on click', async () => {
  const wrapper = mount(MyComponent)
  await wrapper.find('button').trigger('click')
  expect(wrapper.emitted('submit')).toBeTruthy()
})

// 3. slot 测试
it('renders default slot', () => {
  const wrapper = mount(MyComponent, {
    slots: { default: '<p>Slot content</p>' }
  })
  expect(wrapper.html()).toContain('Slot content')
})

// 4. 异步测试
// 注:flushPromises 需要自行定义或从测试工具库导入
// const flushPromises = () => new Promise(resolve => setTimeout(resolve, 0))
it('loads data on mount', async () => {
  const wrapper = mount(MyComponent)
  await flushPromises()  // 等异步完成
  expect(wrapper.text()).toContain('Loaded')
})

// 5. provide/inject 测试
it('uses provided value', () => {
  const wrapper = mount(MyComponent, {
    provide: { theme: 'dark' }
  })
  expect(wrapper.find('.dark').exists()).toBe(true)
})

(3) 5 大断言技巧

JS
// 1. 文本包含
expect(wrapper.text()).toContain('Hello')

// 2. class 存在
expect(wrapper.find('.active').exists()).toBe(true)

// 3. 元素存在
expect(wrapper.find('button').exists()).toBe(true)

// 4. 事件触发
expect(wrapper.emitted('click')).toBeTruthy()
expect(wrapper.emitted('click')[0]).toEqual([arg1, arg2])

// 5. 异步操作完成
await wrapper.vm.$nextTick()
await flushPromises()

5. Playwright E2E 测试

(1) 安装

BASH
npm install -D @playwright/test
npx playwright install

(2) 配置

TS
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test'

export default defineConfig({
  testDir: './tests/e2e',
  fullyParallel: true,
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? 1 : undefined,
  reporter: 'html',
  use: {
    baseURL: 'http://localhost:5173',
    trace: 'on-first-retry'
  },
  projects: [
    { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
    { name: 'firefox', use: { ...devices['Desktop Firefox'] } },
    { name: 'webkit', use: { ...devices['Desktop Safari'] } }
  ],
  webServer: {
    command: 'npm run dev',
    url: 'http://localhost:5173',
    reuseExistingServer: !process.env.CI
  }
})

(3) 5 大 E2E 测试场景

TS
// 1. 登录流程
import { test, expect } from '@playwright/test'

test('user can login', async ({ page }) => {
  await page.goto('/login')
  await page.fill('input[name="username"]', 'alice@example.com')
  await page.fill('input[name="password"]', 'password123')
  await page.click('button[type="submit"]')
  
  await expect(page).toHaveURL('/dashboard')
  await expect(page.locator('h1')).toContainText('Welcome, Alice')
})

// 2. 添加购物车
test('add product to cart', async ({ page }) => {
  await page.goto('/products/1')
  await page.click('button:has-text("Add to Cart")')
  
  await expect(page.locator('.cart-count')).toHaveText('1')
})

// 3. 表单提交
test('submit form with validation', async ({ page }) => {
  await page.goto('/contact')
  await page.fill('input[name="email"]', 'invalid-email')
  await page.click('button[type="submit"]')
  
  await expect(page.locator('.error')).toBeVisible()
})

// 4. 路由跳转
test('navigate to products page', async ({ page }) => {
  await page.goto('/')
  await page.click('a:has-text("Products")')
  
  await expect(page).toHaveURL('/products')
})

// 5. 异步加载
test('loads data after navigation', async ({ page }) => {
  await page.goto('/dashboard')
  
  // 等待数据加载
  await page.waitForSelector('.chart-loaded')
  await expect(page.locator('.chart-loaded')).toBeVisible()
})

(4) 运行 E2E 测试

BASH
# 运行所有测试
npx playwright test

# 运行特定文件
npx playwright test tests/e2e/login.spec.ts

# 调试模式(打开浏览器)
npx playwright test --debug

# UI 模式(可视化)
npx playwright test --ui

6. 完整示例:5 大测试场景

▶ 示例:5 大测试场景(⚠️ 需 Vite + Vitest)

⚠️ 以下代码在 Vite 项目中运行,CDN 全局构建不支持 Vitest。展示核心 API:

JS
// 单元测试:Vitest + @vue/test-utils
// 1. props
it('renders props', () => { /* ... */ })

// 2. emit
it('emits event', async () => { /* ... */ })

// 3. slot
it('renders slot', () => { /* ... */ })

// 4. async
it('loads data', async () => { /* ... */ })

// 5. provide
it('uses provide', () => { /* ... */ })
▶ 试一试
JS
// 5 大断言技巧
// 1. 文本
expect(wrapper.text()).toContain('Hello')

// 2. class
expect(wrapper.find('.active').exists()).toBe(true)

// 3. 元素
expect(wrapper.find('button').exists()).toBe(true)

// 4. 事件
expect(wrapper.emitted('click')).toBeTruthy()

// 5. 异步
await wrapper.vm.$nextTick()
TS
// E2E 测试:Playwright(5 大场景)
// 1. 登录
test('login', async ({ page }) => { /* ... */ })

// 2. 购物车
test('add to cart', async ({ page }) => { /* ... */ })

// 3. 表单
test('form submit', async ({ page }) => { /* ... */ })

// 4. 路由
test('navigate', async ({ page }) => { /* ... */ })

// 5. 异步
test('async load', async ({ page }) => { /* ... */ })

▶ 示例:5 个常见错误速查

错误 现象 解决
测试找不到组件 import 错误 检查路径别名
异步测试不等待 总是失败 用 await + flushPromises
触发事件不生效 监听器没触发 trigger 后 await
E2E 超时 找不到元素 waitForSelector
覆盖率低 没写测试 强制覆盖率门槛

▶ 示例:5 大测试工具对比

工具 速度 适用
Vitest ⭐⭐⭐⭐⭐ 单元测试
Vue Test Utils ⭐⭐⭐⭐⭐ 组件测试
Playwright ⭐⭐⭐⭐ E2E 测试
Cypress ⭐⭐⭐ E2E(慢)
Testing Library ⭐⭐⭐⭐ 组件测试

❓ 常见问题

Q Vitest 和 Jest 选哪个?
A Vitest。新项目一律 Vitest(快 10x、原生 ESM/TS 支持)。Jest 老项目维护用。
Q mount vs shallowMount 选哪个?
A 单元测试用 shallowMount(快、隔离)。集成测试用 mount(真实)。推荐:组件测试用 shallowMount,少数关键组件用 mount。
Q 测试覆盖率多少合适?
A 核心组件 80%+,utils 90%+,整体 60%+。不用追求 100%(不实用)。
Q Playwright vs Cypress 选哪个?
A Playwright。快 2x、跨浏览器(Chromium / Firefox / WebKit)、更好的 API。Cypress 只支持 Chromium 系。
Q 单元测试 vs E2E 测试比例?
A 70% 单元 + 20% 集成 + 10% E2E。E2E 慢且脆弱,单元测试性价比高。
Q 测试要 mock 所有依赖吗?
A 单元测试 mock 外部 API(fetch、localStorage)。集成测试用真实依赖。E2E 完全真实。
Q CI 怎么跑测试?
A GitHub Actions / GitLab CI 添加 npm test 步骤。失败时阻止 PR 合并。

📖 小节


📝 作业

  1. 基础题(难度⭐) 为 ProductCard 写 3 个测试:

    • props 渲染
    • emit 事件
    • 边界情况(库存为 0)
  2. 进阶题(难度⭐⭐) 为购物车 store 写完整测试:

    • 5 个 actions(addItem / removeItem / clear / load / save)
    • 3 个 getters(itemCount / totalPrice / isEmpty)
    • Pinia 测试 setup
  3. 挑战题(难度⭐⭐⭐) 实现完整的"测试体系":

    1. 10 个组件单元测试(每组件 3-5 个测试)
    2. 5 个 composable 测试
    3. 5 个 E2E 测试(登录 / 注册 / 搜索 / 购物车 / 支付)
    4. 覆盖率门槛 80%
    5. CI 集成(GitHub Actions)
    6. Vitest + Playwright 跨浏览器测试
Web-Tutorial.com

Web-Tutorial 技术团队

由多位开发者共同维护的编程教程平台。每篇教程由对应领域的开发者编写和审核,确保内容准确可靠。如发现任何问题,欢迎向我们反馈。

100%

🙏 帮我们做得更好

我们是刚上线的编程教程站,几个人的小团队,精力有限。页面虽经检查,难免还有疏漏——链接失效、排版错乱、内容有误、语言生硬……

如果您发现了,麻烦告诉我们,我们会在收到反馈后第一时间进行修复,再次感谢您的光临 🙏