最后更新于:2026年7月

不写测试的代码,就像不系安全带开车。 大多数时候没事,但一旦出事就是大事故。
根据 2025 年 State of JS 调查,超过 60% 的前端开发者会写单元测试,但只有不到 20% 的人写 E2E 测试。很多人觉得测试「太麻烦」「太花时间」「项目赶就跳过了」。
但实际上,合理的测试策略不仅不会拖慢开发,反而能提升开发速度——因为你会更自信地重构、更快速地发现问题、更少地回归 Bug。
本文从测试理论到实战代码,带你全面掌握前端测试。
一、测试基础理论
1.1 测试金字塔
PLAINTEXT
╱ E2E 测试 ╲ 少量,慢,贵
╱ 集成测试 ╲ 适量,中等速度
╱ 单元测试 ╲ 大量,快,便宜
╱──────────────────╲╱ E2E 测试 ╲ 少量,慢,贵
╱ 集成测试 ╲ 适量,中等速度
╱ 单元测试 ╲ 大量,快,便宜
╱──────────────────╲
| 测试类型 | 范围 | 速度 | 数量 | 工具 |
|---|---|---|---|---|
| 单元测试 | 单个函数/组件 | ⚡ 毫秒 | 多 | Vitest, Jest |
| 集成测试 | 多个组件协作 | 🔵 秒级 | 中 | RTL, Vitest |
| E2E 测试 | 完整用户流程 | 🐢 分钟级 | 少 | Playwright |
1.2 为什么要写测试?
不写测试的代价:
- 😱 改一处代码,不知道哪里会崩
- 🔄 反复手动测试同样的功能
- 🐛 上线后才发现 Bug,修复成本 10 倍
- 😰 不敢重构,代码越来越烂
写测试的收益:
- ✅ 代码变更后秒级验证
- ✅ 重构信心倍增
- ✅ 文档作用(测试即文档)
- ✅ 更好的代码设计(可测试的代码通常设计更好)
- ✅ CI/CD 的基础保障
1.3 测试策略原则
PLAINTEXT
1. 测试行为,不测实现
→ 关注「组件做了什么」,不是「怎么做的」
2. 从用户角度测试
→ 用户看到什么、点击什么、输入什么
3. 测试关键路径
→ 核心功能必须测试,边界情况酌情
4. 金字塔结构
→ 70% 单元测试 + 20% 集成测试 + 10% E2E 测试
5. 测试要快
→ 慢测试会被跳过,快测试才会被运行1. 测试行为,不测实现
→ 关注「组件做了什么」,不是「怎么做的」
2. 从用户角度测试
→ 用户看到什么、点击什么、输入什么
3. 测试关键路径
→ 核心功能必须测试,边界情况酌情
4. 金字塔结构
→ 70% 单元测试 + 20% 集成测试 + 10% E2E 测试
5. 测试要快
→ 慢测试会被跳过,快测试才会被运行
二、Vitest 配置与基础
2.1 安装与配置
BASH
# 安装 Vitest
pnpm add -D vitest @testing-library/react @testing-library/jest-dom @testing-library/user-event jsdom
# 安装 Playwright
pnpm add -D @playwright/test# 安装 Vitest
pnpm add -D vitest @testing-library/react @testing-library/jest-dom @testing-library/user-event jsdom
# 安装 Playwright
pnpm add -D @playwright/test
TS
// vitest.config.ts
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
import path from 'path';
export default defineConfig({
plugins: [react()],
test: {
// 测试环境
environment: 'jsdom',
// 全局设置
globals: true,
// Setup 文件
setupFiles: ['./src/test/setup.ts'],
// CSS 模块处理
css: true,
// 覆盖率
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html', 'lcov'],
include: ['src/**/*.{ts,tsx}'],
exclude: [
'src/types/**',
'src/test/**',
'src/**/*.d.ts',
'src/main.tsx',
],
thresholds: {
branches: 70,
functions: 70,
lines: 70,
statements: 70,
},
},
// 路径别名
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
},
});// vitest.config.ts
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
import path from 'path';
export default defineConfig({
plugins: [react()],
test: {
// 测试环境
environment: 'jsdom',
// 全局设置
globals: true,
// Setup 文件
setupFiles: ['./src/test/setup.ts'],
// CSS 模块处理
css: true,
// 覆盖率
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html', 'lcov'],
include: ['src/**/*.{ts,tsx}'],
exclude: [
'src/types/**',
'src/test/**',
'src/**/*.d.ts',
'src/main.tsx',
],
thresholds: {
branches: 70,
functions: 70,
lines: 70,
statements: 70,
},
},
// 路径别名
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
},
});
TS
// src/test/setup.ts
import '@testing-library/jest-dom/vitest';
// 清理
afterEach(() => {
// Vitest 自动清理,无需手动 cleanup
});
// Mock window.matchMedia
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: (query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: () => {},
removeListener: () => {},
addEventListener: () => {},
removeEventListener: () => {},
dispatchEvent: () => false,
}),
});// src/test/setup.ts
import '@testing-library/jest-dom/vitest';
// 清理
afterEach(() => {
// Vitest 自动清理,无需手动 cleanup
});
// Mock window.matchMedia
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: (query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: () => {},
removeListener: () => {},
addEventListener: () => {},
removeEventListener: () => {},
dispatchEvent: () => false,
}),
});
JSON
// package.json
{
"scripts": {
"test": "vitest",
"test:run": "vitest run",
"test:ui": "vitest --ui",
"test:coverage": "vitest run --coverage",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui"
}
}// package.json
{
"scripts": {
"test": "vitest",
"test:run": "vitest run",
"test:ui": "vitest --ui",
"test:coverage": "vitest run --coverage",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui"
}
}
2.2 基础测试
TS
// src/lib/utils.ts
export function formatPrice(price: number, currency = 'CNY'): string {
return new Intl.NumberFormat('zh-CN', {
style: 'currency',
currency,
}).format(price);
}
export function truncate(str: string, maxLength: number): string {
if (str.length <= maxLength) return str;
return str.slice(0, maxLength - 3) + '...';
}
export function debounce<T extends (...args: unknown[]) => void>(
fn: T,
delay: number
): (...args: Parameters<T>) => void {
let timer: ReturnType<typeof setTimeout>;
return (...args: Parameters<T>) => {
clearTimeout(timer);
timer = setTimeout(() => fn(...args), delay);
};
}// src/lib/utils.ts
export function formatPrice(price: number, currency = 'CNY'): string {
return new Intl.NumberFormat('zh-CN', {
style: 'currency',
currency,
}).format(price);
}
export function truncate(str: string, maxLength: number): string {
if (str.length <= maxLength) return str;
return str.slice(0, maxLength - 3) + '...';
}
export function debounce<T extends (...args: unknown[]) => void>(
fn: T,
delay: number
): (...args: Parameters<T>) => void {
let timer: ReturnType<typeof setTimeout>;
return (...args: Parameters<T>) => {
clearTimeout(timer);
timer = setTimeout(() => fn(...args), delay);
};
}
TS
// src/lib/__tests__/utils.test.ts
import { describe, it, expect, vi } from 'vitest';
import { formatPrice, truncate, debounce } from '../utils';
describe('formatPrice', () => {
it('格式化人民币价格', () => {
expect(formatPrice(99.99)).toBe('¥99.99');
});
it('格式化美元价格', () => {
expect(formatPrice(99.99, 'USD')).toBe('$99.99');
});
it('处理零', () => {
expect(formatPrice(0)).toBe('¥0.00');
});
it('处理大数', () => {
expect(formatPrice(1000000)).toBe('¥1,000,000.00');
});
});
describe('truncate', () => {
it('短字符串不变', () => {
expect(truncate('hello', 10)).toBe('hello');
});
it('长字符串截断', () => {
expect(truncate('hello world', 8)).toBe('hello...');
});
it('边界长度不变', () => {
expect(truncate('hello', 5)).toBe('hello');
});
});
describe('debounce', () => {
it('延迟执行函数', () => {
vi.useFakeTimers();
const fn = vi.fn();
const debouncedFn = debounce(fn, 100);
debouncedFn();
debouncedFn();
debouncedFn();
expect(fn).not.toHaveBeenCalled();
vi.advanceTimersByTime(100);
expect(fn).toHaveBeenCalledTimes(1);
vi.useRealTimers();
});
});// src/lib/__tests__/utils.test.ts
import { describe, it, expect, vi } from 'vitest';
import { formatPrice, truncate, debounce } from '../utils';
describe('formatPrice', () => {
it('格式化人民币价格', () => {
expect(formatPrice(99.99)).toBe('¥99.99');
});
it('格式化美元价格', () => {
expect(formatPrice(99.99, 'USD')).toBe('$99.99');
});
it('处理零', () => {
expect(formatPrice(0)).toBe('¥0.00');
});
it('处理大数', () => {
expect(formatPrice(1000000)).toBe('¥1,000,000.00');
});
});
describe('truncate', () => {
it('短字符串不变', () => {
expect(truncate('hello', 10)).toBe('hello');
});
it('长字符串截断', () => {
expect(truncate('hello world', 8)).toBe('hello...');
});
it('边界长度不变', () => {
expect(truncate('hello', 5)).toBe('hello');
});
});
describe('debounce', () => {
it('延迟执行函数', () => {
vi.useFakeTimers();
const fn = vi.fn();
const debouncedFn = debounce(fn, 100);
debouncedFn();
debouncedFn();
debouncedFn();
expect(fn).not.toHaveBeenCalled();
vi.advanceTimersByTime(100);
expect(fn).toHaveBeenCalledTimes(1);
vi.useRealTimers();
});
});
2.3 Mock 与 Spy
TS
// src/lib/__tests__/api.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { fetchUsers } from '../api';
// Mock 全局 fetch
const mockFetch = vi.fn();
global.fetch = mockFetch;
beforeEach(() => {
mockFetch.mockReset();
});
describe('fetchUsers', () => {
it('成功获取用户列表', async () => {
const mockUsers = [
{ id: 1, name: 'Alice' },
{ id: 2, name: 'Bob' },
];
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(mockUsers),
});
const users = await fetchUsers();
expect(users).toEqual(mockUsers);
expect(mockFetch).toHaveBeenCalledWith('/api/users');
});
it('处理请求失败', async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
status: 500,
statusText: 'Internal Server Error',
});
await expect(fetchUsers()).rejects.toThrow('Failed to fetch users');
});
it('处理网络错误', async () => {
mockFetch.mockRejectedValueOnce(new Error('Network error'));
await expect(fetchUsers()).rejects.toThrow('Network error');
});
});// src/lib/__tests__/api.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { fetchUsers } from '../api';
// Mock 全局 fetch
const mockFetch = vi.fn();
global.fetch = mockFetch;
beforeEach(() => {
mockFetch.mockReset();
});
describe('fetchUsers', () => {
it('成功获取用户列表', async () => {
const mockUsers = [
{ id: 1, name: 'Alice' },
{ id: 2, name: 'Bob' },
];
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(mockUsers),
});
const users = await fetchUsers();
expect(users).toEqual(mockUsers);
expect(mockFetch).toHaveBeenCalledWith('/api/users');
});
it('处理请求失败', async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
status: 500,
statusText: 'Internal Server Error',
});
await expect(fetchUsers()).rejects.toThrow('Failed to fetch users');
});
it('处理网络错误', async () => {
mockFetch.mockRejectedValueOnce(new Error('Network error'));
await expect(fetchUsers()).rejects.toThrow('Network error');
});
});
三、React 组件测试
3.1 React Testing Library 原则
PLAINTEXT
核心原则:从用户角度测试
✅ 好:查找可见文本
screen.getByText('提交')
screen.getByRole('button', { name: '提交' })
❌ 坏:查找实现细节
container.querySelector('.submit-btn')
screen.getByTestId('submit')
为什么?
→ 用户看到的是文本和按钮,不是 class 和 testid
→ 文本和角色更稳定,重构后不易断核心原则:从用户角度测试
✅ 好:查找可见文本
screen.getByText('提交')
screen.getByRole('button', { name: '提交' })
❌ 坏:查找实现细节
container.querySelector('.submit-btn')
screen.getByTestId('submit')
为什么?
→ 用户看到的是文本和按钮,不是 class 和 testid
→ 文本和角色更稳定,重构后不易断
3.2 简单组件测试
TSX
// src/components/ui/button.tsx
interface ButtonProps {
children: React.ReactNode;
onClick?: () => void;
variant?: 'primary' | 'secondary';
disabled?: boolean;
}
export function Button({ children, onClick, variant = 'primary', disabled }: ButtonProps) {
return (
<button
onClick={onClick}
disabled={disabled}
className={`btn btn-${variant}`}
>
{children}
</button>
);
}// src/components/ui/button.tsx
interface ButtonProps {
children: React.ReactNode;
onClick?: () => void;
variant?: 'primary' | 'secondary';
disabled?: boolean;
}
export function Button({ children, onClick, variant = 'primary', disabled }: ButtonProps) {
return (
<button
onClick={onClick}
disabled={disabled}
className={`btn btn-${variant}`}
>
{children}
</button>
);
}
TS
// src/components/ui/__tests__/button.test.tsx
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Button } from '../button';
describe('Button', () => {
it('渲染按钮文本', () => {
render(<Button>点击我</Button>);
expect(screen.getByRole('button', { name: '点击我' })).toBeInTheDocument();
});
it('点击触发回调', async () => {
const handleClick = vi.fn();
const user = userEvent.setup();
render(<Button onClick={handleClick}>提交</Button>);
await user.click(screen.getByRole('button'));
expect(handleClick).toHaveBeenCalledTimes(1);
});
it('禁用状态不可点击', async () => {
const handleClick = vi.fn();
const user = userEvent.setup();
render(<Button onClick={handleClick} disabled>提交</Button>);
expect(screen.getByRole('button')).toBeDisabled();
await user.click(screen.getByRole('button'));
expect(handleClick).not.toHaveBeenCalled();
});
});// src/components/ui/__tests__/button.test.tsx
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Button } from '../button';
describe('Button', () => {
it('渲染按钮文本', () => {
render(<Button>点击我</Button>);
expect(screen.getByRole('button', { name: '点击我' })).toBeInTheDocument();
});
it('点击触发回调', async () => {
const handleClick = vi.fn();
const user = userEvent.setup();
render(<Button onClick={handleClick}>提交</Button>);
await user.click(screen.getByRole('button'));
expect(handleClick).toHaveBeenCalledTimes(1);
});
it('禁用状态不可点击', async () => {
const handleClick = vi.fn();
const user = userEvent.setup();
render(<Button onClick={handleClick} disabled>提交</Button>);
expect(screen.getByRole('button')).toBeDisabled();
await user.click(screen.getByRole('button'));
expect(handleClick).not.toHaveBeenCalled();
});
});
3.3 表单组件测试
TSX
// src/components/login-form.tsx
'use client';
import { useState } from 'react';
interface LoginFormProps {
onSubmit: (email: string, password: string) => Promise<void>;
}
export function LoginForm({ onSubmit }: LoginFormProps) {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setIsLoading(true);
try {
await onSubmit(email, password);
} catch (err) {
setError(err instanceof Error ? err.message : '登录失败');
} finally {
setIsLoading(false);
}
};
return (
<form onSubmit={handleSubmit}>
<div>
<label htmlFor="email">邮箱</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</div>
<div>
<label htmlFor="password">密码</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={6}
/>
</div>
{error && <p role="alert">{error}</p>}
<button type="submit" disabled={isLoading}>
{isLoading ? '登录中...' : '登录'}
</button>
</form>
);
}// src/components/login-form.tsx
'use client';
import { useState } from 'react';
interface LoginFormProps {
onSubmit: (email: string, password: string) => Promise<void>;
}
export function LoginForm({ onSubmit }: LoginFormProps) {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setIsLoading(true);
try {
await onSubmit(email, password);
} catch (err) {
setError(err instanceof Error ? err.message : '登录失败');
} finally {
setIsLoading(false);
}
};
return (
<form onSubmit={handleSubmit}>
<div>
<label htmlFor="email">邮箱</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</div>
<div>
<label htmlFor="password">密码</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={6}
/>
</div>
{error && <p role="alert">{error}</p>}
<button type="submit" disabled={isLoading}>
{isLoading ? '登录中...' : '登录'}
</button>
</form>
);
}
TS
// src/components/__tests__/login-form.test.tsx
import { describe, it, expect, vi } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { LoginForm } from '../login-form';
describe('LoginForm', () => {
it('成功提交表单', async () => {
const handleSubmit = vi.fn().mockResolvedValue(undefined);
const user = userEvent.setup();
render(<LoginForm onSubmit={handleSubmit} />);
await user.type(screen.getByLabelText('邮箱'), 'test@example.com');
await user.type(screen.getByLabelText('密码'), 'password123');
await user.click(screen.getByRole('button', { name: '登录' }));
expect(handleSubmit).toHaveBeenCalledWith('test@example.com', 'password123');
});
it('显示错误信息', async () => {
const handleSubmit = vi.fn().mockRejectedValue(new Error('密码错误'));
const user = userEvent.setup();
render(<LoginForm onSubmit={handleSubmit} />);
await user.type(screen.getByLabelText('邮箱'), 'test@example.com');
await user.type(screen.getByLabelText('密码'), 'wrong');
await user.click(screen.getByRole('button', { name: '登录' }));
await waitFor(() => {
expect(screen.getByRole('alert')).toHaveTextContent('密码错误');
});
});
it('提交时显示加载状态', async () => {
let resolveSubmit: () => void;
const handleSubmit = vi.fn().mockImplementation(
() => new Promise<void>((resolve) => { resolveSubmit = resolve; })
);
const user = userEvent.setup();
render(<LoginForm onSubmit={handleSubmit} />);
await user.type(screen.getByLabelText('邮箱'), 'test@example.com');
await user.type(screen.getByLabelText('密码'), 'password123');
await user.click(screen.getByRole('button', { name: '登录' }));
expect(screen.getByRole('button')).toHaveTextContent('登录中...');
expect(screen.getByRole('button')).toBeDisabled();
resolveSubmit!();
await waitFor(() => {
expect(screen.getByRole('button')).toHaveTextContent('登录');
});
});
});// src/components/__tests__/login-form.test.tsx
import { describe, it, expect, vi } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { LoginForm } from '../login-form';
describe('LoginForm', () => {
it('成功提交表单', async () => {
const handleSubmit = vi.fn().mockResolvedValue(undefined);
const user = userEvent.setup();
render(<LoginForm onSubmit={handleSubmit} />);
await user.type(screen.getByLabelText('邮箱'), 'test@example.com');
await user.type(screen.getByLabelText('密码'), 'password123');
await user.click(screen.getByRole('button', { name: '登录' }));
expect(handleSubmit).toHaveBeenCalledWith('test@example.com', 'password123');
});
it('显示错误信息', async () => {
const handleSubmit = vi.fn().mockRejectedValue(new Error('密码错误'));
const user = userEvent.setup();
render(<LoginForm onSubmit={handleSubmit} />);
await user.type(screen.getByLabelText('邮箱'), 'test@example.com');
await user.type(screen.getByLabelText('密码'), 'wrong');
await user.click(screen.getByRole('button', { name: '登录' }));
await waitFor(() => {
expect(screen.getByRole('alert')).toHaveTextContent('密码错误');
});
});
it('提交时显示加载状态', async () => {
let resolveSubmit: () => void;
const handleSubmit = vi.fn().mockImplementation(
() => new Promise<void>((resolve) => { resolveSubmit = resolve; })
);
const user = userEvent.setup();
render(<LoginForm onSubmit={handleSubmit} />);
await user.type(screen.getByLabelText('邮箱'), 'test@example.com');
await user.type(screen.getByLabelText('密码'), 'password123');
await user.click(screen.getByRole('button', { name: '登录' }));
expect(screen.getByRole('button')).toHaveTextContent('登录中...');
expect(screen.getByRole('button')).toBeDisabled();
resolveSubmit!();
await waitFor(() => {
expect(screen.getByRole('button')).toHaveTextContent('登录');
});
});
});
3.4 列表与异步组件测试
TSX
// src/components/user-list.tsx
'use client';
import { useState, useEffect } from 'react';
interface User {
id: number;
name: string;
email: string;
}
export function UserList() {
const [users, setUsers] = useState<User[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState('');
useEffect(() => {
fetch('/api/users')
.then((res) => {
if (!res.ok) throw new Error('获取失败');
return res.json();
})
.then(setUsers)
.catch((err) => setError(err.message))
.finally(() => setIsLoading(false));
}, []);
if (isLoading) return <div>加载中...</div>;
if (error) return <div role="alert">{error}</div>;
return (
<ul>
{users.map((user) => (
<li key={user.id}>
{user.name} ({user.email})
</li>
))}
</ul>
);
}// src/components/user-list.tsx
'use client';
import { useState, useEffect } from 'react';
interface User {
id: number;
name: string;
email: string;
}
export function UserList() {
const [users, setUsers] = useState<User[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState('');
useEffect(() => {
fetch('/api/users')
.then((res) => {
if (!res.ok) throw new Error('获取失败');
return res.json();
})
.then(setUsers)
.catch((err) => setError(err.message))
.finally(() => setIsLoading(false));
}, []);
if (isLoading) return <div>加载中...</div>;
if (error) return <div role="alert">{error}</div>;
return (
<ul>
{users.map((user) => (
<li key={user.id}>
{user.name} ({user.email})
</li>
))}
</ul>
);
}
TS
// src/components/__tests__/user-list.test.tsx
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen } from '@testing-library/react';
import { UserList } from '../user-list';
const mockUsers = [
{ id: 1, name: 'Alice', email: 'alice@test.com' },
{ id: 2, name: 'Bob', email: 'bob@test.com' },
];
beforeEach(() => {
vi.restoreAllMocks();
});
describe('UserList', () => {
it('显示加载状态', () => {
vi.spyOn(global, 'fetch').mockImplementation(() => new Promise(() => {}));
render(<UserList />);
expect(screen.getByText('加载中...')).toBeInTheDocument();
});
it('显示用户列表', async () => {
vi.spyOn(global, 'fetch').mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockUsers),
} as Response);
render(<UserList />);
expect(screen.getByText('加载中...')).toBeInTheDocument();
// 等待数据加载
const aliceItem = await screen.findByText(/Alice/);
expect(aliceItem).toBeInTheDocument();
expect(screen.getByText(/Bob/)).toBeInTheDocument();
});
it('显示错误信息', async () => {
vi.spyOn(global, 'fetch').mockResolvedValue({
ok: false,
status: 500,
} as Response);
render(<UserList />);
const error = await screen.findByRole('alert');
expect(error).toHaveTextContent('获取失败');
});
});// src/components/__tests__/user-list.test.tsx
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen } from '@testing-library/react';
import { UserList } from '../user-list';
const mockUsers = [
{ id: 1, name: 'Alice', email: 'alice@test.com' },
{ id: 2, name: 'Bob', email: 'bob@test.com' },
];
beforeEach(() => {
vi.restoreAllMocks();
});
describe('UserList', () => {
it('显示加载状态', () => {
vi.spyOn(global, 'fetch').mockImplementation(() => new Promise(() => {}));
render(<UserList />);
expect(screen.getByText('加载中...')).toBeInTheDocument();
});
it('显示用户列表', async () => {
vi.spyOn(global, 'fetch').mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockUsers),
} as Response);
render(<UserList />);
expect(screen.getByText('加载中...')).toBeInTheDocument();
// 等待数据加载
const aliceItem = await screen.findByText(/Alice/);
expect(aliceItem).toBeInTheDocument();
expect(screen.getByText(/Bob/)).toBeInTheDocument();
});
it('显示错误信息', async () => {
vi.spyOn(global, 'fetch').mockResolvedValue({
ok: false,
status: 500,
} as Response);
render(<UserList />);
const error = await screen.findByRole('alert');
expect(error).toHaveTextContent('获取失败');
});
});
3.5 自定义 Hook 测试
TS
// src/hooks/use-counter.ts
import { useState, useCallback } from 'react';
export function useCounter(initialValue = 0, step = 1) {
const [count, setCount] = useState(initialValue);
const increment = useCallback(() => setCount((c) => c + step), [step]);
const decrement = useCallback(() => setCount((c) => c - step), [step]);
const reset = useCallback(() => setCount(initialValue), [initialValue]);
return { count, increment, decrement, reset };
}// src/hooks/use-counter.ts
import { useState, useCallback } from 'react';
export function useCounter(initialValue = 0, step = 1) {
const [count, setCount] = useState(initialValue);
const increment = useCallback(() => setCount((c) => c + step), [step]);
const decrement = useCallback(() => setCount((c) => c - step), [step]);
const reset = useCallback(() => setCount(initialValue), [initialValue]);
return { count, increment, decrement, reset };
}
TS
// src/hooks/__tests__/use-counter.test.ts
import { describe, it, expect } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useCounter } from '../use-counter';
describe('useCounter', () => {
it('初始值默认为 0', () => {
const { result } = renderHook(() => useCounter());
expect(result.current.count).toBe(0);
});
it('支持自定义初始值', () => {
const { result } = renderHook(() => useCounter(10));
expect(result.current.count).toBe(10);
});
it('递增', () => {
const { result } = renderHook(() => useCounter(0, 2));
act(() => result.current.increment());
expect(result.current.count).toBe(2);
act(() => result.current.increment());
expect(result.current.count).toBe(4);
});
it('递减', () => {
const { result } = renderHook(() => useCounter(10));
act(() => result.current.decrement());
expect(result.current.count).toBe(9);
});
it('重置', () => {
const { result } = renderHook(() => useCounter(10));
act(() => result.current.increment());
act(() => result.current.increment());
expect(result.current.count).toBe(11);
act(() => result.current.reset());
expect(result.current.count).toBe(10);
});
});// src/hooks/__tests__/use-counter.test.ts
import { describe, it, expect } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useCounter } from '../use-counter';
describe('useCounter', () => {
it('初始值默认为 0', () => {
const { result } = renderHook(() => useCounter());
expect(result.current.count).toBe(0);
});
it('支持自定义初始值', () => {
const { result } = renderHook(() => useCounter(10));
expect(result.current.count).toBe(10);
});
it('递增', () => {
const { result } = renderHook(() => useCounter(0, 2));
act(() => result.current.increment());
expect(result.current.count).toBe(2);
act(() => result.current.increment());
expect(result.current.count).toBe(4);
});
it('递减', () => {
const { result } = renderHook(() => useCounter(10));
act(() => result.current.decrement());
expect(result.current.count).toBe(9);
});
it('重置', () => {
const { result } = renderHook(() => useCounter(10));
act(() => result.current.increment());
act(() => result.current.increment());
expect(result.current.count).toBe(11);
act(() => result.current.reset());
expect(result.current.count).toBe(10);
});
});
四、Playwright E2E 测试
4.1 配置
TS
// 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',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
{ name: 'mobile', use: { ...devices['iPhone 14'] } },
],
webServer: {
command: 'pnpm dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
},
});// 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',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
{ name: 'mobile', use: { ...devices['iPhone 14'] } },
],
webServer: {
command: 'pnpm dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
},
});
4.2 基础 E2E 测试
TS
// e2e/home.spec.ts
import { test, expect } from '@playwright/test';
test.describe('首页', () => {
test('显示首页标题', async ({ page }) => {
await page.goto('/');
await expect(page.getByRole('heading', { name: '欢迎' })).toBeVisible();
});
test('导航到博客页面', async ({ page }) => {
await page.goto('/');
await page.getByRole('link', { name: '博客' }).click();
await expect(page).toHaveURL('/blog');
await expect(page.getByRole('heading', { name: '博客' })).toBeVisible();
});
});// e2e/home.spec.ts
import { test, expect } from '@playwright/test';
test.describe('首页', () => {
test('显示首页标题', async ({ page }) => {
await page.goto('/');
await expect(page.getByRole('heading', { name: '欢迎' })).toBeVisible();
});
test('导航到博客页面', async ({ page }) => {
await page.goto('/');
await page.getByRole('link', { name: '博客' }).click();
await expect(page).toHaveURL('/blog');
await expect(page.getByRole('heading', { name: '博客' })).toBeVisible();
});
});
4.3 完整用户流程测试
TS
// e2e/auth.spec.ts
import { test, expect } from '@playwright/test';
test.describe('用户认证流程', () => {
test('注册 → 登录 → 访问受保护页面', async ({ page }) => {
// 1. 注册
await page.goto('/register');
await page.getByLabel('用户名').fill('testuser');
await page.getByLabel('邮箱').fill('test@example.com');
await page.getByLabel('密码').fill('password123');
await page.getByRole('button', { name: '注册' }).click();
// 验证注册成功
await expect(page.getByText('注册成功')).toBeVisible();
// 2. 登录
await page.goto('/login');
await page.getByLabel('邮箱').fill('test@example.com');
await page.getByLabel('密码').fill('password123');
await page.getByRole('button', { name: '登录' }).click();
// 验证登录成功,跳转到仪表盘
await expect(page).toHaveURL('/dashboard');
await expect(page.getByText('欢迎回来')).toBeVisible();
// 3. 访问受保护页面
await page.getByRole('link', { name: '设置' }).click();
await expect(page).toHaveURL('/dashboard/settings');
// 4. 登出
await page.getByRole('button', { name: '登出' }).click();
await expect(page).toHaveURL('/');
});
test('未登录不能访问受保护页面', async ({ page }) => {
await page.goto('/dashboard');
await expect(page).toHaveURL('/login');
});
});// e2e/auth.spec.ts
import { test, expect } from '@playwright/test';
test.describe('用户认证流程', () => {
test('注册 → 登录 → 访问受保护页面', async ({ page }) => {
// 1. 注册
await page.goto('/register');
await page.getByLabel('用户名').fill('testuser');
await page.getByLabel('邮箱').fill('test@example.com');
await page.getByLabel('密码').fill('password123');
await page.getByRole('button', { name: '注册' }).click();
// 验证注册成功
await expect(page.getByText('注册成功')).toBeVisible();
// 2. 登录
await page.goto('/login');
await page.getByLabel('邮箱').fill('test@example.com');
await page.getByLabel('密码').fill('password123');
await page.getByRole('button', { name: '登录' }).click();
// 验证登录成功,跳转到仪表盘
await expect(page).toHaveURL('/dashboard');
await expect(page.getByText('欢迎回来')).toBeVisible();
// 3. 访问受保护页面
await page.getByRole('link', { name: '设置' }).click();
await expect(page).toHaveURL('/dashboard/settings');
// 4. 登出
await page.getByRole('button', { name: '登出' }).click();
await expect(page).toHaveURL('/');
});
test('未登录不能访问受保护页面', async ({ page }) => {
await page.goto('/dashboard');
await expect(page).toHaveURL('/login');
});
});
4.4 API Mock(拦截网络请求)
TS
// e2e/blog.spec.ts
import { test, expect } from '@playwright/test';
test.describe('博客页面', () => {
test('显示文章列表', async ({ page }) => {
// Mock API 响应
await page.route('**/api/posts', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify([
{ id: 1, title: '测试文章1', slug: 'test-1' },
{ id: 2, title: '测试文章2', slug: 'test-2' },
]),
})
);
await page.goto('/blog');
await expect(page.getByText('测试文章1')).toBeVisible();
await expect(page.getByText('测试文章2')).toBeVisible();
});
test('处理 API 错误', async ({ page }) => {
await page.route('**/api/posts', (route) =>
route.fulfill({ status: 500, body: 'Server Error' })
);
await page.goto('/blog');
await expect(page.getByText(/出错/)).toBeVisible();
});
});// e2e/blog.spec.ts
import { test, expect } from '@playwright/test';
test.describe('博客页面', () => {
test('显示文章列表', async ({ page }) => {
// Mock API 响应
await page.route('**/api/posts', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify([
{ id: 1, title: '测试文章1', slug: 'test-1' },
{ id: 2, title: '测试文章2', slug: 'test-2' },
]),
})
);
await page.goto('/blog');
await expect(page.getByText('测试文章1')).toBeVisible();
await expect(page.getByText('测试文章2')).toBeVisible();
});
test('处理 API 错误', async ({ page }) => {
await page.route('**/api/posts', (route) =>
route.fulfill({ status: 500, body: 'Server Error' })
);
await page.goto('/blog');
await expect(page.getByText(/出错/)).toBeVisible();
});
});
4.5 视觉回归测试
TS
// e2e/visual.spec.ts
import { test, expect } from '@playwright/test';
test.describe('视觉回归测试', () => {
test('首页截图对比', async ({ page }) => {
await page.goto('/');
await expect(page).toHaveScreenshot('home.png', {
maxDiffPixelRatio: 0.01, // 允许 1% 像素差异
});
});
test('博客页面截图对比', async ({ page }) => {
await page.goto('/blog');
await expect(page).toHaveScreenshot('blog.png');
});
test('移动端首页截图', async ({ page }) => {
await page.setViewportSize({ width: 375, height: 667 });
await page.goto('/');
await expect(page).toHaveScreenshot('home-mobile.png');
});
});// e2e/visual.spec.ts
import { test, expect } from '@playwright/test';
test.describe('视觉回归测试', () => {
test('首页截图对比', async ({ page }) => {
await page.goto('/');
await expect(page).toHaveScreenshot('home.png', {
maxDiffPixelRatio: 0.01, // 允许 1% 像素差异
});
});
test('博客页面截图对比', async ({ page }) => {
await page.goto('/blog');
await expect(page).toHaveScreenshot('blog.png');
});
test('移动端首页截图', async ({ page }) => {
await page.setViewportSize({ width: 375, height: 667 });
await page.goto('/');
await expect(page).toHaveScreenshot('home-mobile.png');
});
});
五、测试最佳实践
5.1 AAA 模式
TS
// Arrange(准备)→ Act(执行)→ Assert(断言)
it('用户登录后显示欢迎消息', async () => {
// Arrange:准备测试数据
const user = userEvent.setup();
const onSubmit = vi.fn().mockResolvedValue(undefined);
render(<LoginForm onSubmit={onSubmit} />);
// Act:执行操作
await user.type(screen.getByLabelText('邮箱'), 'test@example.com');
await user.type(screen.getByLabelText('密码'), 'password123');
await user.click(screen.getByRole('button', { name: '登录' }));
// Assert:验证结果
expect(onSubmit).toHaveBeenCalledWith('test@example.com', 'password123');
});// Arrange(准备)→ Act(执行)→ Assert(断言)
it('用户登录后显示欢迎消息', async () => {
// Arrange:准备测试数据
const user = userEvent.setup();
const onSubmit = vi.fn().mockResolvedValue(undefined);
render(<LoginForm onSubmit={onSubmit} />);
// Act:执行操作
await user.type(screen.getByLabelText('邮箱'), 'test@example.com');
await user.type(screen.getByLabelText('密码'), 'password123');
await user.click(screen.getByRole('button', { name: '登录' }));
// Assert:验证结果
expect(onSubmit).toHaveBeenCalledWith('test@example.com', 'password123');
});
5.2 测试命名规范
TS
// ❌ 不好的命名
it('works', () => {});
it('test1', () => {});
it('button', () => {});
// ✅ 好的命名(描述行为和预期)
it('禁用状态下点击不触发回调', () => {});
it('输入无效邮箱时显示错误提示', () => {});
it('加载完成后显示用户列表', () => {});// ❌ 不好的命名
it('works', () => {});
it('test1', () => {});
it('button', () => {});
// ✅ 好的命名(描述行为和预期)
it('禁用状态下点击不触发回调', () => {});
it('输入无效邮箱时显示错误提示', () => {});
it('加载完成后显示用户列表', () => {});
5.3 查询优先级
PLAINTEXT
1. getByRole → 最推荐(模拟用户视角)
2. getByLabelText → 表单元素
3. getByPlaceholder → 输入框
4. getByText → 非交互元素
5. getByTestId → 最后手段
避免使用:
❌ container.querySelector
❌ 直接用 className 查找1. getByRole → 最推荐(模拟用户视角)
2. getByLabelText → 表单元素
3. getByPlaceholder → 输入框
4. getByText → 非交互元素
5. getByTestId → 最后手段
避免使用:
❌ container.querySelector
❌ 直接用 className 查找
5.4 测试文件组织
PLAINTEXT
src/
├── components/
│ ├── button.tsx
│ └── __tests__/
│ └── button.test.tsx
│
├── hooks/
│ ├── use-counter.ts
│ └── __tests__/
│ └── use-counter.test.ts
│
├── lib/
│ ├── utils.ts
│ └── __tests__/
│ └── utils.test.ts
│
└── test/
├── setup.ts # 全局设置
├── utils.tsx # 测试工具(自定义 render 等)
└── mocks/ # Mock 数据
├── handlers.ts # MSW handlers
└── data.ts # 测试数据src/
├── components/
│ ├── button.tsx
│ └── __tests__/
│ └── button.test.tsx
│
├── hooks/
│ ├── use-counter.ts
│ └── __tests__/
│ └── use-counter.test.ts
│
├── lib/
│ ├── utils.ts
│ └── __tests__/
│ └── utils.test.ts
│
└── test/
├── setup.ts # 全局设置
├── utils.tsx # 测试工具(自定义 render 等)
└── mocks/ # Mock 数据
├── handlers.ts # MSW handlers
└── data.ts # 测试数据
5.5 自定义 Render 函数
TSX
// src/test/utils.tsx
import { render } from '@testing-library/react';
import type { ReactElement } from 'react';
// 如果你的 App 有全局 Provider(如 ThemeProvider, QueryClientProvider)
// 可以创建自定义 render 函数
export function renderWithProviders(
ui: ReactElement,
options?: { router?: { initialEntries: string[] } }
) {
return render(ui, {
// 在这里包裹你的 Provider
wrapper: ({ children }) => (
<QueryClientProvider client={queryClient}>
<ThemeProvider>{children}</ThemeProvider>
</QueryClientProvider>
),
...options,
});
}
// 使用
// renderWithProviders(<MyComponent />)
// src/test/utils.tsx
import { render } from '@testing-library/react';
import type { ReactElement } from 'react';
// 如果你的 App 有全局 Provider(如 ThemeProvider, QueryClientProvider)
// 可以创建自定义 render 函数
export function renderWithProviders(
ui: ReactElement,
options?: { router?: { initialEntries: string[] } }
) {
return render(ui, {
// 在这里包裹你的 Provider
wrapper: ({ children }) => (
<QueryClientProvider client={queryClient}>
<ThemeProvider>{children}</ThemeProvider>
</QueryClientProvider>
),
...options,
});
}
// 使用
// renderWithProviders(<MyComponent />)
六、CI 集成
6.1 GitHub Actions 配置
YAML
# .github/workflows/test.yml
name: Test
on:
push:
branches: [main, develop]
pull_request:
jobs:
unit-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
- run: pnpm test:run
- run: pnpm test:coverage
- uses: codecov/codecov-action@v4
with:
token: ${{ secrets.CODECOV_TOKEN }}
e2e-tests:
runs-on: ubuntu-latest
needs: unit-tests
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
- run: pnpm exec playwright install --with-deps
- run: pnpm build
- run: pnpm test:e2e
- uses: actions/upload-artifact@v4
if: failure()
with:
name: playwright-report
path: playwright-report/# .github/workflows/test.yml
name: Test
on:
push:
branches: [main, develop]
pull_request:
jobs:
unit-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
- run: pnpm test:run
- run: pnpm test:coverage
- uses: codecov/codecov-action@v4
with:
token: ${{ secrets.CODECOV_TOKEN }}
e2e-tests:
runs-on: ubuntu-latest
needs: unit-tests
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
- run: pnpm exec playwright install --with-deps
- run: pnpm build
- run: pnpm test:e2e
- uses: actions/upload-artifact@v4
if: failure()
with:
name: playwright-report
path: playwright-report/
七、总结
测试策略清单
- 配置 Vitest 测试环境
- 编写工具函数的单元测试
- 编写 React 组件的集成测试
- 编写自定义 Hook 测试
- 配置 Playwright E2E 测试
- 编写关键用户流程的 E2E 测试
- 集成到 CI/CD 流水线
- 设置测试覆盖率门槛
学习路径
PLAINTEXT
入门(第1周):
→ Vitest 基础配置
→ 编写简单单元测试
→ 理解 AAA 模式
进阶(第2-3周):
→ React Testing Library
→ 组件测试
→ Mock 和 Spy
高级(第1-2月):
→ Playwright E2E
→ 视觉回归测试
→ 自定义 Hook 测试
专家(第3月+):
→ TDD 开发流程
→ 测试策略设计
→ CI/CD 集成
→ 性能测试入门(第1周):
→ Vitest 基础配置
→ 编写简单单元测试
→ 理解 AAA 模式
进阶(第2-3周):
→ React Testing Library
→ 组件测试
→ Mock 和 Spy
高级(第1-2月):
→ Playwright E2E
→ 视觉回归测试
→ 自定义 Hook 测试
专家(第3月+):
→ TDD 开发流程
→ 测试策略设计
→ CI/CD 集成
→ 性能测试
【相关推荐】
- React 19 + TypeScript 完全指南 - React 基础
- Next.js 15 App Router 完全指南 - Next.js 框架
- CI/CD 持续集成与持续部署 - CI/CD 集成
测试不是负担,而是保障。 从今天开始,给关键代码加上测试,你会发现开发速度不降反升。🚀
