前端测试入门教程——从单元测试到 E2E 测试

前端测试入门教程——从单元测试到 E2E 测试

作者: CaoZH
日期: 2026-03-20
本文为原创教程


前端测试是保证代码质量的关键手段。很多前端开发者不写测试的原因是”太麻烦”或”不知道怎么测”。本文从实用角度出发,用最小的成本覆盖三种测试类型:单元测试 → 组件测试 → E2E 测试

一、测试金字塔

1
2
3
4
5
6
7
8
9
10
        ╱╲
╱ ╲ E2E 测试(少而精)
╱ ╲ 核心用户流程
╱──────╲
╱ ╲ 集成/组件测试(适量)
╱ ╲ 组件交互、API 调用
╱────────────╲
╱ ╲ 单元测试(多而快)
╱ ╲ 工具函数、业务逻辑
╱──────────────────╲

二、单元测试(Vitest)

1
2
# 安装
npm install -D vitest
1
2
3
4
5
6
7
// package.json
{
"scripts": {
"test": "vitest",
"test:coverage": "vitest --coverage"
}
}

测试工具函数

1
2
3
4
5
6
7
8
9
10
11
12
13
// src/utils/format.js
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
// src/utils/__tests__/format.test.js
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
// vitest.config.js
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
// src/components/__tests__/UserCard.test.js
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
// e2e/login.spec.js
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
// playwright.config.js
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
# .github/workflows/test.yml
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

# E2E 测试
- 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 的笔记