前端测试入门教程——从单元测试到 E2E 测试
作者: CaoZH
日期: 2026-03-20
本文为原创教程
前端测试是保证代码质量的关键手段。很多前端开发者不写测试的原因是”太麻烦”或”不知道怎么测”。本文从实用角度出发,用最小的成本覆盖三种测试类型:单元测试 → 组件测试 → E2E 测试。
一、测试金字塔
1 2 3 4 5 6 7 8 9 10
| ╱╲ ╱ ╲ E2E 测试(少而精) ╱ ╲ 核心用户流程 ╱──────╲ ╱ ╲ 集成/组件测试(适量) ╱ ╲ 组件交互、API 调用 ╱────────────╲ ╱ ╲ 单元测试(多而快) ╱ ╲ 工具函数、业务逻辑 ╱──────────────────╲
|
二、单元测试(Vitest)
1 2 3 4 5 6 7
| { "scripts": { "test": "vitest", "test:coverage": "vitest --coverage" } }
|
测试工具函数
1 2 3 4 5 6 7 8 9 10 11 12 13
| export function formatPrice(price) { return `¥${Number(price).toFixed(2)}` }
export function truncate(str, length = 100) { if (!str) return '' return str.length > length ? str.slice(0, length) + '...' : str }
export function validateEmail(email) { return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) }
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31
| import { describe, it, expect } from 'vitest' import { formatPrice, truncate, validateEmail } from '../format'
describe('formatPrice', () => { it('格式化为人民币格式', () => { expect(formatPrice(100)).toBe('¥100.00') expect(formatPrice(99.9)).toBe('¥99.90') })
it('处理零值', () => { expect(formatPrice(0)).toBe('¥0.00') })
it('处理字符串输入', () => { expect(formatPrice('50.5')).toBe('¥50.50') }) })
describe('validateEmail', () => { it('验证有效的邮箱', () => { expect(validateEmail('test@example.com')).toBe(true) expect(validateEmail('user.name+tag@domain.co')).toBe(true) })
it('验证无效的邮箱', () => { expect(validateEmail('')).toBe(false) expect(validateEmail('not-email')).toBe(false) expect(validateEmail('@domain.com')).toBe(false) }) })
|
三、组件测试(Vue Test Utils + Vitest)
1
| npm install -D @vue/test-utils jsdom
|
1 2 3 4 5 6 7 8 9 10
| import { defineConfig } from 'vitest/config' import vue from '@vitejs/plugin-vue'
export default defineConfig({ plugins: [vue()], test: { environment: 'jsdom', }, })
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
| <!-- src/components/UserCard.vue --> <script setup> const props = defineProps({ user: { type: Object, required: true, }, })
const emit = defineEmits(['delete'])
function getRoleLabel(role) { const map = { admin: '管理员', user: '普通用户', guest: '访客' } return map[role] || '未知' } </script>
<template> <div class="user-card"> <h3>{{ user.name }}</h3> <p class="email">{{ user.email }}</p> <span class="role" :data-role="user.role">{{ getRoleLabel(user.role) }}</span> <button @click="emit('delete', user.id)" class="delete-btn">删除</button> </div> </template>
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31
| import { mount } from '@vue/test-utils' import { describe, it, expect } from 'vitest' import UserCard from '../UserCard.vue'
describe('UserCard', () => { const user = { id: 1, name: '张三', email: 'zhangsan@test.com', role: 'admin', }
it('渲染用户信息', () => { const wrapper = mount(UserCard, { props: { user } }) expect(wrapper.text()).toContain('张三') expect(wrapper.text()).toContain('zhangsan@test.com') })
it('角色标签显示正确', () => { const wrapper = mount(UserCard, { props: { user } }) expect(wrapper.find('.role').text()).toBe('管理员') })
it('点击删除按钮触发事件', async () => { const wrapper = mount(UserCard, { props: { user } }) await wrapper.find('.delete-btn').trigger('click') expect(wrapper.emitted('delete')).toBeTruthy() expect(wrapper.emitted('delete')[0]).toEqual([1]) }) })
|
四、E2E 测试(Playwright)
1 2
| npm install -D @playwright/test npx playwright install
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34
| import { test, expect } from '@playwright/test'
test.describe('登录流程', () => { test.beforeEach(async ({ page }) => { await page.goto('http://localhost:5173/login') })
test('正常登录', async ({ page }) => { await page.fill('[data-test="username"]', 'admin') await page.fill('[data-test="password"]', 'admin123') await page.click('[data-test="login-btn"]')
await expect(page).toHaveURL(/dashboard/) await expect(page.locator('[data-test="welcome"]')).toContainText('欢迎回来') })
test('密码错误显示提示', async ({ page }) => { await page.fill('[data-test="username"]', 'admin') await page.fill('[data-test="password"]', 'wrong') await page.click('[data-test="login-btn"]')
await expect(page.locator('[data-test="error-msg"]')).toBeVisible() await expect(page.locator('[data-test="error-msg"]')).toContainText('用户名或密码错误') })
test('空表单校验', async ({ page }) => { await page.click('[data-test="login-btn"]')
await expect(page.locator('[data-test="username-error"]')).toBeVisible() await expect(page.locator('[data-test="password-error"]')).toBeVisible() }) })
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| export default { testDir: './e2e', timeout: 30000, use: { baseURL: 'http://localhost:5173', screenshot: 'only-on-failure', trace: 'retain-on-failure', }, projects: [ { name: 'chromium', use: { browserName: 'chromium' } }, { name: 'firefox', use: { browserName: 'firefox' } }, { name: 'webkit', use: { browserName: 'webkit' } }, ], }
|
五、CI 集成
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34
| name: Frontend Tests
on: [push, pull_request]
jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 20
- run: npm ci
- run: npm run test
- name: Install Playwright run: npx playwright install --with-deps chromium
- name: Start dev server run: npm run dev &
- name: Run E2E tests run: npx playwright test
- uses: actions/upload-artifact@v4 if: failure() with: name: test-screenshots path: test-results/
|
六、总结
1 2 3 4 5 6 7 8 9 10 11 12 13
| ## 测试策略
| 测试类型 | 工具 | 重点 | 数量 | |---------|------|------|------| | 单元测试 | Vitest | 工具函数、逻辑 | 多(70%) | | 组件测试 | Vue Test Utils | 组件渲染、交互 | 中(20%) | | E2E 测试 | Playwright | 核心用户流程 | 少(10%) |
## 快速开始 ✅ 先给工具函数写单元测试 ✅ 给核心组件写组件测试 ✅ 给最重要的 5 个用户流程写 E2E 测试 ✅ 集成到 CI,每次 push 自动运行
|
首发于 CaoZH 的笔记