Vue.js: 单元测试与 E2E
最后更新:2026-08-26
单元测试是保证代码质量的基石——组件重构不怕坏、CI 自动跑测试、新人敢动手改代码。Vue 3 推荐 Vitest(新一代测试框架,比 Jest 快 10x)+ Vue Test Utils(官方组件测试工具)。
E2E 测试模拟真实用户操作——Playwright 是当前最流行的 E2E 工具(比 Cypress 更快、跨浏览器)。本课覆盖单元 + E2E 完整测试体系。
1. 你将学到
- Vitest 配置和 4 大核心 API
- Vue Test Utils(@vue/test-utils)组件挂载
- 5 大组件测试场景(props / emit / slot / event / async)
- 5 大断言技巧
- Playwright E2E 测试
- 覆盖率报告
- 5 个常见错误
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) 收益
加上测试后:
- 重构信心:100% 提升(不怕改坏)
- Bug 提前发现:80% 在开发阶段捕获
- 文档作用:测试就是组件使用说明书
- CI 自动化:PR 自动跑测试
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 合并。📖 小节
- Vitest 是 Vue 3 推荐测试框架(比 Jest 快 10x)
- Vue Test Utils:mount / shallowMount / 5 大 API
- 5 大组件测试:props / emit / slot / async / provide
- 5 大断言:text / class / element / event / async
- Playwright 是 E2E 推荐(快 / 跨浏览器)
- 测试覆盖率:核心 80%+、整体 60%+
- 70% 单元 + 20% 集成 + 10% E2E
📝 作业
-
基础题(难度⭐) 为 ProductCard 写 3 个测试:
- props 渲染
- emit 事件
- 边界情况(库存为 0)
-
进阶题(难度⭐⭐) 为购物车 store 写完整测试:
- 5 个 actions(addItem / removeItem / clear / load / save)
- 3 个 getters(itemCount / totalPrice / isEmpty)
- Pinia 测试 setup
-
挑战题(难度⭐⭐⭐) 实现完整的"测试体系":
- 10 个组件单元测试(每组件 3-5 个测试)
- 5 个 composable 测试
- 5 个 E2E 测试(登录 / 注册 / 搜索 / 购物车 / 支付)
- 覆盖率门槛 80%
- CI 集成(GitHub Actions)
- Vitest + Playwright 跨浏览器测试