第6章:测试与质量
掌握现代前端测试策略,确保代码质量和应用稳定性
📚 本章目标
通过本章学习,你将掌握:
- Vitest 单元测试框架
- Vue Test Utils 组件测试
- Playwright 端到端测试
- 测试驱动开发 (TDD)
- 测试最佳实践
🧪 6.1 测试基础
6.1.1 测试金字塔
/\
/ \
/ E2E \ <- 少量,覆盖关键用户流程
/______\
/ \
/ 集成测试 \ <- 中等数量,测试模块间交互
/____________\
/ \
/ 单元测试 \ <- 大量,测试单个函数/组件
/________________\
6.1.2 测试类型
- 单元测试:测试单个函数或组件
- 集成测试:测试多个模块的交互
- 端到端测试:测试完整的用户流程
- 视觉回归测试:测试 UI 变化
⚡ 6.2 Vitest 单元测试
6.2.1 环境配置
# 安装依赖
pnpm add -D vitest @vue/test-utils jsdom @testing-library/vue
// vitest.config.ts
import { defineConfig } from 'vitest/config'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
export default defineConfig({
plugins: [vue()],
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./test/setup.ts']
},
resolve: {
alias: {
'@': resolve(__dirname, 'src')
}
}
})
// test/setup.ts
import { config } from '@vue/test-utils'
// 全局配置
config.global.stubs = {
'router-link': true,
'router-view': true
}
// 模拟全局属性
config.global.mocks = {
$t: (key: string) => key
}
6.2.2 基础测试
// test/utils.test.ts
import { describe, it, expect } from 'vitest'
// 工具函数测试
export function formatDate(date: Date): string {
return date.toLocaleDateString('zh-CN')
}
export function calculateTotal(items: Array<{ price: number; quantity: number }>): number {
return items.reduce((total, item) => total + item.price * item.quantity, 0)
}
describe('工具函数测试', () => {
describe('formatDate', () => {
it('应该正确格式化日期', () => {
const date = new Date('2024-01-01')
expect(formatDate(date)).toBe('2024/1/1')
})
})
describe('calculateTotal', () => {
it('应该正确计算总价', () => {
const items = [
{ price: 10, quantity: 2 },
{ price: 5, quantity: 3 }
]
expect(calculateTotal(items)).toBe(35)
})
it('应该处理空数组', () => {
expect(calculateTotal([])).toBe(0)
})
})
})
6.2.3 异步测试
// test/api.test.ts
import { describe, it, expect, vi } from 'vitest'
// 模拟 API 函数
export async function fetchUser(id: string) {
const response = await fetch(`/api/users/${id}`)
if (!response.ok) {
throw new Error('用户不存在')
}
return response.json()
}
describe('API 测试', () => {
it('应该成功获取用户信息', async () => {
// 模拟 fetch
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ id: '1', name: '张三' })
})
const user = await fetchUser('1')
expect(user).toEqual({ id: '1', name: '张三' })
expect(fetch).toHaveBeenCalledWith('/api/users/1')
})
it('应该处理错误情况', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: false
})
await expect(fetchUser('999')).rejects.toThrow('用户不存在')
})
})
🧩 6.3 Vue 组件测试
6.3.1 基础组件测试
<!-- src/components/Counter.vue -->
<template>
<div class="counter">
<button @click="decrement" :disabled="count <= 0">-</button>
<span class="count">{{ count }}</span>
<button @click="increment">+</button>
<button @click="reset">重置</button>
</div>
</template>
<script setup lang="ts">
interface Props {
initialValue?: number
max?: number
min?: number
}
const props = withDefaults(defineProps<Props>(), {
initialValue: 0,
max: Infinity,
min: 0
})
const emit = defineEmits<{
change: [value: number]
reset: []
}>()
const count = ref(props.initialValue)
const increment = () => {
if (count.value < props.max) {
count.value++
emit('change', count.value)
}
}
const decrement = () => {
if (count.value > props.min) {
count.value--
emit('change', count.value)
}
}
const reset = () => {
count.value = props.initialValue
emit('reset')
}
</script>
// test/components/Counter.test.ts
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import Counter from '@/components/Counter.vue'
describe('Counter 组件', () => {
it('应该正确渲染初始值', () => {
const wrapper = mount(Counter, {
props: { initialValue: 5 }
})
expect(wrapper.find('.count').text()).toBe('5')
})
it('应该增加计数', async () => {
const wrapper = mount(Counter)
await wrapper.find('button:last-of-type').trigger('click')
expect(wrapper.find('.count').text()).toBe('1')
})
it('应该减少计数', async () => {
const wrapper = mount(Counter, {
props: { initialValue: 5 }
})
await wrapper.find('button:first-of-type').trigger('click')
expect(wrapper.find('.count').text()).toBe('4')
})
it('应该触发 change 事件', async () => {
const wrapper = mount(Counter)
await wrapper.find('button:last-of-type').trigger('click')
expect(wrapper.emitted('change')).toBeTruthy()
expect(wrapper.emitted('change')?.[0]).toEqual([1])
})
it('应该禁用减少按钮当计数为最小值', () => {
const wrapper = mount(Counter, {
props: { initialValue: 0, min: 0 }
})
const decrementBtn = wrapper.find('button:first-of-type')
expect(decrementBtn.attributes('disabled')).toBeDefined()
})
it('应该重置计数', async () => {
const wrapper = mount(Counter, {
props: { initialValue: 10 }
})
// 先改变值
await wrapper.find('button:last-of-type').trigger('click')
expect(wrapper.find('.count').text()).toBe('11')
// 重置
await wrapper.find('button[class*="reset"]').trigger('click')
expect(wrapper.find('.count').text()).toBe('10')
expect(wrapper.emitted('reset')).toBeTruthy()
})
})
6.3.2 复杂组件测试
<!-- src/components/UserForm.vue -->
<template>
<form @submit.prevent="handleSubmit" class="user-form">
<div class="form-group">
<label for="name">姓名</label>
<input
id="name"
v-model="form.name"
type="text"
required
:class="{ error: errors.name }"
/>
<span v-if="errors.name" class="error-message">{{ errors.name }}</span>
</div>
<div class="form-group">
<label for="email">邮箱</label>
<input
id="email"
v-model="form.email"
type="email"
required
:class="{ error: errors.email }"
/>
<span v-if="errors.email" class="error-message">{{ errors.email }}</span>
</div>
<div class="form-group">
<label for="age">年龄</label>
<input
id="age"
v-model.number="form.age"
type="number"
min="1"
max="120"
required
:class="{ error: errors.age }"
/>
<span v-if="errors.age" class="error-message">{{ errors.age }}</span>
</div>
<button type="submit" :disabled="isSubmitting">
{{ isSubmitting ? '提交中...' : '提交' }}
</button>
</form>
</template>
<script setup lang="ts">
interface UserForm {
name: string
email: string
age: number
}
interface Props {
initialData?: Partial<UserForm>
}
const props = withDefaults(defineProps<Props>(), {
initialData: () => ({})
})
const emit = defineEmits<{
submit: [data: UserForm]
}>()
const form = reactive<UserForm>({
name: '',
email: '',
age: 0,
...props.initialData
})
const errors = reactive<Partial<Record<keyof UserForm, string>>>({})
const isSubmitting = ref(false)
const validateForm = (): boolean => {
Object.keys(errors).forEach(key => delete errors[key as keyof UserForm])
if (!form.name.trim()) {
errors.name = '姓名不能为空'
}
if (!form.email.trim()) {
errors.email = '邮箱不能为空'
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email)) {
errors.email = '邮箱格式不正确'
}
if (!form.age || form.age < 1 || form.age > 120) {
errors.age = '年龄必须在1-120之间'
}
return Object.keys(errors).length === 0
}
const handleSubmit = async () => {
if (!validateForm()) return
isSubmitting.value = true
try {
emit('submit', { ...form })
} finally {
isSubmitting.value = false
}
}
</script>
// test/components/UserForm.test.ts
import { describe, it, expect, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import UserForm from '@/components/UserForm.vue'
describe('UserForm 组件', () => {
it('应该正确渲染表单', () => {
const wrapper = mount(UserForm)
expect(wrapper.find('input[name="name"]').exists()).toBe(true)
expect(wrapper.find('input[name="email"]').exists()).toBe(true)
expect(wrapper.find('input[name="age"]').exists()).toBe(true)
expect(wrapper.find('button[type="submit"]').exists()).toBe(true)
})
it('应该显示初始数据', () => {
const initialData = {
name: '张三',
email: 'zhangsan@example.com',
age: 25
}
const wrapper = mount(UserForm, {
props: { initialData }
})
expect(wrapper.find('input[name="name"]').element.value).toBe('张三')
expect(wrapper.find('input[name="email"]').element.value).toBe('zhangsan@example.com')
expect(wrapper.find('input[name="age"]').element.value).toBe('25')
})
it('应该验证必填字段', async () => {
const wrapper = mount(UserForm)
await wrapper.find('form').trigger('submit')
expect(wrapper.find('.error-message').text()).toContain('姓名不能为空')
})
it('应该验证邮箱格式', async () => {
const wrapper = mount(UserForm)
await wrapper.find('input[name="name"]').setValue('张三')
await wrapper.find('input[name="email"]').setValue('invalid-email')
await wrapper.find('input[name="age"]').setValue('25')
await wrapper.find('form').trigger('submit')
expect(wrapper.find('.error-message').text()).toContain('邮箱格式不正确')
})
it('应该验证年龄范围', async () => {
const wrapper = mount(UserForm)
await wrapper.find('input[name="name"]').setValue('张三')
await wrapper.find('input[name="email"]').setValue('zhangsan@example.com')
await wrapper.find('input[name="age"]').setValue('150')
await wrapper.find('form').trigger('submit')
expect(wrapper.find('.error-message').text()).toContain('年龄必须在1-120之间')
})
it('应该提交有效数据', async () => {
const wrapper = mount(UserForm)
await wrapper.find('input[name="name"]').setValue('张三')
await wrapper.find('input[name="email"]').setValue('zhangsan@example.com')
await wrapper.find('input[name="age"]').setValue('25')
await wrapper.find('form').trigger('submit')
expect(wrapper.emitted('submit')).toBeTruthy()
expect(wrapper.emitted('submit')?.[0]).toEqual([{
name: '张三',
email: 'zhangsan@example.com',
age: 25
}])
})
it('应该在提交时禁用按钮', async () => {
const wrapper = mount(UserForm)
await wrapper.find('input[name="name"]').setValue('张三')
await wrapper.find('input[name="email"]').setValue('zhangsan@example.com')
await wrapper.find('input[name="age"]').setValue('25')
const submitBtn = wrapper.find('button[type="submit"]')
expect(submitBtn.attributes('disabled')).toBeUndefined()
await wrapper.find('form').trigger('submit')
expect(submitBtn.attributes('disabled')).toBeDefined()
})
})
6.3.3 组合式函数测试
// src/composables/useCounter.ts
export function useCounter(initialValue = 0) {
const count = ref(initialValue)
const increment = () => count.value++
const decrement = () => count.value--
const reset = () => count.value = initialValue
return {
count: readonly(count),
increment,
decrement,
reset
}
}
// test/composables/useCounter.test.ts
import { describe, it, expect } from 'vitest'
import { useCounter } from '@/composables/useCounter'
describe('useCounter', () => {
it('应该返回初始值', () => {
const { count } = useCounter(5)
expect(count.value).toBe(5)
})
it('应该增加计数', () => {
const { count, increment } = useCounter()
increment()
expect(count.value).toBe(1)
})
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)
})
})
🎭 6.4 Playwright 端到端测试
6.4.1 环境配置
# 安装 Playwright
pnpm add -D @playwright/test
npx playwright install
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test'
export default defineConfig({
testDir: './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:3000',
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: 'pnpm dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
},
})
6.4.2 基础 E2E 测试
// e2e/homepage.spec.ts
import { test, expect } from '@playwright/test'
test.describe('首页测试', () => {
test('应该正确加载首页', async ({ page }) => {
await page.goto('/')
// 检查页面标题
await expect(page).toHaveTitle(/Vue App/)
// 检查关键元素
await expect(page.locator('h1')).toBeVisible()
await expect(page.locator('nav')).toBeVisible()
})
test('应该能够导航到关于页面', async ({ page }) => {
await page.goto('/')
// 点击导航链接
await page.click('a[href="/about"]')
// 验证页面跳转
await expect(page).toHaveURL('/about')
await expect(page.locator('h1')).toContainText('关于我们')
})
})
6.4.3 表单交互测试
// e2e/contact.spec.ts
import { test, expect } from '@playwright/test'
test.describe('联系表单测试', () => {
test('应该能够提交联系表单', async ({ page }) => {
await page.goto('/contact')
// 填写表单
await page.fill('input[name="name"]', '张三')
await page.fill('input[name="email"]', 'zhangsan@example.com')
await page.fill('textarea[name="message"]', '这是一条测试消息')
// 提交表单
await page.click('button[type="submit"]')
// 验证成功消息
await expect(page.locator('.success-message')).toBeVisible()
await expect(page.locator('.success-message')).toContainText('消息发送成功')
})
test('应该显示验证错误', async ({ page }) => {
await page.goto('/contact')
// 直接提交空表单
await page.click('button[type="submit"]')
// 验证错误消息
await expect(page.locator('.error-message')).toBeVisible()
await expect(page.locator('.error-message')).toContainText('请填写所有必填字段')
})
})
6.4.4 用户认证流程测试
// e2e/auth.spec.ts
import { test, expect } from '@playwright/test'
test.describe('用户认证测试', () => {
test('应该能够登录', async ({ page }) => {
await page.goto('/login')
// 填写登录表单
await page.fill('input[name="email"]', 'test@example.com')
await page.fill('input[name="password"]', 'password123')
// 提交登录
await page.click('button[type="submit"]')
// 验证登录成功
await expect(page).toHaveURL('/dashboard')
await expect(page.locator('.user-menu')).toBeVisible()
})
test('应该能够登出', async ({ page }) => {
// 先登录
await page.goto('/login')
await page.fill('input[name="email"]', 'test@example.com')
await page.fill('input[name="password"]', 'password123')
await page.click('button[type="submit"]')
// 登出
await page.click('.user-menu')
await page.click('button:has-text("登出")')
// 验证登出成功
await expect(page).toHaveURL('/login')
})
test('应该保护需要认证的页面', async ({ page }) => {
// 直接访问受保护的页面
await page.goto('/dashboard')
// 应该重定向到登录页
await expect(page).toHaveURL('/login')
})
})
🎨 6.5 视觉回归测试
6.5.1 配置 Chromatic
# 安装 Chromatic
pnpm add -D chromatic
// chromatic.config.js
module.exports = {
projectToken: process.env.CHROMATIC_PROJECT_TOKEN,
buildScriptName: 'build-storybook',
storybookBuildDir: 'storybook-static',
exitZeroOnChanges: true,
exitOnceUploaded: true,
}
6.5.2 组件快照测试
// test/components/Button.spec.ts
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import Button from '@/components/Button.vue'
describe('Button 组件快照测试', () => {
it('应该匹配默认按钮快照', () => {
const wrapper = mount(Button, {
slots: { default: '点击我' }
})
expect(wrapper.html()).toMatchSnapshot()
})
it('应该匹配主要按钮快照', () => {
const wrapper = mount(Button, {
props: { variant: 'primary' },
slots: { default: '主要按钮' }
})
expect(wrapper.html()).toMatchSnapshot()
})
it('应该匹配禁用按钮快照', () => {
const wrapper = mount(Button, {
props: { disabled: true },
slots: { default: '禁用按钮' }
})
expect(wrapper.html()).toMatchSnapshot()
})
})
🚀 6.6 测试驱动开发 (TDD)
6.6.1 TDD 流程
- Red:写一个失败的测试
- Green:写最少的代码让测试通过
- Refactor:重构代码,保持测试通过
6.6.2 TDD 示例
// test/utils/calculator.test.ts
import { describe, it, expect } from 'vitest'
import { Calculator } from '@/utils/calculator'
describe('Calculator TDD 示例', () => {
let calculator: Calculator
beforeEach(() => {
calculator = new Calculator()
})
// Red: 写失败的测试
it('应该能够相加两个数字', () => {
expect(calculator.add(2, 3)).toBe(5)
})
it('应该能够相减两个数字', () => {
expect(calculator.subtract(5, 3)).toBe(2)
})
it('应该能够相乘两个数字', () => {
expect(calculator.multiply(4, 3)).toBe(12)
})
it('应该能够相除两个数字', () => {
expect(calculator.divide(10, 2)).toBe(5)
})
it('应该处理除零错误', () => {
expect(() => calculator.divide(10, 0)).toThrow('不能除以零')
})
})
// src/utils/calculator.ts
export class Calculator {
add(a: number, b: number): number {
return a + b
}
subtract(a: number, b: number): number {
return a - b
}
multiply(a: number, b: number): number {
return a * b
}
divide(a: number, b: number): number {
if (b === 0) {
throw new Error('不能除以零')
}
return a / b
}
}
📊 6.7 测试覆盖率
6.7.1 配置覆盖率
// vitest.config.ts
export default defineConfig({
test: {
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
exclude: [
'node_modules/',
'test/',
'**/*.d.ts',
'**/*.config.*'
],
thresholds: {
global: {
branches: 80,
functions: 80,
lines: 80,
statements: 80
}
}
}
}
})
6.7.2 覆盖率报告
# 生成覆盖率报告
pnpm test:coverage
# 查看 HTML 报告
open coverage/index.html
🎯 6.8 测试最佳实践
6.8.1 测试命名
// ✅ 好的测试命名
describe('UserService', () => {
describe('createUser', () => {
it('应该创建新用户并返回用户信息', () => {})
it('应该在邮箱已存在时抛出错误', () => {})
it('应该验证邮箱格式', () => {})
})
})
// ❌ 不好的测试命名
describe('UserService', () => {
it('test1', () => {})
it('should work', () => {})
})
6.8.2 测试结构
// AAA 模式:Arrange, Act, Assert
describe('用户登录', () => {
it('应该成功登录有效用户', async () => {
// Arrange: 准备测试数据
const user = { email: 'test@example.com', password: 'password123' }
const mockApi = vi.fn().mockResolvedValue({ token: 'abc123' })
// Act: 执行被测试的操作
const result = await loginUser(user, mockApi)
// Assert: 验证结果
expect(result.token).toBe('abc123')
expect(mockApi).toHaveBeenCalledWith('/login', user)
})
})
6.8.3 模拟和存根
// 模拟外部依赖
describe('用户服务', () => {
it('应该获取用户列表', async () => {
// 模拟 API 响应
const mockUsers = [
{ id: 1, name: '张三' },
{ id: 2, name: '李四' }
]
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockUsers)
})
const users = await fetchUsers()
expect(users).toEqual(mockUsers)
})
})
🎯 本章总结
通过本章学习,你掌握了:
- 测试基础:测试金字塔、测试类型
- Vitest 单元测试:环境配置、基础测试、异步测试
- Vue 组件测试:组件测试、组合式函数测试
- Playwright E2E 测试:环境配置、用户流程测试
- 视觉回归测试:快照测试、Chromatic 集成
- 测试驱动开发:TDD 流程和实践
- 测试最佳实践:命名规范、测试结构、模拟技巧
📝 练习题
- 为现有的工具函数编写单元测试,确保覆盖率超过 80%
- 为 Vue 组件编写完整的测试套件,包括边界情况
- 使用 Playwright 编写用户注册和登录的 E2E 测试
- 实现一个计算器组件,使用 TDD 方法开发
- 配置测试覆盖率报告,并优化测试覆盖率