前端测试体系
大约 7 分钟约 2217 字
前端测试体系
为什么要做前端测试?
前端测试的核心目的是在代码变更时快速验证功能正确性,为重构提供安全网,减少线上 Bug 的发生概率。一个完善的测试体系不是追求 100% 的覆盖率数字,而是通过合理的分层策略,用最小的维护成本覆盖最重要的业务逻辑。
测试金字塔
前端测试遵循经典的测试金字塔模型:
/ E2E 测试 \ ← 少量:覆盖关键用户路径
/ 组件测试 \ ← 适量:验证组件交互行为
/ 集成测试 \ ← 适量:验证模块间协作
/ 单元测试 \ ← 大量:测试工具函数和纯逻辑| 测试层级 | 工具 | 执行速度 | 维护成本 | 覆盖范围 |
|---|---|---|---|---|
| 单元测试 | Vitest/Jest | 毫秒级 | 低 | 纯函数、工具方法、Hook |
| 组件测试 | Testing Library | 秒级 | 中 | 组件渲染、用户交互 |
| E2E 测试 | Playwright/Cypress | 分钟级 | 高 | 完整用户流程 |
单元测试 — Vitest
Vitest 是 Vite 生态的原生测试框架,API 兼容 Jest,但速度更快,配置更简单。
基础配置
// vitest.config.ts
import { defineConfig } from 'vitest/config';
import vue from '@vitejs/plugin-vue';
export default defineConfig({
plugins: [vue()],
test: {
globals: true, // 全局 API(describe、it、expect)
environment: 'jsdom', // DOM 环境
setupFiles: ['./src/test/setup.ts'],
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
exclude: ['node_modules/', 'src/test/', '*.d.ts'],
thresholds: {
lines: 80, // 最低行覆盖率
functions: 80,
branches: 70,
},
},
},
});
// src/test/setup.ts — 测试全局配置
import '@testing-library/jest-dom/vitest'; // 扩展 expect 匹配器工具函数测试
// src/utils/format.test.ts
import { describe, it, expect, vi } from 'vitest';
import { formatDate, formatCurrency, truncate, debounce } from './format';
describe('formatDate', () => {
it('格式化日期为 YYYY-MM-DD', () => {
const date = new Date('2024-01-15T10:30:00');
expect(formatDate(date)).toBe('2024-01-15');
});
it('处理无效输入返回空字符串', () => {
expect(formatDate(null as any)).toBe('');
expect(formatDate(undefined as any)).toBe('');
});
it('处理时间戳输入', () => {
expect(formatDate(1705276800000)).toBe('2024-01-15');
});
});
describe('formatCurrency', () => {
it('格式化金额保留两位小数', () => {
expect(formatCurrency(1234.5)).toBe('¥1,234.50');
});
it('处理负数', () => {
expect(formatCurrency(-100)).toBe('-¥100.00');
});
it('处理零', () => {
expect(formatCurrency(0)).toBe('¥0.00');
});
});
describe('truncate', () => {
it('截断超长字符串', () => {
expect(truncate('Hello World', 5)).toBe('Hello...');
});
it('短字符串不截断', () => {
expect(truncate('Hi', 5)).toBe('Hi');
});
});
describe('debounce', () => {
it('延迟执行并只执行最后一次', async () => {
vi.useFakeTimers();
const fn = vi.fn();
const debounced = debounce(fn, 300);
debounced('a');
debounced('b');
debounced('c');
expect(fn).not.toHaveBeenCalled();
vi.advanceTimersByTime(300);
expect(fn).toHaveBeenCalledTimes(1);
expect(fn).toHaveBeenCalledWith('c');
vi.useRealTimers();
});
});数据处理函数测试
// src/utils/dataProcessing.test.ts
import { describe, it, expect } from 'vitest';
import { groupBy, uniqueBy, sortBy, paginate, treeToList, listToTree } from './dataProcessing';
describe('groupBy', () => {
it('按指定字段分组', () => {
const users = [
{ name: '张三', role: 'admin' },
{ name: '李四', role: 'user' },
{ name: '王五', role: 'admin' },
];
const result = groupBy(users, 'role');
expect(result).toEqual({
admin: [
{ name: '张三', role: 'admin' },
{ name: '王五', role: 'admin' },
],
user: [{ name: '李四', role: 'user' }],
});
});
});
describe('paginate', () => {
it('分页计算', () => {
const items = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
expect(paginate(items, 1, 3)).toEqual([1, 2, 3]);
expect(paginate(items, 2, 3)).toEqual([4, 5, 6]);
expect(paginate(items, 4, 3)).toEqual([10]);
});
});
describe('listToTree', () => {
it('将扁平列表转为树结构', () => {
const list = [
{ id: 1, parentId: null, name: '根节点' },
{ id: 2, parentId: 1, name: '子节点1' },
{ id: 3, parentId: 1, name: '子节点2' },
{ id: 4, parentId: 2, name: '孙节点' },
];
const tree = listToTree(list);
expect(tree[0].name).toBe('根节点');
expect(tree[0].children[0].name).toBe('子节点1');
expect(tree[0].children[0].children[0].name).toBe('孙节点');
});
});组件测试 — Testing Library
React Testing Library 的核心理念是:测试用户看到的行为,而不是组件的实现细节。不要测试组件内部 state,而是测试 DOM 输出和用户交互。
React 组件测试
// UserList.test.tsx
import { render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { UserList } from './UserList';
// Mock API
vi.mock('./api', () => ({
fetchUsers: vi.fn().mockResolvedValue([
{ id: 1, name: '张三', role: 'admin', status: 'active' },
{ id: 2, name: '李四', role: 'user', status: 'inactive' },
]),
}));
describe('UserList', () => {
it('加载并显示用户列表', async () => {
render(<UserList />);
// 加载状态
expect(screen.getByText('加载中...')).toBeInTheDocument();
// 等待数据加载
await waitFor(() => {
expect(screen.getByText('张三')).toBeInTheDocument();
});
// 验证列表完整性
expect(screen.getByText('李四')).toBeInTheDocument();
expect(screen.getAllByRole('row')).toHaveLength(3); // 表头 + 2 行数据
});
it('搜索过滤用户', async () => {
const user = userEvent.setup();
render(<UserList />);
await waitFor(() => {
expect(screen.getByText('张三')).toBeInTheDocument();
});
// 搜索
await user.type(screen.getByPlaceholderText('搜索用户名'), '张');
await waitFor(() => {
expect(screen.getByText('张三')).toBeInTheDocument();
expect(screen.queryByText('李四')).not.toBeInTheDocument();
});
});
it('删除用户', async () => {
const user = userEvent.setup();
render(<UserList />);
await waitFor(() => {
expect(screen.getByText('张三')).toBeInTheDocument();
});
// 点击删除按钮
await user.click(screen.getByLabelText('删除张三'));
// 确认删除
await user.click(screen.getByRole('button', { name: '确认' }));
await waitFor(() => {
expect(screen.queryByText('张三')).not.toBeInTheDocument();
});
});
it('空状态展示', async () => {
vi.mocked(fetchUsers).mockResolvedValueOnce([]);
render(<UserList />);
await waitFor(() => {
expect(screen.getByText('暂无数据')).toBeInTheDocument();
});
});
});Vue 组件测试
// DeviceList.test.ts
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/vue';
import DeviceList from './DeviceList.vue';
// Mock Pinia Store
vi.mock('@/stores/deviceStore', () => ({
useDeviceStore: () => ({
devices: [
{ id: '1', name: '设备A', status: 'online' },
{ id: '2', name: '设备B', status: 'offline' },
],
loading: false,
fetchDevices: vi.fn(),
selectDevice: vi.fn(),
}),
}));
describe('DeviceList', () => {
it('渲染设备列表', () => {
render(DeviceList);
expect(screen.getByText('设备A')).toBeInTheDocument();
expect(screen.getByText('设备B')).toBeInTheDocument();
});
it('点击设备触发选中', async () => {
const { container } = render(DeviceList);
const store = useDeviceStore();
await fireEvent.click(screen.getByText('设备A'));
expect(store.selectDevice).toHaveBeenCalledWith('1');
});
});Mock 技巧
Mock API 请求 — MSW
MSW(Mock Service Worker)在浏览器和 Node.js 环境中拦截网络请求,是模拟 API 的最佳方案。
// src/test/mocks/handlers.ts
import { http, HttpResponse } from 'msw';
export const handlers = [
http.get('/api/users', () => {
return HttpResponse.json([
{ id: 1, name: '张三', role: 'admin' },
{ id: 2, name: '李四', role: 'user' },
]);
}),
http.post('/api/login', async ({ request }) => {
const body = await request.json() as { username: string; password: string };
if (body.username === 'admin' && body.password === '123456') {
return HttpResponse.json({ token: 'mock-token', user: { id: 1, name: '管理员' } });
}
return HttpResponse.json({ error: '用户名或密码错误' }, { status: 401 });
}),
http.get('/api/devices/:id', ({ params }) => {
return HttpResponse.json({
id: params.id,
name: '测试设备',
status: 'online',
});
}),
];// src/test/setup.ts
import { setupServer } from 'msw/node';
import { handlers } from './mocks/handlers';
export const server = setupServer(...handlers);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());// 测试中使用 — 修改特定测试的 Mock
import { http, HttpResponse } from 'msw';
import { server } from './setup';
describe('Error Handling', () => {
it('显示错误提示', async () => {
// 覆盖默认 handler
server.use(
http.get('/api/users', () => {
return HttpResponse.json({ error: '服务器错误' }, { status: 500 });
})
);
render(<UserList />);
await waitFor(() => {
expect(screen.getByText('加载失败,请重试')).toBeInTheDocument();
});
});
});Mock 定时器
describe('轮询逻辑', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('每30秒轮询一次数据', () => {
const fetchData = vi.fn();
const { result } = renderHook(() => usePolling(fetchData, 30000));
// 初始调用
expect(fetchData).toHaveBeenCalledTimes(1);
// 30秒后再次调用
vi.advanceTimersByTime(30000);
expect(fetchData).toHaveBeenCalledTimes(2);
// 60秒后第三次
vi.advanceTimersByTime(30000);
expect(fetchData).toHaveBeenCalledTimes(3);
});
});E2E 测试 — Playwright
Playwright 是微软推出的 E2E 测试框架,支持多浏览器并行测试,API 设计简洁。
基础 E2E 测试
// e2e/login.spec.ts
import { test, expect } from '@playwright/test';
test.describe('登录流程', () => {
test('正常登录跳转到仪表盘', async ({ page }) => {
await page.goto('/login');
// 填写表单
await page.fill('[data-testid="username-input"]', 'admin');
await page.fill('[data-testid="password-input"]', '123456');
await page.click('[data-testid="login-button"]');
// 验证跳转
await expect(page).toHaveURL('/dashboard');
await expect(page.locator('[data-testid="user-name"]')).toHaveText('管理员');
});
test('空用户名显示验证错误', async ({ page }) => {
await page.goto('/login');
await page.click('[data-testid="login-button"]');
await expect(page.locator('[data-testid="username-error"]')).toHaveText('请输入用户名');
});
test('密码错误显示提示', async ({ page }) => {
await page.goto('/login');
await page.fill('[data-testid="username-input"]', 'admin');
await page.fill('[data-testid="password-input"]', 'wrong');
await page.click('[data-testid="login-button"]');
await expect(page.locator('[data-testid="login-error"]')).toBeVisible();
});
});视觉回归测试
// e2e/visual.spec.ts
test('首页截图对比', async ({ page }) => {
await page.goto('/');
await expect(page).toHaveScreenshot('homepage.png', {
maxDiffPixels: 100, // 允许的最大像素差异
threshold: 0.01, // 允许 1% 的颜色差异
});
});
// 不同主题的截图
test('暗色模式截图', async ({ page }) => {
await page.goto('/');
await page.click('[data-testid="theme-toggle"]');
await expect(page).toHaveScreenshot('homepage-dark.png');
});Page Object Model
// e2e/pages/LoginPage.ts
import { type Page, type Locator } from '@playwright/test';
export class LoginPage {
readonly page: Page;
readonly usernameInput: Locator;
readonly passwordInput: Locator;
readonly submitButton: Locator;
readonly errorMessage: Locator;
constructor(page: Page) {
this.page = page;
this.usernameInput = page.locator('[data-testid="username-input"]');
this.passwordInput = page.locator('[data-testid="password-input"]');
this.submitButton = page.locator('[data-testid="login-button"]');
this.errorMessage = page.locator('[data-testid="login-error"]');
}
async goto() {
await this.page.goto('/login');
}
async login(username: string, password: string) {
await this.usernameInput.fill(username);
await this.passwordInput.fill(password);
await this.submitButton.click();
}
}
// 使用 POM
test('登录流程', async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login('admin', '123456');
await expect(page).toHaveURL('/dashboard');
});CI 集成
GitHub Actions 配置
# .github/workflows/test.yml
name: Test
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- run: npm ci
- run: npm run lint
- run: npm run test -- --coverage
- run: npm run test:e2e
# 上传覆盖率报告
- name: Upload coverage
uses: codecov/codecov-action@v3
with:
token: ${{ secrets.CODECOV_TOKEN }}常见误区
误区 1:追求 100% 覆盖率
覆盖率数字不等于测试质量。以下代码虽然 100% 覆盖,但测试毫无价值:
// Bad — 只为了覆盖率写测试
describe('Counter', () => {
it('renders', () => {
render(<Counter />);
expect(true).toBe(true); // 无意义的断言
});
});误区 2:测试实现细节
// Bad — 测试内部 state
expect(component.state('count')).toBe(1);
// Good — 测试用户看到的内容
expect(screen.getByText('Count: 1')).toBeInTheDocument();误区 3:E2E 测试过于脆弱
// Bad — 依赖精确的 CSS 类名和延迟
await page.waitForTimeout(3000);
await page.click('.ant-btn-primary.ant-btn-lg');
// Good — 使用 data-testid 和等待条件
await page.click('[data-testid="submit-button"]');
await expect(page.locator('[data-testid="success-message"]')).toBeVisible();最佳实践总结
- 测试分层 — 大量单元测试 + 适量组件测试 + 少量 E2E 测试
- 测试行为而非实现 — 关注用户看到什么、能做什么
- 使用 MSW 模拟 API — 比 jest.mock 更接近真实网络行为
- E2E 用 data-testid — 不依赖 CSS 类名和 DOM 结构
- CI 集成 — 每次提交自动运行测试
- 覆盖率是参考 — 关注核心业务逻辑的测试质量
