测试概述
理解 Vue 3 应用的测试策略、测试类型划分以及测试框架的选择。
为什么需要测试
测试在软件开发生命周期中扮演关键角色:
| 测试价值 | 描述 |
|---|---|
| 质量保障 | 及早发现缺陷,降低生产环境风险 |
| 重构信心 | 测试覆盖提供安全网,支持代码重构 |
| 文档作用 | 测试用例即活文档,展示预期行为 |
| 设计改进 | 编写测试促使更好的模块化设计 |
测试金字塔
测试金字塔描述了测试的层次结构:
code
▲
/E2E\ 数量少,运行慢,成本高
/------\
/ 组件测试 \ 数量适中,运行较快
/----------\
/ 单元测试 \ 数量多,运行快,成本低
/--------------\各层测试对比
| 特性 | 单元测试 | 组件测试 | E2E 测试 |
|---|---|---|---|
| 测试范围 | 函数/模块 | Vue 组件 | 完整应用 |
| 运行速度 | 毫秒级 | 秒级 | 分钟级 |
| 维护成本 | 低 | 中 | 高 |
| 调试难度 | 简单 | 中等 | 较难 |
| 测试可靠性 | 高 | 中 | 较低 |
| 覆盖范围 | 代码逻辑 | 组件行为 | 用户流程 |
一、单元测试
单元测试针对应用中最小可测试单元进行验证。
适用场景
- 工具函数(utils/helpers)
- 业务逻辑函数
- 组合式函数(Composables)
- Pinia Store 模块
框架选择
Vitest(推荐)
Vitest 是 Vite 原生的测试框架,与 Vite 无缝集成:
bash
# 安装
npm install -D vitest @vue/test-utils jsdom优势:
- ⚡ 与 Vite 共享配置,极速启动
- 🔧 兼容 Jest API,迁移成本低
- 📦 原生支持 ESM 和 TypeScript
- 🎯 开箱即用的 Vue 组件测试支持
- 🔄 HMR 支持,测试热更新
Vitest 配置示例:
typescript
// vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
test: {
globals: true,
environment: 'jsdom',
include: ['tests/**/*.{test,spec}.{js,ts}'],
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html']
}
}
})Jest
Jest 是成熟的测试框架,需要额外配置:
bash
# 安装
npm install -D jest @vue/vue3-jest babel-jest优势:
- 成熟稳定,生态丰富
- 完善的 Mock 功能
- 快照测试支持
框架对比
| 特性 | Vitest | Jest |
|---|---|---|
| 启动速度 | 极快 | 较慢 |
| Vite 集成 | 原生 | 需配置 |
| 配置复杂度 | 低 | 中 |
| ESM 支持 | 原生 | 需配置 |
| HMR | 支持 | 不支持 |
| 迁移成本 | - | 低(API 兼容) |
二、组件测试
组件测试验证 Vue 组件的渲染输出、用户交互和状态管理。
适用场景
- UI 组件渲染测试
- 用户交互测试(点击、输入)
- Props 和事件测试
- 插槽内容测试
- 组件生命周期测试
框架选择
@vue/test-utils(官方推荐)
bash
npm install -D @vue/test-utils jsdom特点:
- Vue 官方维护
- 提供底层 API
- 完整的组件控制能力
- 支持 Vue 3 所有特性
示例:
typescript
import { mount } from '@vue/test-utils'
import Counter from './Counter.vue'
test('计数器递增', async () => {
const wrapper = mount(Counter)
expect(wrapper.text()).toContain('0')
await wrapper.find('button').trigger('click')
expect(wrapper.text()).toContain('1')
})Vue Testing Library
bash
npm install -D @testing-library/vue测试原则:
- 测试用户可见行为
- 避免依赖实现细节
- 重视可访问性
选择建议
| 场景 | 推荐框架 |
|---|---|
| 新项目 / 需要底层控制 | @vue/test-utils |
| 以用户行为为中心 | Vue Testing Library |
| 已有项目迁移 | 保持原框架一致性 |
三、端到端测试(E2E)
端到端测试模拟真实用户行为,验证完整的应用流程。
适用场景
- 关键业务流程(注册、登录、支付)
- 跨页面操作流程
- 表单提交和数据持久化
- 第三方集成验证
框架选择
Cypress(推荐入门)
bash
npm install -D cypress
npx cypress open优势:
- 实时重载和调试
- 时间旅行功能
- 自动等待机制
- 网络请求控制
- 优秀的开发者体验
Playwright(推荐生产)
bash
npm install -D @playwright/test
npx playwright install优势:
- 多浏览器支持(Chromium、Firefox、WebKit)
- 强大的选择器引擎
- 自动等待和重试
- 原生支持并行测试
E2E 框架对比
| 特性 | Cypress | Playwright |
|---|---|---|
| 学习曲线 | 低 | 中 |
| 调试体验 | 优秀 | 良好 |
| 跨浏览器 | Chromium系 | 全支持 |
| 执行速度 | 快 | 快 |
| 并行测试 | 需付费 | 原生支持 |
| CI 集成 | 简单 | 简单 |
四、测试策略建议
测试覆盖原则
code
┌─────────────────────────────────────────────────────┐
│ 测试策略 │
├─────────────────────────────────────────────────────┤
│ 1. 核心业务逻辑 → 100% 单元测试覆盖 │
│ 2. 关键组件 → 组件测试 + 快照测试 │
│ 3. 用户关键路径 → E2E 测试 │
│ 4. 边缘情况 → 按需补充 │
└─────────────────────────────────────────────────────┘测试优先级
| 优先级 | 测试类型 | 测试内容 |
|---|---|---|
| P0 | 单元测试 | 核心算法、支付逻辑、权限验证 |
| P0 | E2E 测试 | 注册/登录、支付流程、核心业务 |
| P1 | 组件测试 | 表单组件、弹窗组件、列表组件 |
| P1 | 单元测试 | 工具函数、数据转换 |
| P2 | 组件测试 | 展示型组件 |
| P3 | 快照测试 | UI 组件外观 |
Vue 3 测试特点
与 Vue 2 相比,Vue 3 在测试方面有以下优势:
| 特性 | Vue 2 | Vue 3 |
|---|---|---|
| 组合式函数测试 | 较难 | 简单直接 |
| 响应式测试 | 需要 Vue Test Utils | 可独立测试 |
| TypeScript | 支持有限 | 完美支持 |
| Tree-shaking | 不支持 | 支持 |
组合式函数测试示例:
typescript
// useCounter.test.ts
import { expect, test } from 'vitest'
import { useCounter } from './useCounter'
test('useCounter 组合式函数', () => {
// 直接测试,无需挂载组件
const { count, increment, decrement } = useCounter(5)
expect(count.value).toBe(5)
increment()
expect(count.value).toBe(6)
decrement()
expect(count.value).toBe(5)
})五、常见问题
Q1: 单元测试和组件测试的区别?
单元测试关注独立的函数或模块,不涉及 DOM 渲染;组件测试需要挂载 Vue 组件到 DOM,验证渲染和交互行为。
Q2: 测试覆盖率多少合适?
建议目标:
- 整体覆盖率:70-80%
- 核心业务模块:90%+
- E2E 覆盖核心用户流程即可
Q3: 如何选择 E2E 框架?
- Cypress:入门简单,调试体验好,适合小中型项目
- Playwright:跨浏览器支持好,适合大型项目和企业级应用
Q4: Vitest 和 Jest 如何选择?
- 新项目:推荐 Vitest,与 Vite 无缝集成
- 现有 Jest 项目:可继续使用 Jest,或逐步迁移到 Vitest
- 非 Vite 项目:Jest 仍是好选择
相关资源
下一步
单元测试
使用 Vitest 进行 Vue 3 应用的单元测试。
安装与配置
安装依赖
bash
# 安装 Vitest 和测试工具
npm install -D vitest @vue/test-utils jsdom @vitest/coverage-v8
# 如果使用 TypeScript
npm install -D @vitest/expect配置文件
typescript
// vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
test: {
// 启用全局 API(describe, test, expect 等)
globals: true,
// 使用 jsdom 模拟浏览器环境
environment: 'jsdom',
// 测试文件匹配模式
include: ['**/*.{test,spec}.{js,ts}'],
// 排除目录
exclude: ['node_modules', 'dist'],
// 覆盖率配置
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
exclude: [
'node_modules/',
'tests/',
'**/*.d.ts',
'**/*.config.*'
]
}
}
})package.json 脚本
json
{
"scripts": {
"test": "vitest",
"test:run": "vitest run",
"test:ui": "vitest --ui",
"test:coverage": "vitest run --coverage"
}
}基本测试
测试工具函数
typescript
// utils/math.ts
export function add(a: number, b: number): number {
return a + b
}
export function multiply(a: number, b: number): number {
return a * b
}
export function divide(a: number, b: number): number {
if (b === 0) {
throw new Error('不能除以零')
}
return a / b
}typescript
// utils/math.test.ts
import { describe, it, expect } from 'vitest'
import { add, multiply, divide } from './math'
describe('数学工具函数', () => {
describe('add', () => {
it('应该正确相加两个正数', () => {
expect(add(1, 2)).toBe(3)
})
it('应该正确处理负数', () => {
expect(add(-1, -2)).toBe(-3)
})
it('应该正确处理零', () => {
expect(add(0, 0)).toBe(0)
})
})
describe('multiply', () => {
it('应该正确相乘', () => {
expect(multiply(3, 4)).toBe(12)
})
})
describe('divide', () => {
it('应该正确相除', () => {
expect(divide(10, 2)).toBe(5)
})
it('除以零时应该抛出错误', () => {
expect(() => divide(10, 0)).toThrow('不能除以零')
})
})
})测试组合式函数
Vue 3 的组合式函数可以独立测试,无需挂载组件:
typescript
// composables/useCounter.ts
import { ref, computed } from 'vue'
export function useCounter(initialValue = 0) {
const count = ref(initialValue)
const double = computed(() => count.value * 2)
function increment() {
count.value++
}
function decrement() {
count.value--
}
function reset() {
count.value = initialValue
}
return {
count,
double,
increment,
decrement,
reset
}
}typescript
// composables/useCounter.test.ts
import { describe, it, expect } from 'vitest'
import { useCounter } from './useCounter'
describe('useCounter', () => {
it('应该使用初始值初始化', () => {
const { count, double } = useCounter(5)
expect(count.value).toBe(5)
expect(double.value).toBe(10)
})
it('应该正确递增', () => {
const { count, increment } = useCounter(0)
increment()
expect(count.value).toBe(1)
increment()
expect(count.value).toBe(2)
})
it('应该正确递减', () => {
const { count, decrement } = useCounter(5)
decrement()
expect(count.value).toBe(4)
})
it('应该正确重置', () => {
const { count, increment, reset } = useCounter(10)
increment()
increment()
expect(count.value).toBe(12)
reset()
expect(count.value).toBe(10)
})
})Mock 功能
函数 Mock
typescript
// utils/api.ts
export async function fetchUser(id: number) {
const response = await fetch(`/api/users/${id}`)
return response.json()
}typescript
// utils/api.test.ts
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { fetchUser } from './api'
describe('API 函数', () => {
beforeEach(() => {
// 每个测试前重置所有 mock
vi.resetAllMocks()
})
afterEach(() => {
// 每个 test 后恢复所有 mock
vi.restoreAllMocks()
})
it('应该获取用户数据', async () => {
// Mock fetch
global.fetch = vi.fn().mockResolvedValue({
json: () => Promise.resolve({ id: 1, name: 'Vue' })
})
const user = await fetchUser(1)
expect(user).toEqual({ id: 1, name: 'Vue' })
expect(fetch).toHaveBeenCalledWith('/api/users/1')
})
it('应该处理错误', async () => {
global.fetch = vi.fn().mockRejectedValue(new Error('Network Error'))
await expect(fetchUser(1)).rejects.toThrow('Network Error')
})
})模块 Mock
typescript
// utils/storage.ts
export function saveToStorage(key: string, value: string) {
localStorage.setItem(key, value)
}
export function getFromStorage(key: string) {
return localStorage.getItem(key)
}typescript
// utils/storage.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { saveToStorage, getFromStorage } from './storage'
describe('Storage 工具', () => {
beforeEach(() => {
// Mock localStorage
const localStorageMock = {
store: {} as Record<string, string>,
setItem(key: string, value: string) {
this.store[key] = value
},
getItem(key: string) {
return this.store[key] || null
},
clear() {
this.store = {}
}
}
Object.defineProperty(global, 'localStorage', {
value: localStorageMock
})
})
it('应该保存数据到 localStorage', () => {
saveToStorage('token', 'abc123')
expect(localStorage.getItem('token')).toBe('abc123')
})
it('应该从 localStorage 获取数据', () => {
localStorage.setItem('user', 'Vue')
expect(getFromStorage('user')).toBe('Vue')
})
})定时器 Mock
typescript
// utils/timer.ts
export function debounce<T extends (...args: unknown[]) => unknown>(
fn: T,
delay: number
): (...args: Parameters<T>) => void {
let timeoutId: ReturnType<typeof setTimeout>
return function (this: unknown, ...args: Parameters<T>) {
clearTimeout(timeoutId)
timeoutId = setTimeout(() => fn.apply(this, args), delay)
}
}typescript
// utils/timer.test.ts
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { debounce } from './timer'
describe('debounce', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.restoreAllTimers()
})
it('应该延迟执行函数', () => {
const fn = vi.fn()
const debouncedFn = debounce(fn, 100)
debouncedFn()
expect(fn).not.toHaveBeenCalled()
vi.advanceTimersByTime(50)
expect(fn).not.toHaveBeenCalled()
vi.advanceTimersByTime(50)
expect(fn).toHaveBeenCalledTimes(1)
})
it('应该只执行最后一次调用', () => {
const fn = vi.fn()
const debouncedFn = debounce(fn, 100)
debouncedFn('a')
debouncedFn('b')
debouncedFn('c')
vi.advanceTimersByTime(100)
expect(fn).toHaveBeenCalledTimes(1)
expect(fn).toHaveBeenCalledWith('c')
})
})快照测试
快照测试用于捕获组件或数据的预期输出:
typescript
// utils/formatter.ts
export function formatUser(user: { name: string; email: string; age: number }) {
return {
displayName: user.name.toUpperCase(),
contact: user.email.toLowerCase(),
category: user.age < 18 ? 'minor' : 'adult',
formatted: `${user.name} <${user.email}>`
}
}typescript
// utils/formatter.test.ts
import { describe, it, expect } from 'vitest'
import { formatUser } from './formatter'
describe('formatUser', () => {
it('应该正确格式化成年用户', () => {
const result = formatUser({
name: 'Vue',
email: 'VUE@Example.COM',
age: 25
})
expect(result).toMatchSnapshot()
})
it('应该正确格式化未成年用户', () => {
const result = formatUser({
name: 'Vue',
email: 'vue@example.com',
age: 15
})
expect(result).toMatchSnapshot()
})
// 行内快照
it('应该返回正确的结构', () => {
const result = formatUser({
name: 'Test',
email: 'test@test.com',
age: 20
})
expect(result).toMatchInlineSnapshot(`
{
"category": "adult",
"contact": "test@test.com",
"displayName": "TEST",
"formatted": "Test <test@test.com>",
}
`)
})
})更新快照
bash
# 更新所有快照
npx vitest run --update
# 交互式更新快照
npx vitest watch --update测试覆盖率
配置覆盖率
typescript
// vite.config.ts
export default defineConfig({
test: {
coverage: {
provider: 'v8',
// 报告格式
reporter: ['text', 'json', 'html', 'lcov'],
// 覆盖率阈值
thresholds: {
lines: 80,
functions: 80,
branches: 70,
statements: 80
},
// 包含文件
include: ['src/**/*.{js,ts,vue}'],
// 排除文件
exclude: [
'src/main.ts',
'src/**/*.d.ts',
'src/**/*.spec.ts',
'src/**/*.test.ts'
]
}
}
})运行覆盖率报告
bash
# 生成覆盖率报告
npm run test:coverage
# 查看 HTML 报告
open coverage/index.html覆盖率指标说明
| 指标 | 说明 |
|---|---|
| Statements | 语句覆盖率 - 执行的语句比例 |
| Branches | 分支覆盖率 - 执行的条件分支比例 |
| Functions | 函数覆盖率 - 调用的函数比例 |
| Lines | 行覆盖率 - 执行的代码行比例 |
测试 Pinia Store
typescript
// stores/user.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
export const useUserStore = defineStore('user', () => {
const user = ref<{ id: number; name: string } | null>(null)
const isLoggedIn = computed(() => !!user.value)
function login(userData: { id: number; name: string }) {
user.value = userData
}
function logout() {
user.value = null
}
return { user, isLoggedIn, login, logout }
})typescript
// stores/user.test.ts
import { describe, it, expect, beforeEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useUserStore } from './user'
describe('User Store', () => {
beforeEach(() => {
// 每个测试创建新的 Pinia 实例
setActivePinia(createPinia())
})
it('初始状态应该为空', () => {
const store = useUserStore()
expect(store.user).toBeNull()
expect(store.isLoggedIn).toBe(false)
})
it('login 应该设置用户', () => {
const store = useUserStore()
store.login({ id: 1, name: 'Vue' })
expect(store.user).toEqual({ id: 1, name: 'Vue' })
expect(store.isLoggedIn).toBe(true)
})
it('logout 应该清除用户', () => {
const store = useUserStore()
store.login({ id: 1, name: 'Vue' })
store.logout()
expect(store.user).toBeNull()
expect(store.isLoggedIn).toBe(false)
})
})高级技巧
参数化测试
typescript
import { describe, it, expect } from 'vitest'
describe('参数化测试示例', () => {
it.each([
[1, 2, 3],
[2, 3, 5],
[10, 20, 30]
])('add(%i, %i) 应该等于 %i', (a, b, expected) => {
expect(add(a, b)).toBe(expected)
})
it.each([
{ input: 'hello', expected: 'HELLO' },
{ input: 'Vue', expected: 'VUE' },
{ input: '', expected: '' }
])('toUpperCase($input) = $expected', ({ input, expected }) => {
expect(input.toUpperCase()).toBe(expected)
})
})测试异步代码
typescript
import { describe, it, expect } from 'vitest'
describe('异步测试', () => {
it('应该等待 Promise 完成', async () => {
const result = await Promise.resolve('success')
expect(result).toBe('success')
})
it('应该使用 resolves/rejects', async () => {
await expect(Promise.resolve('success')).resolves.toBe('success')
await expect(Promise.reject(new Error('failed'))).rejects.toThrow('failed')
})
it('应该处理回调', () => {
return new Promise<void>((resolve) => {
setTimeout(() => {
expect(true).toBe(true)
resolve()
}, 100)
})
})
})测试环境变量
typescript
// utils/config.ts
export function getApiUrl() {
return import.meta.env.VITE_API_URL || 'http://localhost:3000'
}
export function isProduction() {
return import.meta.env.PROD
}typescript
// utils/config.test.ts
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
describe('配置工具', () => {
const originalEnv = { ...import.meta.env }
beforeEach(() => {
vi.stubEnv('VITE_API_URL', 'https://api.example.com')
})
afterEach(() => {
vi.unstubAllEnvs()
})
it('应该获取 API URL', () => {
const { getApiUrl } = await import('./config')
expect(getApiUrl()).toBe('https://api.example.com')
})
})常见问题
Q1: 如何测试 DOM 相关的代码?
确保配置了 environment: 'jsdom':
typescript
// vite.config.ts
export default defineConfig({
test: {
environment: 'jsdom'
}
})Q2: 测试中如何使用 setup 文件?
typescript
// tests/setup.ts
import { config } from '@vue/test-utils'
import { vi } from 'vitest'
// 全局 mock
vi.stubGlobal('IntersectionObserver', vi.fn())
// 全局组件配置
config.global.stubs = {}
// vite.config.ts
export default defineConfig({
test: {
setupFiles: ['./tests/setup.ts']
}
})Q3: 如何跳过某些测试?
typescript
// 跳过单个测试
it.skip('这个测试暂时跳过', () => {
// ...
})
// 跳过整个 describe
describe.skip('这个测试套件暂时跳过', () => {
// ...
})
// 只运行特定测试
it.only('只运行这个测试', () => {
// ...
})Q4: 测试运行很慢怎么办?
bash
# 并行运行测试
npx vitest run --threads
# 只运行特定文件
npx vitest run math.test.ts
# 使用测试隔离
npx vitest run --isolate=falseQ5: 如何调试测试?
bash
# 使用 VS Code 调试
# 在 launch.json 中添加配置
{
"type": "node",
"request": "launch",
"name": "Debug Tests",
"runtimeExecutable": "${workspaceFolder}/node_modules/.bin/vitest",
"runtimeArgs": ["run"],
"console": "integratedTerminal"
}相关资源
下一步
- 组件测试 - 学习组件测试技巧