- 发布于
前端测试策略全解析:单元测试、集成测试与E2E测试实战
- 作者

- 姓名
- 全能波
- GitHub
- @weicracker
前端测试策略全解析:单元测试、集成测试与E2E测试实战
完善的测试策略是保证前端应用质量的重要手段。本文将分享前端测试的完整体系和实战经验,涵盖各种测试类型的最佳实践。
测试金字塔与策略
测试金字塔理论
// 测试金字塔结构
const TestingPyramid = {
// 底层:单元测试 (70%)
unitTests: {
description: '测试单个函数、组件或模块',
characteristics: ['快速执行', '成本低', '反馈及时', '易于维护'],
tools: ['Jest', 'Vitest', 'Mocha', 'Jasmine'],
coverage: '70%',
examples: [
'纯函数测试',
'组件渲染测试',
'工具类测试',
'状态管理测试'
]
},
// 中层:集成测试 (20%)
integrationTests: {
description: '测试多个模块或组件的协作',
characteristics: ['中等执行速度', '中等成本', '覆盖交互场景'],
tools: ['React Testing Library', 'Vue Test Utils', 'Enzyme'],
coverage: '20%',
examples: [
'组件间交互测试',
'API集成测试',
'路由测试',
'状态流转测试'
]
},
// 顶层:端到端测试 (10%)
e2eTests: {
description: '测试完整的用户流程',
characteristics: ['执行较慢', '成本高', '最接近真实场景'],
tools: ['Cypress', 'Playwright', 'Puppeteer', 'Selenium'],
coverage: '10%',
examples: [
'用户注册流程',
'购买流程',
'关键业务路径',
'跨浏览器兼容性'
]
}
};
// 测试策略制定
class TestingStrategy {
constructor(projectType, teamSize, timeline) {
this.projectType = projectType;
this.teamSize = teamSize;
this.timeline = timeline;
this.strategy = this.generateStrategy();
}
generateStrategy() {
const baseStrategy = {
unitTests: { priority: 'high', coverage: 70 },
integrationTests: { priority: 'medium', coverage: 20 },
e2eTests: { priority: 'low', coverage: 10 }
};
// 根据项目类型调整策略
switch (this.projectType) {
case 'library':
return {
unitTests: { priority: 'high', coverage: 85 },
integrationTests: { priority: 'medium', coverage: 15 },
e2eTests: { priority: 'low', coverage: 0 }
};
case 'enterprise':
return {
unitTests: { priority: 'high', coverage: 60 },
integrationTests: { priority: 'high', coverage: 25 },
e2eTests: { priority: 'medium', coverage: 15 }
};
case 'startup':
return {
unitTests: { priority: 'medium', coverage: 50 },
integrationTests: { priority: 'low', coverage: 30 },
e2eTests: { priority: 'high', coverage: 20 }
};
default:
return baseStrategy;
}
}
getRecommendations() {
const recommendations = [];
if (this.teamSize < 5) {
recommendations.push('小团队建议重点关注单元测试和关键E2E测试');
}
if (this.timeline === 'tight') {
recommendations.push('时间紧张时优先编写核心功能的单元测试');
}
if (this.projectType === 'library') {
recommendations.push('库项目应该有接近100%的单元测试覆盖率');
}
return recommendations;
}
}
// 使用示例
const strategy = new TestingStrategy('enterprise', 10, 'normal');
console.log('测试策略:', strategy.strategy);
console.log('建议:', strategy.getRecommendations());
单元测试实战
Jest配置与基础测试
// jest.config.js
module.exports = {
// 测试环境
testEnvironment: 'jsdom',
// 测试文件匹配模式
testMatch: [
'<rootDir>/src/**/__tests__/**/*.{js,jsx,ts,tsx}',
'<rootDir>/src/**/*.{test,spec}.{js,jsx,ts,tsx}'
],
// 模块路径映射
moduleNameMapping: {
'^@/(.*)$': '<rootDir>/src/$1',
'^@components/(.*)$': '<rootDir>/src/components/$1',
'^@utils/(.*)$': '<rootDir>/src/utils/$1'
},
// 设置文件
setupFilesAfterEnv: ['<rootDir>/src/setupTests.js'],
// 覆盖率配置
collectCoverageFrom: [
'src/**/*.{js,jsx,ts,tsx}',
'!src/**/*.d.ts',
'!src/index.js',
'!src/serviceWorker.js'
],
// 覆盖率阈值
coverageThreshold: {
global: {
branches: 80,
functions: 80,
lines: 80,
statements: 80
}
},
// 转换配置
transform: {
'^.+\\.(js|jsx|ts|tsx)$': 'babel-jest',
'^.+\\.css$': 'jest-transform-css'
},
// 模拟文件
moduleFileExtensions: ['js', 'jsx', 'ts', 'tsx', 'json'],
// 忽略转换的模块
transformIgnorePatterns: [
'node_modules/(?!(axios|some-es6-module)/)'
]
};
// setupTests.js
import '@testing-library/jest-dom';
import { configure } from '@testing-library/react';
// 配置测试库
configure({ testIdAttribute: 'data-testid' });
// 全局模拟
global.fetch = jest.fn();
// 模拟localStorage
const localStorageMock = {
getItem: jest.fn(),
setItem: jest.fn(),
removeItem: jest.fn(),
clear: jest.fn(),
};
global.localStorage = localStorageMock;
// 模拟IntersectionObserver
global.IntersectionObserver = jest.fn().mockImplementation(() => ({
observe: jest.fn(),
unobserve: jest.fn(),
disconnect: jest.fn(),
}));
// 工具函数测试示例
// utils/formatters.js
export function formatCurrency(amount, currency = 'USD') {
if (typeof amount !== 'number' || isNaN(amount)) {
throw new Error('Amount must be a valid number');
}
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: currency,
}).format(amount);
}
export function formatDate(date, format = 'short') {
if (!date) return '';
const dateObj = date instanceof Date ? date : new Date(date);
if (isNaN(dateObj.getTime())) {
throw new Error('Invalid date');
}
const options = {
short: { year: 'numeric', month: 'short', day: 'numeric' },
long: { year: 'numeric', month: 'long', day: 'numeric', weekday: 'long' },
iso: undefined // 特殊处理
};
if (format === 'iso') {
return dateObj.toISOString().split('T')[0];
}
return dateObj.toLocaleDateString('en-US', options[format] || options.short);
}
export function debounce(func, wait) {
let timeout;
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
}
// utils/__tests__/formatters.test.js
import { formatCurrency, formatDate, debounce } from '../formatters';
describe('formatCurrency', () => {
test('formats positive numbers correctly', () => {
expect(formatCurrency(1234.56)).toBe('$1,234.56');
expect(formatCurrency(0)).toBe('$0.00');
expect(formatCurrency(0.99)).toBe('$0.99');
});
test('formats negative numbers correctly', () => {
expect(formatCurrency(-1234.56)).toBe('-$1,234.56');
});
test('supports different currencies', () => {
expect(formatCurrency(1234.56, 'EUR')).toBe('€1,234.56');
expect(formatCurrency(1234.56, 'JPY')).toBe('¥1,235');
});
test('throws error for invalid input', () => {
expect(() => formatCurrency('invalid')).toThrow('Amount must be a valid number');
expect(() => formatCurrency(NaN)).toThrow('Amount must be a valid number');
expect(() => formatCurrency(null)).toThrow('Amount must be a valid number');
});
});
describe('formatDate', () => {
const testDate = new Date('2023-12-25T10:30:00Z');
test('formats date with default format', () => {
expect(formatDate(testDate)).toBe('Dec 25, 2023');
});
test('formats date with different formats', () => {
expect(formatDate(testDate, 'short')).toBe('Dec 25, 2023');
expect(formatDate(testDate, 'long')).toBe('Monday, December 25, 2023');
expect(formatDate(testDate, 'iso')).toBe('2023-12-25');
});
test('handles string dates', () => {
expect(formatDate('2023-12-25')).toBe('Dec 25, 2023');
});
test('handles empty input', () => {
expect(formatDate('')).toBe('');
expect(formatDate(null)).toBe('');
expect(formatDate(undefined)).toBe('');
});
test('throws error for invalid dates', () => {
expect(() => formatDate('invalid-date')).toThrow('Invalid date');
});
});
describe('debounce', () => {
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
test('delays function execution', () => {
const mockFn = jest.fn();
const debouncedFn = debounce(mockFn, 1000);
debouncedFn();
expect(mockFn).not.toHaveBeenCalled();
jest.advanceTimersByTime(1000);
expect(mockFn).toHaveBeenCalledTimes(1);
});
test('cancels previous calls', () => {
const mockFn = jest.fn();
const debouncedFn = debounce(mockFn, 1000);
debouncedFn();
debouncedFn();
debouncedFn();
jest.advanceTimersByTime(1000);
expect(mockFn).toHaveBeenCalledTimes(1);
});
test('passes arguments correctly', () => {
const mockFn = jest.fn();
const debouncedFn = debounce(mockFn, 1000);
debouncedFn('arg1', 'arg2');
jest.advanceTimersByTime(1000);
expect(mockFn).toHaveBeenCalledWith('arg1', 'arg2');
});
});
React组件测试
// components/UserCard.jsx
import React, { useState } from 'react';
import PropTypes from 'prop-types';
const UserCard = ({ user, onEdit, onDelete, showActions = true }) => {
const [isExpanded, setIsExpanded] = useState(false);
if (!user) {
return <div data-testid="user-card-empty">No user data</div>;
}
const handleToggleExpand = () => {
setIsExpanded(!isExpanded);
};
const handleEdit = () => {
onEdit?.(user.id);
};
const handleDelete = () => {
if (window.confirm('Are you sure you want to delete this user?')) {
onDelete?.(user.id);
}
};
return (
<div data-testid="user-card" className="user-card">
<div className="user-card-header">
<img
src={user.avatar || '/default-avatar.png'}
alt={`${user.name}'s avatar`}
data-testid="user-avatar"
/>
<div className="user-info">
<h3 data-testid="user-name">{user.name}</h3>
<p data-testid="user-email">{user.email}</p>
</div>
<button
onClick={handleToggleExpand}
data-testid="expand-button"
aria-label={isExpanded ? 'Collapse' : 'Expand'}
>
{isExpanded ? '−' : '+'}
</button>
</div>
{isExpanded && (
<div data-testid="user-details" className="user-details">
<p><strong>Phone:</strong> {user.phone || 'N/A'}</p>
<p><strong>Department:</strong> {user.department || 'N/A'}</p>
<p><strong>Role:</strong> {user.role || 'N/A'}</p>
</div>
)}
{showActions && (
<div data-testid="user-actions" className="user-actions">
<button
onClick={handleEdit}
data-testid="edit-button"
className="btn-edit"
>
Edit
</button>
<button
onClick={handleDelete}
data-testid="delete-button"
className="btn-delete"
>
Delete
</button>
</div>
)}
</div>
);
};
UserCard.propTypes = {
user: PropTypes.shape({
id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
name: PropTypes.string.isRequired,
email: PropTypes.string.isRequired,
avatar: PropTypes.string,
phone: PropTypes.string,
department: PropTypes.string,
role: PropTypes.string,
}),
onEdit: PropTypes.func,
onDelete: PropTypes.func,
showActions: PropTypes.bool,
};
export default UserCard;
// components/__tests__/UserCard.test.jsx
import React from 'react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import UserCard from '../UserCard';
// 测试数据
const mockUser = {
id: 1,
name: 'John Doe',
email: 'john@example.com',
avatar: 'https://example.com/avatar.jpg',
phone: '+1234567890',
department: 'Engineering',
role: 'Senior Developer'
};
// 模拟window.confirm
const mockConfirm = jest.fn();
global.confirm = mockConfirm;
describe('UserCard', () => {
beforeEach(() => {
mockConfirm.mockClear();
});
describe('Rendering', () => {
test('renders user information correctly', () => {
render(<UserCard user={mockUser} />);
expect(screen.getByTestId('user-name')).toHaveTextContent('John Doe');
expect(screen.getByTestId('user-email')).toHaveTextContent('john@example.com');
expect(screen.getByTestId('user-avatar')).toHaveAttribute('src', mockUser.avatar);
expect(screen.getByTestId('user-avatar')).toHaveAttribute('alt', "John Doe's avatar");
});
test('renders default avatar when avatar is not provided', () => {
const userWithoutAvatar = { ...mockUser, avatar: undefined };
render(<UserCard user={userWithoutAvatar} />);
expect(screen.getByTestId('user-avatar')).toHaveAttribute('src', '/default-avatar.png');
});
test('renders empty state when user is not provided', () => {
render(<UserCard user={null} />);
expect(screen.getByTestId('user-card-empty')).toHaveTextContent('No user data');
expect(screen.queryByTestId('user-card')).not.toBeInTheDocument();
});
test('hides actions when showActions is false', () => {
render(<UserCard user={mockUser} showActions={false} />);
expect(screen.queryByTestId('user-actions')).not.toBeInTheDocument();
});
test('shows actions by default', () => {
render(<UserCard user={mockUser} />);
expect(screen.getByTestId('user-actions')).toBeInTheDocument();
expect(screen.getByTestId('edit-button')).toBeInTheDocument();
expect(screen.getByTestId('delete-button')).toBeInTheDocument();
});
});
describe('Expand/Collapse functionality', () => {
test('details are hidden by default', () => {
render(<UserCard user={mockUser} />);
expect(screen.queryByTestId('user-details')).not.toBeInTheDocument();
});
test('shows details when expand button is clicked', async () => {
const user = userEvent.setup();
render(<UserCard user={mockUser} />);
const expandButton = screen.getByTestId('expand-button');
expect(expandButton).toHaveAttribute('aria-label', 'Expand');
await user.click(expandButton);
expect(screen.getByTestId('user-details')).toBeInTheDocument();
expect(screen.getByText('Phone:')).toBeInTheDocument();
expect(screen.getByText('+1234567890')).toBeInTheDocument();
expect(expandButton).toHaveAttribute('aria-label', 'Collapse');
});
test('hides details when collapse button is clicked', async () => {
const user = userEvent.setup();
render(<UserCard user={mockUser} />);
const expandButton = screen.getByTestId('expand-button');
// 展开
await user.click(expandButton);
expect(screen.getByTestId('user-details')).toBeInTheDocument();
// 收起
await user.click(expandButton);
expect(screen.queryByTestId('user-details')).not.toBeInTheDocument();
});
test('handles missing optional user data', async () => {
const userWithMissingData = {
id: 1,
name: 'John Doe',
email: 'john@example.com'
};
const user = userEvent.setup();
render(<UserCard user={userWithMissingData} />);
await user.click(screen.getByTestId('expand-button'));
expect(screen.getByText('N/A')).toBeInTheDocument();
});
});
describe('Action handlers', () => {
test('calls onEdit with user id when edit button is clicked', async () => {
const mockOnEdit = jest.fn();
const user = userEvent.setup();
render(<UserCard user={mockUser} onEdit={mockOnEdit} />);
await user.click(screen.getByTestId('edit-button'));
expect(mockOnEdit).toHaveBeenCalledWith(mockUser.id);
expect(mockOnEdit).toHaveBeenCalledTimes(1);
});
test('calls onDelete with user id when delete is confirmed', async () => {
const mockOnDelete = jest.fn();
const user = userEvent.setup();
mockConfirm.mockReturnValue(true);
render(<UserCard user={mockUser} onDelete={mockOnDelete} />);
await user.click(screen.getByTestId('delete-button'));
expect(mockConfirm).toHaveBeenCalledWith('Are you sure you want to delete this user?');
expect(mockOnDelete).toHaveBeenCalledWith(mockUser.id);
});
test('does not call onDelete when delete is cancelled', async () => {
const mockOnDelete = jest.fn();
const user = userEvent.setup();
mockConfirm.mockReturnValue(false);
render(<UserCard user={mockUser} onDelete={mockOnDelete} />);
await user.click(screen.getByTestId('delete-button'));
expect(mockConfirm).toHaveBeenCalled();
expect(mockOnDelete).not.toHaveBeenCalled();
});
test('does not crash when handlers are not provided', async () => {
const user = userEvent.setup();
render(<UserCard user={mockUser} />);
// 这些操作不应该抛出错误
await user.click(screen.getByTestId('edit-button'));
mockConfirm.mockReturnValue(true);
await user.click(screen.getByTestId('delete-button'));
expect(mockConfirm).toHaveBeenCalled();
});
});
describe('Accessibility', () => {
test('has proper ARIA labels', () => {
render(<UserCard user={mockUser} />);
const expandButton = screen.getByTestId('expand-button');
expect(expandButton).toHaveAttribute('aria-label', 'Expand');
});
test('avatar has proper alt text', () => {
render(<UserCard user={mockUser} />);
const avatar = screen.getByTestId('user-avatar');
expect(avatar).toHaveAttribute('alt', "John Doe's avatar");
});
});
describe('Edge cases', () => {
test('handles user with numeric id', () => {
const mockOnEdit = jest.fn();
const userWithNumericId = { ...mockUser, id: 123 };
render(<UserCard user={userWithNumericId} onEdit={mockOnEdit} />);
fireEvent.click(screen.getByTestId('edit-button'));
expect(mockOnEdit).toHaveBeenCalledWith(123);
});
test('handles user with string id', () => {
const mockOnEdit = jest.fn();
const userWithStringId = { ...mockUser, id: 'user-123' };
render(<UserCard user={userWithStringId} onEdit={mockOnEdit} />);
fireEvent.click(screen.getByTestId('edit-button'));
expect(mockOnEdit).toHaveBeenCalledWith('user-123');
});
});
});
集成测试实战
API集成测试
// services/userService.js
import axios from 'axios';
const API_BASE_URL = process.env.REACT_APP_API_URL || 'http://localhost:3001/api';
class UserService {
constructor() {
this.api = axios.create({
baseURL: API_BASE_URL,
timeout: 10000,
headers: {
'Content-Type': 'application/json',
},
});
// 请求拦截器
this.api.interceptors.request.use(
(config) => {
const token = localStorage.getItem('authToken');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => Promise.reject(error)
);
// 响应拦截器
this.api.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
localStorage.removeItem('authToken');
window.location.href = '/login';
}
return Promise.reject(error);
}
);
}
async getUsers(params = {}) {
try {
const response = await this.api.get('/users', { params });
return response.data;
} catch (error) {
throw this.handleError(error);
}
}
async getUserById(id) {
try {
const response = await this.api.get(`/users/${id}`);
return response.data;
} catch (error) {
throw this.handleError(error);
}
}
async createUser(userData) {
try {
const response = await this.api.post('/users', userData);
return response.data;
} catch (error) {
throw this.handleError(error);
}
}
async updateUser(id, userData) {
try {
const response = await this.api.put(`/users/${id}`, userData);
return response.data;
} catch (error) {
throw this.handleError(error);
}
}
async deleteUser(id) {
try {
await this.api.delete(`/users/${id}`);
return true;
} catch (error) {
throw this.handleError(error);
}
}
handleError(error) {
if (error.response) {
// 服务器响应错误
return new Error(error.response.data.message || 'Server error');
} else if (error.request) {
// 网络错误
return new Error('Network error');
} else {
// 其他错误
return new Error(error.message || 'Unknown error');
}
}
}
export const userService = new UserService();
// services/__tests__/userService.test.js
import axios from 'axios';
import { userService } from '../userService';
// 模拟axios
jest.mock('axios');
const mockedAxios = axios;
// 模拟localStorage
const mockLocalStorage = {
getItem: jest.fn(),
setItem: jest.fn(),
removeItem: jest.fn(),
};
Object.defineProperty(window, 'localStorage', { value: mockLocalStorage });
// 模拟window.location
delete window.location;
window.location = { href: '' };
describe('UserService', () => {
let mockApi;
beforeEach(() => {
// 重置所有模拟
jest.clearAllMocks();
// 创建axios实例模拟
mockApi = {
get: jest.fn(),
post: jest.fn(),
put: jest.fn(),
delete: jest.fn(),
interceptors: {
request: { use: jest.fn() },
response: { use: jest.fn() }
}
};
mockedAxios.create.mockReturnValue(mockApi);
});
describe('getUsers', () => {
test('fetches users successfully', async () => {
const mockUsers = [
{ id: 1, name: 'John Doe', email: 'john@example.com' },
{ id: 2, name: 'Jane Smith', email: 'jane@example.com' }
];
mockApi.get.mockResolvedValue({ data: mockUsers });
const result = await userService.getUsers();
expect(mockApi.get).toHaveBeenCalledWith('/users', { params: {} });
expect(result).toEqual(mockUsers);
});
test('fetches users with parameters', async () => {
const params = { page: 1, limit: 10, search: 'john' };
const mockUsers = [{ id: 1, name: 'John Doe', email: 'john@example.com' }];
mockApi.get.mockResolvedValue({ data: mockUsers });
const result = await userService.getUsers(params);
expect(mockApi.get).toHaveBeenCalledWith('/users', { params });
expect(result).toEqual(mockUsers);
});
test('handles API error', async () => {
const errorResponse = {
response: {
data: { message: 'Users not found' },
status: 404
}
};
mockApi.get.mockRejectedValue(errorResponse);
await expect(userService.getUsers()).rejects.toThrow('Users not found');
});
test('handles network error', async () => {
const networkError = { request: {} };
mockApi.get.mockRejectedValue(networkError);
await expect(userService.getUsers()).rejects.toThrow('Network error');
});
});
describe('createUser', () => {
test('creates user successfully', async () => {
const userData = { name: 'New User', email: 'new@example.com' };
const createdUser = { id: 3, ...userData };
mockApi.post.mockResolvedValue({ data: createdUser });
const result = await userService.createUser(userData);
expect(mockApi.post).toHaveBeenCalledWith('/users', userData);
expect(result).toEqual(createdUser);
});
test('handles validation error', async () => {
const userData = { name: '', email: 'invalid-email' };
const errorResponse = {
response: {
data: { message: 'Validation failed' },
status: 400
}
};
mockApi.post.mockRejectedValue(errorResponse);
await expect(userService.createUser(userData)).rejects.toThrow('Validation failed');
});
});
describe('Authentication', () => {
test('adds auth token to requests when available', () => {
mockLocalStorage.getItem.mockReturnValue('mock-token');
// 创建新的服务实例来触发拦截器设置
const service = new (userService.constructor)();
// 验证请求拦截器被正确设置
expect(mockedAxios.create).toHaveBeenCalled();
expect(mockApi.interceptors.request.use).toHaveBeenCalled();
});
test('handles 401 unauthorized response', () => {
// 模拟响应拦截器的行为
const error = {
response: { status: 401 }
};
// 这里我们需要直接测试错误处理逻辑
const handledError = userService.handleError(error);
expect(handledError.message).toBe('Server error');
});
});
});
// 集成测试:组件与服务的集成
// components/__tests__/UserList.integration.test.jsx
import React from 'react';
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { rest } from 'msw';
import { setupServer } from 'msw/node';
import UserList from '../UserList';
// 模拟服务器
const server = setupServer(
rest.get('/api/users', (req, res, ctx) => {
const page = req.url.searchParams.get('page') || '1';
const search = req.url.searchParams.get('search') || '';
let users = [
{ id: 1, name: 'John Doe', email: 'john@example.com', department: 'Engineering' },
{ id: 2, name: 'Jane Smith', email: 'jane@example.com', department: 'Marketing' },
{ id: 3, name: 'Bob Johnson', email: 'bob@example.com', department: 'Sales' }
];
if (search) {
users = users.filter(user =>
user.name.toLowerCase().includes(search.toLowerCase()) ||
user.email.toLowerCase().includes(search.toLowerCase())
);
}
return res(
ctx.json({
users,
total: users.length,
page: parseInt(page),
totalPages: Math.ceil(users.length / 10)
})
);
}),
rest.delete('/api/users/:id', (req, res, ctx) => {
return res(ctx.status(204));
})
);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
describe('UserList Integration', () => {
test('loads and displays users', async () => {
render(<UserList />);
// 显示加载状态
expect(screen.getByText('Loading...')).toBeInTheDocument();
// 等待用户数据加载
await waitFor(() => {
expect(screen.getByText('John Doe')).toBeInTheDocument();
});
expect(screen.getByText('Jane Smith')).toBeInTheDocument();
expect(screen.getByText('Bob Johnson')).toBeInTheDocument();
});
test('searches users', async () => {
const user = userEvent.setup();
render(<UserList />);
// 等待初始数据加载
await waitFor(() => {
expect(screen.getByText('John Doe')).toBeInTheDocument();
});
// 搜索用户
const searchInput = screen.getByPlaceholderText('Search users...');
await user.type(searchInput, 'john');
// 等待搜索结果
await waitFor(() => {
expect(screen.getByText('John Doe')).toBeInTheDocument();
expect(screen.queryByText('Jane Smith')).not.toBeInTheDocument();
});
});
test('handles API error', async () => {
// 模拟API错误
server.use(
rest.get('/api/users', (req, res, ctx) => {
return res(ctx.status(500), ctx.json({ message: 'Server error' }));
})
);
render(<UserList />);
await waitFor(() => {
expect(screen.getByText('Error loading users')).toBeInTheDocument();
});
});
test('deletes user', async () => {
const user = userEvent.setup();
// 模拟window.confirm
window.confirm = jest.fn(() => true);
render(<UserList />);
// 等待数据加载
await waitFor(() => {
expect(screen.getByText('John Doe')).toBeInTheDocument();
});
// 点击删除按钮
const deleteButtons = screen.getAllByText('Delete');
await user.click(deleteButtons[0]);
// 验证确认对话框
expect(window.confirm).toHaveBeenCalled();
// 等待用户被删除
await waitFor(() => {
expect(screen.queryByText('John Doe')).not.toBeInTheDocument();
});
});
});
E2E测试实战
Cypress测试配置
// cypress.config.js
const { defineConfig } = require('cypress');
module.exports = defineConfig({
e2e: {
baseUrl: 'http://localhost:3000',
viewportWidth: 1280,
viewportHeight: 720,
video: true,
screenshotOnRunFailure: true,
// 测试文件配置
specPattern: 'cypress/e2e/**/*.cy.{js,jsx,ts,tsx}',
supportFile: 'cypress/support/e2e.js',
// 环境变量
env: {
apiUrl: 'http://localhost:3001/api',
testUser: {
email: 'test@example.com',
password: 'password123'
}
},
// 重试配置
retries: {
runMode: 2,
openMode: 0
},
// 超时配置
defaultCommandTimeout: 10000,
requestTimeout: 10000,
responseTimeout: 10000,
setupNodeEvents(on, config) {
// 任务配置
on('task', {
// 数据库操作
'db:seed': () => {
// 种子数据逻辑
return null;
},
'db:clean': () => {
// 清理数据库逻辑
return null;
}
});
return config;
},
},
});
// cypress/support/e2e.js
import './commands';
// 全局配置
Cypress.on('uncaught:exception', (err, runnable) => {
// 忽略某些不影响测试的错误
if (err.message.includes('ResizeObserver loop limit exceeded')) {
return false;
}
return true;
});
// cypress/support/commands.js
// 自定义命令
Cypress.Commands.add('login', (email, password) => {
cy.session([email, password], () => {
cy.visit('/login');
cy.get('[data-testid="email-input"]').type(email);
cy.get('[data-testid="password-input"]').type(password);
cy.get('[data-testid="login-button"]').click();
cy.url().should('not.include', '/login');
cy.get('[data-testid="user-menu"]').should('be.visible');
});
});
Cypress.Commands.add('logout', () => {
cy.get('[data-testid="user-menu"]').click();
cy.get('[data-testid="logout-button"]').click();
cy.url().should('include', '/login');
});
Cypress.Commands.add('createUser', (userData) => {
cy.request({
method: 'POST',
url: `${Cypress.env('apiUrl')}/users`,
body: userData,
headers: {
'Authorization': `Bearer ${window.localStorage.getItem('authToken')}`
}
});
});
Cypress.Commands.add('deleteUser', (userId) => {
cy.request({
method: 'DELETE',
url: `${Cypress.env('apiUrl')}/users/${userId}`,
headers: {
'Authorization': `Bearer ${window.localStorage.getItem('authToken')}`
}
});
});
// 等待API请求完成
Cypress.Commands.add('waitForApi', (alias) => {
cy.wait(alias).then((interception) => {
expect(interception.response.statusCode).to.be.oneOf([200, 201, 204]);
});
});
// 检查可访问性
Cypress.Commands.add('checkA11y', (context, options) => {
cy.injectAxe();
cy.checkA11y(context, options);
});
// E2E测试示例
// cypress/e2e/user-management.cy.js
describe('User Management', () => {
beforeEach(() => {
// 清理和准备测试数据
cy.task('db:clean');
cy.task('db:seed');
// 登录
cy.login(Cypress.env('testUser').email, Cypress.env('testUser').password);
});
describe('User List', () => {
it('displays user list correctly', () => {
cy.visit('/users');
// 检查页面标题
cy.get('[data-testid="page-title"]').should('contain', 'User Management');
// 检查用户列表
cy.get('[data-testid="user-list"]').should('be.visible');
cy.get('[data-testid="user-card"]').should('have.length.at.least', 1);
// 检查用户信息
cy.get('[data-testid="user-card"]').first().within(() => {
cy.get('[data-testid="user-name"]').should('not.be.empty');
cy.get('[data-testid="user-email"]').should('not.be.empty');
});
});
it('searches users', () => {
cy.visit('/users');
// 等待用户列表加载
cy.get('[data-testid="user-card"]').should('have.length.at.least', 1);
// 记录初始用户数量
cy.get('[data-testid="user-card"]').then($cards => {
const initialCount = $cards.length;
// 执行搜索
cy.get('[data-testid="search-input"]').type('john');
cy.get('[data-testid="search-button"]').click();
// 验证搜索结果
cy.get('[data-testid="user-card"]').should('have.length.lessThan', initialCount);
cy.get('[data-testid="user-name"]').should('contain', 'John');
});
});
it('filters users by department', () => {
cy.visit('/users');
// 选择部门过滤器
cy.get('[data-testid="department-filter"]').select('Engineering');
// 验证过滤结果
cy.get('[data-testid="user-card"]').each($card => {
cy.wrap($card).within(() => {
cy.get('[data-testid="user-department"]').should('contain', 'Engineering');
});
});
});
});
describe('User Creation', () => {
it('creates a new user successfully', () => {
cy.visit('/users');
// 点击创建用户按钮
cy.get('[data-testid="create-user-button"]').click();
// 验证导航到创建页面
cy.url().should('include', '/users/create');
cy.get('[data-testid="page-title"]').should('contain', 'Create User');
// 填写用户信息
const newUser = {
name: 'Test User',
email: 'testuser@example.com',
phone: '+1234567890',
department: 'Engineering',
role: 'Developer'
};
cy.get('[data-testid="name-input"]').type(newUser.name);
cy.get('[data-testid="email-input"]').type(newUser.email);
cy.get('[data-testid="phone-input"]').type(newUser.phone);
cy.get('[data-testid="department-select"]').select(newUser.department);
cy.get('[data-testid="role-input"]').type(newUser.role);
// 提交表单
cy.intercept('POST', '/api/users').as('createUser');
cy.get('[data-testid="submit-button"]').click();
// 验证API调用
cy.waitForApi('@createUser');
// 验证成功消息
cy.get('[data-testid="success-message"]').should('be.visible');
cy.get('[data-testid="success-message"]').should('contain', 'User created successfully');
// 验证导航回列表页
cy.url().should('include', '/users');
// 验证新用户出现在列表中
cy.get('[data-testid="user-name"]').should('contain', newUser.name);
});
it('validates required fields', () => {
cy.visit('/users/create');
// 尝试提交空表单
cy.get('[data-testid="submit-button"]').click();
// 验证错误消息
cy.get('[data-testid="name-error"]').should('contain', 'Name is required');
cy.get('[data-testid="email-error"]').should('contain', 'Email is required');
// 验证表单未提交
cy.url().should('include', '/users/create');
});
it('validates email format', () => {
cy.visit('/users/create');
// 输入无效邮箱
cy.get('[data-testid="email-input"]').type('invalid-email');
cy.get('[data-testid="name-input"]').type('Test User');
cy.get('[data-testid="submit-button"]').click();
// 验证邮箱格式错误
cy.get('[data-testid="email-error"]').should('contain', 'Invalid email format');
});
});
describe('User Editing', () => {
it('edits user information', () => {
cy.visit('/users');
// 点击第一个用户的编辑按钮
cy.get('[data-testid="user-card"]').first().within(() => {
cy.get('[data-testid="edit-button"]').click();
});
// 验证导航到编辑页面
cy.url().should('include', '/users/edit/');
// 验证表单预填充
cy.get('[data-testid="name-input"]').should('not.have.value', '');
cy.get('[data-testid="email-input"]').should('not.have.value', '');
// 修改用户信息
cy.get('[data-testid="name-input"]').clear().type('Updated Name');
cy.get('[data-testid="phone-input"]').clear().type('+9876543210');
// 提交更新
cy.intercept('PUT', '/api/users/*').as('updateUser');
cy.get('[data-testid="submit-button"]').click();
// 验证API调用
cy.waitForApi('@updateUser');
// 验证成功消息
cy.get('[data-testid="success-message"]').should('contain', 'User updated successfully');
// 返回列表页验证更新
cy.visit('/users');
cy.get('[data-testid="user-name"]').should('contain', 'Updated Name');
});
});
describe('User Deletion', () => {
it('deletes user with confirmation', () => {
cy.visit('/users');
// 记录初始用户数量
cy.get('[data-testid="user-card"]').then($cards => {
const initialCount = $cards.length;
// 点击删除按钮
cy.get('[data-testid="user-card"]').first().within(() => {
cy.get('[data-testid="delete-button"]').click();
});
// 确认删除
cy.get('[data-testid="confirm-dialog"]').should('be.visible');
cy.get('[data-testid="confirm-delete-button"]').click();
// 验证API调用
cy.intercept('DELETE', '/api/users/*').as('deleteUser');
cy.waitForApi('@deleteUser');
// 验证用户被删除
cy.get('[data-testid="user-card"]').should('have.length', initialCount - 1);
cy.get('[data-testid="success-message"]').should('contain', 'User deleted successfully');
});
});
it('cancels deletion', () => {
cy.visit('/users');
// 记录初始用户数量
cy.get('[data-testid="user-card"]').then($cards => {
const initialCount = $cards.length;
// 点击删除按钮
cy.get('[data-testid="user-card"]').first().within(() => {
cy.get('[data-testid="delete-button"]').click();
});
// 取消删除
cy.get('[data-testid="confirm-dialog"]').should('be.visible');
cy.get('[data-testid="cancel-button"]').click();
// 验证用户未被删除
cy.get('[data-testid="user-card"]').should('have.length', initialCount);
cy.get('[data-testid="confirm-dialog"]').should('not.exist');
});
});
});
describe('Accessibility', () => {
it('meets accessibility standards', () => {
cy.visit('/users');
cy.checkA11y();
// 检查键盘导航
cy.get('body').tab();
cy.focused().should('have.attr', 'data-testid', 'create-user-button');
// 检查ARIA标签
cy.get('[data-testid="user-list"]').should('have.attr', 'role', 'list');
cy.get('[data-testid="user-card"]').should('have.attr', 'role', 'listitem');
});
});
describe('Responsive Design', () => {
it('works on mobile devices', () => {
cy.viewport('iphone-x');
cy.visit('/users');
// 验证移动端布局
cy.get('[data-testid="mobile-menu-button"]').should('be.visible');
cy.get('[data-testid="desktop-navigation"]').should('not.be.visible');
// 测试移动端交互
cy.get('[data-testid="mobile-menu-button"]').click();
cy.get('[data-testid="mobile-menu"]').should('be.visible');
});
it('works on tablet devices', () => {
cy.viewport('ipad-2');
cy.visit('/users');
// 验证平板端布局
cy.get('[data-testid="user-card"]').should('be.visible');
cy.get('[data-testid="sidebar"]').should('be.visible');
});
});
});
总结
前端测试策略的核心要点:
- 测试金字塔:70%单元测试、20%集成测试、10%E2E测试
- 单元测试:快速、可靠、易维护的函数和组件测试
- 集成测试:验证模块间协作和API集成
- E2E测试:覆盖关键用户流程和业务场景
- 最佳实践:测试驱动开发、持续集成、覆盖率监控
完善的测试策略能够显著提高代码质量,减少生产环境bug,提升开发团队的信心和效率。