每天一个高级前端知识 - Day 14
今日主题:前端测试策略 - 单元测试、组件测试、E2E测试的完整实践
核心概念:测试金字塔与分层策略
测试不是越多越好,而是需要合理的分层和投资回报率。
/\
/ \ E2E测试 (少量,慢,贵)
/____\ 🎯 关键用户流程
/ \
/ 集成测试 \ (中等,中等)
/__________\ 🎯 组件交互、API集成
/ \
/ 单元测试 \ (大量,快,便宜)
/______________\ 🎯 函数、算法、边界条件
🎯 单元测试(Vitest/Jest)
// utils/math.ts
export function add(a: number, b: number): number {
if (typeof a !== 'number' || typeof b !== 'number') {
throw new Error('参数必须是数字');
}
return a + b;
}
export function factorial(n: number): number {
if (n < 0) throw new Error('负数没有阶乘');
if (n === 0 || n === 1) return 1;
return n * factorial(n - 1);
}
export function debounce<T extends (...args: any[]) => any>(
fn: T,
delay: number
): T & { cancel: () => void } {
let timer: ReturnType<typeof setTimeout> | null = null;
const debounced = ((...args: Parameters<T>) => {
if (timer) clearTimeout(timer);
timer = setTimeout(() => fn(...args), delay);
}) as T & { cancel: () => void };
debounced.cancel = () => {
if (timer) clearTimeout(timer);
timer = null;
};
return debounced;
}
// utils/math.test.ts
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { add, factorial, debounce } from './math';
describe('数学工具函数', () => {
describe('add', () => {
it('应该正确计算两个正数之和', () => {
expect(add(1, 2)).toBe(3);
expect(add(100, 200)).toBe(300);
});
it('应该正确处理负数', () => {
expect(add(-1, -2)).toBe(-3);
expect(add(-5, 3)).toBe(-2);
});
it('应该正确处理浮点数', () => {
expect(add(0.1, 0.2)).toBeCloseTo(0.3);
});
it('应该在参数无效时抛出错误', () => {
expect(() => add('a' as any, 2)).toThrow('参数必须是数字');
expect(() => add(1, null as any)).toThrow('参数必须是数字');
});
});
describe('factorial', () => {
it('应该正确计算阶乘', () => {
expect(factorial(0)).toBe(1);
expect(factorial(1)).toBe(1);
expect(factorial(5)).toBe(120);
expect(factorial(10)).toBe(3628800);
});
it('应该在输入负数时抛出错误', () => {
expect(() => factorial(-1)).toThrow('负数没有阶乘');
});
// 性能测试
it('应该在合理时间内完成大数计算', () => {
const start = performance.now();
factorial(100);
const end = performance.now();
expect(end - start).toBeLessThan(100);
});
});
describe('debounce', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.restoreAllMocks();
});
it('应该在延迟时间内只执行一次', () => {
const fn = vi.fn();
const debounced = debounce(fn, 100);
debounced();
debounced();
debounced();
expect(fn).not.toHaveBeenCalled();
vi.advanceTimersByTime(100);
expect(fn).toHaveBeenCalledTimes(1);
});
it('应该传递正确的参数', () => {
const fn = vi.fn();
const debounced = debounce(fn, 100);
debounced('hello', 123);
vi.advanceTimersByTime(100);
expect(fn).toHaveBeenCalledWith('hello', 123);
});
it('应该支持取消操作', () => {
const fn = vi.fn();
const debounced = debounce(fn, 100);
debounced();
debounced.cancel();
vi.advanceTimersByTime(100);
expect(fn).not.toHaveBeenCalled();
});
});
});
⚛️ 组件测试(React Testing Library)
// components/LoginForm.tsx
import React, { useState } from 'react';
interface LoginFormProps {
onSubmit: (email: string, password: string) => Promise<void>;
isLoading?: boolean;
}
export const LoginForm: React.FC<LoginFormProps> = ({ onSubmit, isLoading = false }) => {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [errors, setErrors] = useState<{ email?: string; password?: string }>({});
const validate = (): boolean => {
const newErrors: typeof errors = {};
if (!email) {
newErrors.email = '邮箱不能为空';
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
newErrors.email = '邮箱格式不正确';
}
if (!password) {
newErrors.password = '密码不能为空';
} else if (password.length < 6) {
newErrors.password = '密码至少6位';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!validate()) return;
try {
await onSubmit(email, password);
} catch (error) {
setErrors({ email: (error as Error).message });
}
};
return (
<form onSubmit={handleSubmit} data-testid="login-form">
<div>
<label htmlFor="email">邮箱</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
aria-invalid={!!errors.email}
disabled={isLoading}
/>
{errors.email && <span role="alert">{errors.email}</span>}
</div>
<div>
<label htmlFor="password">密码</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
aria-invalid={!!errors.password}
disabled={isLoading}
/>
{errors.password && <span role="alert">{errors.password}</span>}
</div>
<button type="submit" disabled={isLoading}>
{isLoading ? '登录中...' : '登录'}
</button>
</form>
);
};
// components/LoginForm.test.tsx
import React from 'react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, it, expect, vi } from 'vitest';
import { LoginForm } from './LoginForm';
describe('LoginForm', () => {
// 基础渲染测试
it('应该正确渲染表单', () => {
render(<LoginForm onSubmit={vi.fn()} />);
expect(screen.getByLabelText(/邮箱/i)).toBeInTheDocument();
expect(screen.getByLabelText(/密码/i)).toBeInTheDocument();
expect(screen.getByRole('button', { name: /登录/i })).toBeInTheDocument();
});
// 用户交互测试
it('应该在提交前进行验证', async () => {
const onSubmit = vi.fn();
render(<LoginForm onSubmit={onSubmit} />);
// 提交空表单
fireEvent.click(screen.getByRole('button', { name: /登录/i }));
expect(await screen.findByText(/邮箱不能为空/i)).toBeInTheDocument();
expect(await screen.findByText(/密码不能为空/i)).toBeInTheDocument();
expect(onSubmit).not.toHaveBeenCalled();
});
// 异步操作测试
it('应该在提交时显示加载状态', async () => {
const onSubmit = vi.fn().mockImplementation(() => new Promise(resolve => setTimeout(resolve, 100)));
render(<LoginForm onSubmit={onSubmit} />);
await userEvent.type(screen.getByLabelText(/邮箱/i), 'test@example.com');
await userEvent.type(screen.getByLabelText(/密码/i), 'password123');
fireEvent.click(screen.getByRole('button', { name: /登录/i }));
// 检查加载状态
expect(screen.getByRole('button', { name: /登录中\.\.\./i })).toBeDisabled();
await waitFor(() => {
expect(screen.getByRole('button', { name: /登录/i })).toBeEnabled();
});
});
// 可访问性测试
it('应该在无效输入时标记错误状态', async () => {
render(<LoginForm onSubmit={vi.fn()} />);
const emailInput = screen.getByLabelText(/邮箱/i);
fireEvent.blur(emailInput);
expect(emailInput).toHaveAttribute('aria-invalid', 'true');
});
// 快照测试
it('应该匹配快照', () => {
const { container } = render(<LoginForm onSubmit={vi.fn()} />);
expect(container).toMatchSnapshot();
});
});
🔗 集成测试
// integration/auth.integration.test.tsx
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { setupServer } from 'msw/node';
import { http, HttpResponse } from 'msw';
import { Provider } from 'react-redux';
import { configureStore } from '@reduxjs/toolkit';
import { LoginPage } from '../pages/LoginPage';
import authReducer from '../store/authSlice';
// Mock API服务器
const server = setupServer(
http.post('/api/login', async ({ request }) => {
const body = await request.json();
if (body.email === 'admin@example.com' && body.password === 'correct') {
return HttpResponse.json({
token: 'fake-jwt-token',
user: { id: 1, email: body.email, name: 'Admin' }
});
}
return HttpResponse.json(
{ message: '用户名或密码错误' },
{ status: 401 }
);
})
);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
describe('登录流程集成测试', () => {
it('应该成功登录并跳转', async () => {
const store = configureStore({
reducer: { auth: authReducer }
});
const mockNavigate = vi.fn();
render(
<Provider store={store}>
<LoginPage navigate={mockNavigate} />
</Provider>
);
await userEvent.type(screen.getByLabelText(/邮箱/i), 'admin@example.com');
await userEvent.type(screen.getByLabelText(/密码/i), 'correct');
fireEvent.click(screen.getByRole('button', { name: /登录/i }));
await waitFor(() => {
expect(mockNavigate).toHaveBeenCalledWith('/dashboard');
expect(localStorage.getItem('token')).toBe('fake-jwt-token');
});
});
it('应该显示登录失败错误', async () => {
const store = configureStore({
reducer: { auth: authReducer }
});
render(
<Provider store={store}>
<LoginPage />
</Provider>
);
await userEvent.type(screen.getByLabelText(/邮箱/i), 'wrong@example.com');
await userEvent.type(screen.getByLabelText(/密码/i), 'wrong');
fireEvent.click(screen.getByRole('button', { name: /登录/i }));
expect(await screen.findByText(/用户名或密码错误/i)).toBeInTheDocument();
});
});
🌐 E2E测试(Playwright)
// e2e/login.spec.ts
import { test, expect } from '@playwright/test';
test.describe('登录流程', () => {
test.beforeEach(async ({ page }) => {
await page.goto('http://localhost:3000/login');
});
test('应该成功登录', async ({ page }) => {
// 填写表单
await page.fill('[data-testid="email-input"]', 'user@example.com');
await page.fill('[data-testid="password-input"]', 'password123');
// 提交
await page.click('[data-testid="login-button"]');
// 等待跳转
await page.waitForURL('http://localhost:3000/dashboard');
// 验证登录成功
await expect(page.locator('[data-testid="welcome-message"]')).toContainText('欢迎回来');
});
test('应该显示验证错误', async ({ page }) => {
await page.click('[data-testid="login-button"]');
// 验证错误消息
await expect(page.locator('[data-testid="email-error"]')).toBeVisible();
await expect(page.locator('[data-testid="password-error"]')).toBeVisible();
});
test('应该处理网络错误', async ({ page, context }) => {
// 模拟离线状态
await context.setOffline(true);
await page.fill('[data-testid="email-input"]', 'user@example.com');
await page.fill('[data-testid="password-input"]', 'password123');
await page.click('[data-testid="login-button"]');
await expect(page.locator('[data-testid="network-error"]')).toContainText('网络连接失败');
});
});
test.describe('购物车流程', () => {
test('应该添加商品到购物车', async ({ page }) => {
await page.goto('http://localhost:3000/products');
// 截图对比
await expect(page).toHaveScreenshot('products-page.png');
// 添加商品
await page.click('[data-testid="add-to-cart-1"]');
// 检查购物车数量
await expect(page.locator('[data-testid="cart-count"]')).toHaveText('1');
// 进入购物车
await page.click('[data-testid="cart-icon"]');
// 验证商品存在
await expect(page.locator('[data-testid="cart-item-1"]')).toBeVisible();
});
});
🛠️ 测试工具配置
// vitest.config.ts
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
test: {
globals: true,
environment: 'jsdom',
setupFiles: ['./src/test/setup.ts'],
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
exclude: [
'node_modules/',
'src/test/',
'**/*.d.ts',
'**/*.config.ts'
],
thresholds: {
lines: 80,
functions: 80,
branches: 75,
statements: 80
}
},
reporters: ['default', 'html'],
testTimeout: 10000,
hookTimeout: 10000
}
});
// src/test/setup.ts
import '@testing-library/jest-dom/vitest';
import { cleanup } from '@testing-library/react';
import { afterEach } from 'vitest';
// 每个测试后清理
afterEach(() => {
cleanup();
});
// Mock window.matchMedia
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn().mockImplementation(query => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
// Mock IntersectionObserver
global.IntersectionObserver = class IntersectionObserver {
constructor() {}
observe() { return null; }
unobserve() { return null; }
disconnect() { return null; }
} as any;
🎯 测试策略最佳实践
// 1. AAA模式(Arrange-Act-Assert)
test('应该计算总价', () => {
// Arrange
const cart = [{ price: 10, quantity: 2 }, { price: 5, quantity: 1 }];
// Act
const total = calculateTotal(cart);
// Assert
expect(total).toBe(25);
});
// 2. 测试描述应该清晰
test('getUserById: 当用户存在时返回用户对象', () => {});
test('getUserById: 当用户不存在时返回null', () => {});
// 3. 避免测试实现细节
// ❌ Bad
test('应该调用setState', () => {
expect(setState).toHaveBeenCalled();
});
// ✅ Good
test('点击按钮后显示成功消息', () => {
// 测试用户可见的行为
});
// 4. 使用工厂函数减少重复
const createUser = (overrides = {}) => ({
id: 1,
name: 'Test User',
email: 'test@example.com',
...overrides
});
test('应该更新用户邮箱', () => {
const user = createUser();
const updated = updateEmail(user, 'new@example.com');
expect(updated.email).toBe('new@example.com');
});
📊 测试覆盖率目标
| 测试类型 | 覆盖率目标 | 执行时间 | 维护成本 |
|---|---|---|---|
| 单元测试 | 80%+ | 秒级 | 低 |
| 组件测试 | 70%+ | 分钟级 | 中 |
| 集成测试 | 60%+ | 分钟级 | 中 |
| E2E测试 | 核心流程 | 小时级 | 高 |
明日预告:前端架构设计 - 微前端、组件库、Monorepo的最佳实践
💡 测试哲学:测试不是为了100%覆盖率,而是为了信心——重构的勇气、上线的底气!