前端测试深度实践:从单元测试到E2E测试的完整测试解决方案
引言
前端测试是现代Web开发中不可或缺的重要环节,它不仅能够保证代码质量,还能提高开发效率,降低维护成本。随着前端应用复杂度的不断增加,建立完善的测试体系变得越来越重要。
本文将深入探讨前端测试的各个层面,从单元测试到集成测试,再到端到端测试,提供一套完整的前端测试解决方案。我们将涵盖测试策略设计、工具选择、最佳实践以及自动化测试流程等关键内容。
1. 前端测试概述
1.1 测试金字塔理论
前端测试遵循测试金字塔理论,从底层到顶层分为:
- 单元测试(Unit Tests): 测试独立的函数、组件或模块
- 集成测试(Integration Tests): 测试组件间的交互
- 端到端测试(E2E Tests): 测试完整的用户流程
1.2 测试策略管理器
// 前端测试策略管理器
class FrontendTestingManager {constructor(config = {}) {this.config = {unitTestRatio: 0.7,integrationTestRatio: 0.2,e2eTestRatio: 0.1,coverageThreshold: {statements: 80,branches: 75,functions: 80,lines: 80},testEnvironments: ['jsdom', 'node', 'browser'],enableParallelTesting: true,enableWatchMode: true,enableCoverageReport: true,enableVisualTesting: true,enablePerformanceTesting: true,enableAccessibilityTesting: true,...config};this.testSuites = new Map();this.testResults = [];this.coverageData = null;this.testMetrics = {totalTests: 0,passedTests: 0,failedTests: 0,skippedTests: 0,executionTime: 0,coverage: {}};this.init();}// 初始化init() {this.setupTestEnvironments();this.registerTestSuites();this.setupTestReporting();this.setupCoverageTracking();}// 设置测试环境setupTestEnvironments() {this.environments = {unit: {framework: 'jest',environment: 'jsdom',setupFiles: ['<rootDir>/src/setupTests.js'],testMatch: ['**/__tests__/**/*.test.{js,jsx,ts,tsx}'],collectCoverageFrom: ['src/**/*.{js,jsx,ts,tsx}','!src/**/*.d.ts','!src/index.js','!src/serviceWorker.js']},integration: {framework: 'jest',environment: 'jsdom',testMatch: ['**/__tests__/**/*.integration.{js,jsx,ts,tsx}'],setupFilesAfterEnv: ['<rootDir>/src/setupIntegrationTests.js']},e2e: {framework: 'playwright',browsers: ['chromium', 'firefox', 'webkit'],testDir: './e2e',testMatch: '**/*.e2e.{js,ts}',baseURL: 'http://localhost:3000'}};}// 注册测试套件registerTestSuites() {// 单元测试套件this.testSuites.set('unit', {type: 'unit',runner: new UnitTestRunner(this.environments.unit),priority: 1,parallel: true});// 集成测试套件this.testSuites.set('integration', {type: 'integration',runner: new IntegrationTestRunner(this.environments.integration),priority: 2,parallel: true});// E2E测试套件this.testSuites.set('e2e', {type: 'e2e',runner: new E2ETestRunner(this.environments.e2e),priority: 3,parallel: false});// 视觉测试套件if (this.config.enableVisualTesting) {this.testSuites.set('visual', {type: 'visual',runner: new VisualTestRunner(),priority: 4,parallel: true});}// 性能测试套件if (this.config.enablePerformanceTesting) {this.testSuites.set('performance', {type: 'performance',runner: new PerformanceTestRunner(),priority: 5,parallel: false});}// 可访问性测试套件if (this.config.enableAccessibilityTesting) {this.testSuites.set('accessibility', {type: 'accessibility',runner: new AccessibilityTestRunner(),priority: 6,parallel: true});}}// 运行所有测试async runAllTests(options = {}) {const startTime = Date.now();try {console.log('🧪 Starting test execution...');// 重置测试指标this.resetTestMetrics();// 按优先级排序测试套件const sortedSuites = Array.from(this.testSuites.entries()).sort(([, a], [, b]) => a.priority - b.priority);// 运行测试套件for (const [name, suite] of sortedSuites) {if (options.suites && !options.suites.includes(name)) {continue;}console.log(`📋 Running ${name} tests...`);const suiteResult = await this.runTestSuite(name, suite, options);this.processTestResult(name, suiteResult);}// 生成测试报告const report = await this.generateTestReport();// 检查覆盖率阈值this.validateCoverageThreshold();const endTime = Date.now();this.testMetrics.executionTime = endTime - startTime;console.log('✅ Test execution completed');console.log('📊 Test Summary:', this.getTestSummary());return {success: this.testMetrics.failedTests === 0,metrics: this.testMetrics,report: report};} catch (error) {console.error('❌ Test execution failed:', error);throw error;}}// 运行单个测试套件async runTestSuite(name, suite, options) {try {const result = await suite.runner.run({...options,parallel: suite.parallel && this.config.enableParallelTesting});return {suite: name,type: suite.type,success: result.success,tests: result.tests || [],coverage: result.coverage || null,duration: result.duration || 0,errors: result.errors || []};} catch (error) {return {suite: name,type: suite.type,success: false,tests: [],coverage: null,duration: 0,errors: [error.message]};}}// 处理测试结果processTestResult(suiteName, result) {this.testResults.push(result);// 更新测试指标if (result.tests) {result.tests.forEach(test => {this.testMetrics.totalTests++;switch (test.status) {case 'passed':this.testMetrics.passedTests++;break;case 'failed':this.testMetrics.failedTests++;break;case 'skipped':this.testMetrics.skippedTests++;break;}});}// 合并覆盖率数据if (result.coverage) {this.mergeCoverageData(result.coverage);}// 记录错误if (result.errors && result.errors.length > 0) {console.error(`❌ Errors in ${suiteName} tests:`, result.errors);}}// 合并覆盖率数据mergeCoverageData(coverage) {if (!this.coverageData) {this.coverageData = coverage;} else {// 简单的覆盖率合并逻辑Object.keys(coverage).forEach(file => {if (this.coverageData[file]) {// 合并文件覆盖率this.coverageData[file] = this.mergeCoverageFile(this.coverageData[file],coverage[file]);} else {this.coverageData[file] = coverage[file];}});}}// 合并单个文件的覆盖率mergeCoverageFile(existing, newCoverage) {return {statements: this.mergeCoverageMetric(existing.statements, newCoverage.statements),branches: this.mergeCoverageMetric(existing.branches, newCoverage.branches),functions: this.mergeCoverageMetric(existing.functions, newCoverage.functions),lines: this.mergeCoverageMetric(existing.lines, newCoverage.lines)};}// 合并覆盖率指标mergeCoverageMetric(existing, newMetric) {return {total: Math.max(existing.total, newMetric.total),covered: Math.max(existing.covered, newMetric.covered),percentage: Math.max(existing.percentage, newMetric.percentage)};}// 验证覆盖率阈值validateCoverageThreshold() {if (!this.coverageData || !this.config.enableCoverageReport) {return;}const overallCoverage = this.calculateOverallCoverage();const threshold = this.config.coverageThreshold;const violations = [];Object.keys(threshold).forEach(metric => {if (overallCoverage[metric] < threshold[metric]) {violations.push({metric,actual: overallCoverage[metric],expected: threshold[metric]});}});if (violations.length > 0) {console.warn('⚠️ Coverage threshold violations:', violations);this.testMetrics.coverageViolations = violations;}this.testMetrics.coverage = overallCoverage;}// 计算整体覆盖率calculateOverallCoverage() {if (!this.coverageData) {return { statements: 0, branches: 0, functions: 0, lines: 0 };}const totals = {statements: { total: 0, covered: 0 },branches: { total: 0, covered: 0 },functions: { total: 0, covered: 0 },lines: { total: 0, covered: 0 }};Object.values(this.coverageData).forEach(fileCoverage => {Object.keys(totals).forEach(metric => {totals[metric].total += fileCoverage[metric].total;totals[metric].covered += fileCoverage[metric].covered;});});const result = {};Object.keys(totals).forEach(metric => {const { total, covered } = totals[metric];result[metric] = total > 0 ? Math.round((covered / total) * 100) : 0;});return result;}// 生成测试报告async generateTestReport() {const report = {timestamp: new Date().toISOString(),summary: this.getTestSummary(),suites: this.testResults,coverage: this.coverageData ? this.calculateOverallCoverage() : null,metrics: this.testMetrics,environment: {node: process.version,platform: process.platform,ci: process.env.CI || false}};// 保存报告到文件if (this.config.enableTestReporting) {await this.saveTestReport(report);}return report;}// 保存测试报告async saveTestReport(report) {try {const fs = require('fs').promises;const path = require('path');const reportDir = path.join(process.cwd(), 'test-reports');await fs.mkdir(reportDir, { recursive: true });// JSON报告const jsonReportPath = path.join(reportDir, 'test-report.json');await fs.writeFile(jsonReportPath, JSON.stringify(report, null, 2));// HTML报告const htmlReport = this.generateHTMLReport(report);const htmlReportPath = path.join(reportDir, 'test-report.html');await fs.writeFile(htmlReportPath, htmlReport);console.log('📄 Test reports saved:', { jsonReportPath, htmlReportPath });} catch (error) {console.error('Failed to save test report:', error);}}// 生成HTML报告generateHTMLReport(report) {return `
<!DOCTYPE html>
<html>
<head><title>Test Report</title><style>body { font-family: Arial, sans-serif; margin: 20px; }.summary { background: #f5f5f5; padding: 15px; border-radius: 5px; }.suite { margin: 20px 0; border: 1px solid #ddd; border-radius: 5px; }.suite-header { background: #e9e9e9; padding: 10px; font-weight: bold; }.test { padding: 10px; border-bottom: 1px solid #eee; }.passed { color: green; }.failed { color: red; }.skipped { color: orange; }.coverage { margin: 20px 0; }.coverage-bar { width: 200px; height: 20px; background: #ddd; border-radius: 10px; overflow: hidden; }.coverage-fill { height: 100%; background: linear-gradient(to right, red, yellow, green); }</style>
</head>
<body><h1>Frontend Test Report</h1><div class="summary"><h2>Summary</h2><p>Total Tests: ${report.summary.total}</p><p>Passed: <span class="passed">${report.summary.passed}</span></p><p>Failed: <span class="failed">${report.summary.failed}</span></p><p>Skipped: <span class="skipped">${report.summary.skipped}</span></p><p>Success Rate: ${report.summary.successRate}%</p><p>Execution Time: ${report.summary.duration}ms</p></div>${report.coverage ? `<div class="coverage"><h2>Coverage</h2><p>Statements: ${report.coverage.statements}%</p><p>Branches: ${report.coverage.branches}%</p><p>Functions: ${report.coverage.functions}%</p><p>Lines: ${report.coverage.lines}%</p></div>` : ''}<div class="suites"><h2>Test Suites</h2>${report.suites.map(suite => `<div class="suite"><div class="suite-header">${suite.suite} (${suite.type})</div>${suite.tests.map(test => `<div class="test ${test.status}">${test.name} - ${test.status}${test.duration ? ` (${test.duration}ms)` : ''}${test.error ? `<br><small>${test.error}</small>` : ''}</div>`).join('')}</div>`).join('')}</div><footer><p>Generated at: ${report.timestamp}</p></footer>
</body>
</html>`;}// 设置测试报告setupTestReporting() {// 配置测试报告选项this.reportingConfig = {formats: ['json', 'html', 'junit'],outputDir: './test-reports',includeConsoleOutput: true,includeCoverage: this.config.enableCoverageReport};}// 设置覆盖率跟踪setupCoverageTracking() {if (!this.config.enableCoverageReport) return;// 配置覆盖率收集this.coverageConfig = {collectCoverageFrom: ['src/**/*.{js,jsx,ts,tsx}','!src/**/*.d.ts','!src/**/*.stories.{js,jsx,ts,tsx}','!src/**/*.test.{js,jsx,ts,tsx}'],coverageReporters: ['text', 'lcov', 'html', 'json'],coverageDirectory: './coverage'};}// 重置测试指标resetTestMetrics() {this.testMetrics = {totalTests: 0,passedTests: 0,failedTests: 0,skippedTests: 0,executionTime: 0,coverage: {},coverageViolations: []};this.testResults = [];this.coverageData = null;}// 获取测试摘要getTestSummary() {const { totalTests, passedTests, failedTests, skippedTests, executionTime } = this.testMetrics;return {total: totalTests,passed: passedTests,failed: failedTests,skipped: skippedTests,successRate: totalTests > 0 ? Math.round((passedTests / totalTests) * 100) : 0,duration: executionTime};}// 监听模式async startWatchMode() {if (!this.config.enableWatchMode) {console.log('Watch mode is disabled');return;}console.log('👀 Starting watch mode...');const chokidar = require('chokidar');// 监听源文件变化const watcher = chokidar.watch(['src/**/*.{js,jsx,ts,tsx}', '**/*.test.{js,jsx,ts,tsx}'], {ignored: /node_modules/,persistent: true});let debounceTimer;watcher.on('change', (path) => {console.log(`📝 File changed: ${path}`);// 防抖处理clearTimeout(debounceTimer);debounceTimer = setTimeout(async () => {try {console.log('🔄 Re-running tests...');await this.runAllTests({ watch: true });} catch (error) {console.error('Watch mode test execution failed:', error);}}, 1000);});return watcher;}// 获取状态getStatus() {return {config: this.config,environments: this.environments,testSuites: Array.from(this.testSuites.keys()),metrics: this.testMetrics,lastResults: this.testResults.slice(-5)};}// 清理cleanup() {this.testSuites.clear();this.testResults = [];this.coverageData = null;this.resetTestMetrics();}
}
2.2 React组件测试实践
// React组件测试示例
import React from 'react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { Provider } from 'react-redux';
import { BrowserRouter } from 'react-router-dom';
import { configureStore } from '@reduxjs/toolkit';
import userEvent from '@testing-library/user-event';// 示例组件:用户登录表单
const LoginForm = ({ onSubmit, loading = false }) => {const [formData, setFormData] = React.useState({email: '',password: ''});const [errors, setErrors] = React.useState({});const validateForm = () => {const newErrors = {};if (!formData.email) {newErrors.email = 'Email is required';} else if (!/\S+@\S+\.\S+/.test(formData.email)) {newErrors.email = 'Email is invalid';}if (!formData.password) {newErrors.password = 'Password is required';} else if (formData.password.length < 6) {newErrors.password = 'Password must be at least 6 characters';}setErrors(newErrors);return Object.keys(newErrors).length === 0;};const handleSubmit = (e) => {e.preventDefault();if (validateForm()) {onSubmit(formData);}};const handleChange = (field) => (e) => {setFormData(prev => ({...prev,[field]: e.target.value}));// 清除错误if (errors[field]) {setErrors(prev => ({...prev,[field]: ''}));}};return (<form onSubmit={handleSubmit} data-testid="login-form"><div><label htmlFor="email">Email:</label><inputid="email"type="email"value={formData.email}onChange={handleChange('email')}data-testid="email-input"aria-invalid={!!errors.email}aria-describedby={errors.email ? 'email-error' : undefined}/>{errors.email && (<div id="email-error" role="alert" data-testid="email-error">{errors.email}</div>)}</div><div><label htmlFor="password">Password:</label><inputid="password"type="password"value={formData.password}onChange={handleChange('password')}data-testid="password-input"aria-invalid={!!errors.password}aria-describedby={errors.password ? 'password-error' : undefined}/>{errors.password && (<div id="password-error" role="alert" data-testid="password-error">{errors.password}</div>)}</div><buttontype="submit"disabled={loading}data-testid="submit-button">{loading ? 'Logging in...' : 'Login'}</button></form>);
};// 测试套件
describe('LoginForm', () => {let mockOnSubmit;beforeEach(() => {mockOnSubmit = jest.fn();});afterEach(() => {jest.clearAllMocks();});// 基础渲染测试test('renders login form with all fields', () => {render(<LoginForm onSubmit={mockOnSubmit} />);expect(screen.getByTestId('login-form')).toBeInTheDocument();expect(screen.getByLabelText(/email/i)).toBeInTheDocument();expect(screen.getByLabelText(/password/i)).toBeInTheDocument();expect(screen.getByRole('button', { name: /login/i })).toBeInTheDocument();});// 用户交互测试test('allows user to enter email and password', async () => {const user = userEvent.setup();render(<LoginForm onSubmit={mockOnSubmit} />);const emailInput = screen.getByTestId('email-input');const passwordInput = screen.getByTestId('password-input');await user.type(emailInput, 'test@example.com');await user.type(passwordInput, 'password123');expect(emailInput).toHaveValue('test@example.com');expect(passwordInput).toHaveValue('password123');});// 表单验证测试test('shows validation errors for empty fields', async () => {const user = userEvent.setup();render(<LoginForm onSubmit={mockOnSubmit} />);const submitButton = screen.getByTestId('submit-button');await user.click(submitButton);await waitFor(() => {expect(screen.getByTestId('email-error')).toHaveTextContent('Email is required');expect(screen.getByTestId('password-error')).toHaveTextContent('Password is required');});expect(mockOnSubmit).not.toHaveBeenCalled();});test('shows validation error for invalid email', async () => {const user = userEvent.setup();render(<LoginForm onSubmit={mockOnSubmit} />);const emailInput = screen.getByTestId('email-input');const submitButton = screen.getByTestId('submit-button');await user.type(emailInput, 'invalid-email');await user.click(submitButton);await waitFor(() => {expect(screen.getByTestId('email-error')).toHaveTextContent('Email is invalid');});});test('shows validation error for short password', async () => {const user = userEvent.setup();render(<LoginForm onSubmit={mockOnSubmit} />);const passwordInput = screen.getByTestId('password-input');const submitButton = screen.getByTestId('submit-button');await user.type(passwordInput, '123');await user.click(submitButton);await waitFor(() => {expect(screen.getByTestId('password-error')).toHaveTextContent('Password must be at least 6 characters');});});// 成功提交测试test('submits form with valid data', async () => {const user = userEvent.setup();render(<LoginForm onSubmit={mockOnSubmit} />);const emailInput = screen.getByTestId('email-input');const passwordInput = screen.getByTestId('password-input');const submitButton = screen.getByTestId('submit-button');await user.type(emailInput, 'test@example.com');await user.type(passwordInput, 'password123');await user.click(submitButton);await waitFor(() => {expect(mockOnSubmit).toHaveBeenCalledWith({email: 'test@example.com',password: 'password123'});});});// 加载状态测试test('shows loading state when submitting', () => {render(<LoginForm onSubmit={mockOnSubmit} loading={true} />);const submitButton = screen.getByTestId('submit-button');expect(submitButton).toBeDisabled();expect(submitButton).toHaveTextContent('Logging in...');});// 错误清除测试test('clears errors when user starts typing', async () => {const user = userEvent.setup();render(<LoginForm onSubmit={mockOnSubmit} />);const emailInput = screen.getByTestId('email-input');const submitButton = screen.getByTestId('submit-button');// 触发验证错误await user.click(submitButton);await waitFor(() => {expect(screen.getByTestId('email-error')).toBeInTheDocument();});// 开始输入,错误应该清除await user.type(emailInput, 't');await waitFor(() => {expect(screen.queryByTestId('email-error')).not.toBeInTheDocument();});});// 可访问性测试test('has proper accessibility attributes', () => {render(<LoginForm onSubmit={mockOnSubmit} />);const emailInput = screen.getByTestId('email-input');const passwordInput = screen.getByTestId('password-input');expect(emailInput).toHaveAttribute('aria-invalid', 'false');expect(passwordInput).toHaveAttribute('aria-invalid', 'false');});test('associates error messages with inputs', async () => {const user = userEvent.setup();render(<LoginForm onSubmit={mockOnSubmit} />);const submitButton = screen.getByTestId('submit-button');await user.click(submitButton);await waitFor(() => {const emailInput = screen.getByTestId('email-input');const emailError = screen.getByTestId('email-error');expect(emailInput).toHaveAttribute('aria-invalid', 'true');expect(emailInput).toHaveAttribute('aria-describedby', 'email-error');expect(emailError).toHaveAttribute('role', 'alert');});});// 快照测试test('matches snapshot', () => {const { container } = render(<LoginForm onSubmit={mockOnSubmit} />);expect(container.firstChild).toMatchSnapshot();});test('matches snapshot with loading state', () => {const { container } = render(<LoginForm onSubmit={mockOnSubmit} loading={true} />);expect(container.firstChild).toMatchSnapshot();});
});// Redux连接组件测试
describe('LoginForm with Redux', () => {let store;let mockOnSubmit;beforeEach(() => {mockOnSubmit = jest.fn();// 创建测试storestore = configureStore({reducer: {auth: (state = { loading: false, error: null }, action) => {switch (action.type) {case 'auth/loginStart':return { ...state, loading: true, error: null };case 'auth/loginSuccess':return { ...state, loading: false, error: null };case 'auth/loginFailure':return { ...state, loading: false, error: action.payload };default:return state;}}}});});const renderWithProviders = (component) => {return render(<Provider store={store}><BrowserRouter>{component}</BrowserRouter></Provider>);};test('integrates with Redux store', () => {renderWithProviders(<LoginForm onSubmit={mockOnSubmit} />);// 验证组件正常渲染expect(screen.getByTestId('login-form')).toBeInTheDocument();// 验证初始状态const state = store.getState();expect(state.auth.loading).toBe(false);expect(state.auth.error).toBe(null);});
});
3. 集成测试深度实践
3.1 集成测试运行器
// 集成测试运行器
class IntegrationTestRunner {constructor(config = {}) {this.config = {framework: 'jest',environment: 'jsdom',testMatch: ['**/__tests__/**/*.integration.{js,jsx,ts,tsx}'],setupFilesAfterEnv: [],testTimeout: 30000,maxWorkers: 1,...config};this.testSuites = [];this.mockServices = new Map();this.testDatabase = null;this.testServer = null;this.init();}// 初始化init() {this.setupTestEnvironment();this.setupMockServices();this.setupTestDatabase();this.setupTestServer();}// 设置测试环境setupTestEnvironment() {// 配置测试环境变量process.env.NODE_ENV = 'test';process.env.API_BASE_URL = 'http://localhost:3001';process.env.DATABASE_URL = 'sqlite::memory:';// 配置全局测试设置global.testConfig = {apiTimeout: 5000,dbTimeout: 3000,retryAttempts: 3};}// 设置Mock服务setupMockServices() {this.mockServices.set('api', {baseUrl: process.env.API_BASE_URL,endpoints: new Map(),middleware: [],requests: []});this.mockServices.set('auth', {users: new Map(),sessions: new Map(),tokens: new Map()});this.mockServices.set('storage', {data: new Map(),config: {maxSize: 1024 * 1024, // 1MBttl: 3600000 // 1 hour}});}// 设置测试数据库setupTestDatabase() {this.testDatabase = {connection: null,tables: new Map(),async connect() {// 模拟数据库连接this.connection = {connected: true,database: 'test_db',tables: this.tables};console.log('📊 Test database connected');return this.connection;},async disconnect() {if (this.connection) {this.connection.connected = false;this.connection = null;}console.log('📊 Test database disconnected');},async seed(data = {}) {// 填充测试数据Object.keys(data).forEach(table => {this.tables.set(table, data[table]);});console.log('🌱 Test database seeded');},async clean() {// 清理测试数据this.tables.clear();console.log('🧹 Test database cleaned');},async query(sql, params = []) {// 模拟数据库查询console.log('🔍 Database query:', sql, params);// 简单的查询模拟if (sql.includes('SELECT')) {const tableName = sql.match(/FROM\s+(\w+)/i)?.[1];return this.tables.get(tableName) || [];}if (sql.includes('INSERT')) {const tableName = sql.match(/INTO\s+(\w+)/i)?.[1];const table = this.tables.get(tableName) || [];table.push(params);this.tables.set(tableName, table);return { insertId: table.length };}return { affectedRows: 1 };}};}// 设置测试服务器setupTestServer() {this.testServer = {port: 3001,routes: new Map(),middleware: [],server: null,async start() {// 模拟服务器启动this.server = {listening: true,port: this.port,routes: this.routes};console.log(`🚀 Test server started on port ${this.port}`);return this.server;},async stop() {if (this.server) {this.server.listening = false;this.server = null;}console.log('🛑 Test server stopped');},addRoute(method, path, handler) {const key = `${method.toUpperCase()} ${path}`;this.routes.set(key, handler);},addMiddleware(middleware) {this.middleware.push(middleware);}};// 添加默认路由this.setupDefaultRoutes();}// 设置默认路由setupDefaultRoutes() {// 健康检查this.testServer.addRoute('GET', '/health', () => ({status: 'ok',timestamp: new Date().toISOString()}));// 用户认证this.testServer.addRoute('POST', '/auth/login', (req) => {const { email, password } = req.body;const authService = this.mockServices.get('auth');// 简单的认证逻辑if (email === 'test@example.com' && password === 'password123') {const token = `token_${Date.now()}`;const user = { id: 1, email, name: 'Test User' };authService.tokens.set(token, user);return {success: true,token,user};}return {success: false,error: 'Invalid credentials'};});// 用户信息this.testServer.addRoute('GET', '/auth/me', (req) => {const token = req.headers.authorization?.replace('Bearer ', '');const authService = this.mockServices.get('auth');const user = authService.tokens.get(token);if (user) {return { success: true, user };}return { success: false, error: 'Unauthorized' };});// 数据APIthis.testServer.addRoute('GET', '/api/users', async () => {const users = await this.testDatabase.query('SELECT * FROM users');return { success: true, data: users };});this.testServer.addRoute('POST', '/api/users', async (req) => {const result = await this.testDatabase.query('INSERT INTO users (name, email) VALUES (?, ?)',[req.body.name, req.body.email]);return {success: true,data: { id: result.insertId, ...req.body }};});}// 运行集成测试async run(options = {}) {const startTime = Date.now();try {console.log('🔗 Running integration tests...');// 启动测试环境await this.startTestEnvironment();// 运行测试const result = await this.runTests(options);// 清理测试环境await this.cleanupTestEnvironment();const endTime = Date.now();result.duration = endTime - startTime;console.log(`✅ Integration tests completed in ${result.duration}ms`);return result;} catch (error) {console.error('❌ Integration test execution failed:', error);// 确保清理await this.cleanupTestEnvironment();throw error;}}// 启动测试环境async startTestEnvironment() {console.log('🏗️ Setting up integration test environment...');// 连接测试数据库await this.testDatabase.connect();// 填充测试数据await this.seedTestData();// 启动测试服务器await this.testServer.start();// 设置全局fetch mockthis.setupFetchMock();console.log('✅ Integration test environment ready');}// 填充测试数据async seedTestData() {const testData = {users: [{ id: 1, name: 'John Doe', email: 'john@example.com', role: 'user' },{ id: 2, name: 'Jane Smith', email: 'jane@example.com', role: 'admin' },{ id: 3, name: 'Bob Johnson', email: 'bob@example.com', role: 'user' }],posts: [{ id: 1, title: 'Test Post 1', content: 'Content 1', userId: 1 },{ id: 2, title: 'Test Post 2', content: 'Content 2', userId: 2 }],comments: [{ id: 1, content: 'Test comment 1', postId: 1, userId: 2 },{ id: 2, content: 'Test comment 2', postId: 1, userId: 3 }]};await this.testDatabase.seed(testData);}// 设置Fetch MocksetupFetchMock() {const originalFetch = global.fetch;global.fetch = jest.fn(async (url, options = {}) => {const method = options.method || 'GET';const routeKey = `${method.toUpperCase()} ${url.replace(process.env.API_BASE_URL, '')}`;const handler = this.testServer.routes.get(routeKey);if (handler) {const req = {url,method,headers: options.headers || {},body: options.body ? JSON.parse(options.body) : null};try {const response = await handler(req);return {ok: true,status: 200,json: async () => response,text: async () => JSON.stringify(response)};} catch (error) {return {ok: false,status: 500,json: async () => ({ error: error.message }),text: async () => JSON.stringify({ error: error.message })};}}// 回退到原始fetchreturn originalFetch(url, options);});}// 运行测试async runTests(options = {}) {const jest = require('jest');const jestConfig = {testEnvironment: this.config.environment,testMatch: this.config.testMatch,setupFilesAfterEnv: this.config.setupFilesAfterEnv,testTimeout: this.config.testTimeout,maxWorkers: this.config.maxWorkers,verbose: true,runInBand: true // 集成测试通常需要串行运行};return new Promise((resolve, reject) => {jest.runCLI(jestConfig, [process.cwd()]).then(({ results }) => {const tests = [];const errors = [];results.testResults.forEach(testFile => {testFile.testResults.forEach(test => {tests.push({name: test.title,file: testFile.testFilePath,status: test.status,duration: test.duration,error: test.failureMessages.length > 0 ? test.failureMessages[0] : null});});if (testFile.failureMessage) {errors.push(testFile.failureMessage);}});resolve({success: results.success,tests,errors,numTotalTests: results.numTotalTests,numPassedTests: results.numPassedTests,numFailedTests: results.numFailedTests});}).catch(reject);});}// 清理测试环境async cleanupTestEnvironment() {console.log('🧹 Cleaning up integration test environment...');// 停止测试服务器await this.testServer.stop();// 清理测试数据库await this.testDatabase.clean();await this.testDatabase.disconnect();// 恢复全局对象if (global.fetch && global.fetch.mockRestore) {global.fetch.mockRestore();}// 清理Mock服务this.mockServices.forEach(service => {if (typeof service === 'object' && service !== null) {Object.keys(service).forEach(key => {if (service[key] instanceof Map) {service[key].clear();} else if (Array.isArray(service[key])) {service[key].length = 0;}});}});console.log('✅ Integration test environment cleaned up');}// 获取状态getStatus() {return {config: this.config,database: {connected: this.testDatabase.connection?.connected || false,tables: this.testDatabase.tables.size},server: {running: this.testServer.server?.listening || false,routes: this.testServer.routes.size},mockServices: Array.from(this.mockServices.keys())};}
}
3.2 用户管理集成测试示例
// __tests__/integration/user-management.integration.test.js
import React from 'react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { Provider } from 'react-redux';
import { BrowserRouter } from 'react-router-dom';
import { store } from '../../src/store';
import UserManagement from '../../src/components/UserManagement';
import { IntegrationTestRunner } from '../../src/utils/testing';// 集成测试套件
describe('User Management Integration Tests', () => {let testRunner;beforeAll(async () => {// 初始化集成测试环境testRunner = new IntegrationTestRunner({testTimeout: 30000,setupFilesAfterEnv: ['<rootDir>/src/setupTests.js']});await testRunner.startTestEnvironment();});afterAll(async () => {// 清理集成测试环境await testRunner.cleanupTestEnvironment();});beforeEach(async () => {// 每个测试前重置数据await testRunner.testDatabase.clean();await testRunner.seedTestData();});// 测试组件const renderUserManagement = () => {return render(<Provider store={store}><BrowserRouter><UserManagement /></BrowserRouter></Provider>);};describe('用户认证流程', () => {test('应该能够成功登录并获取用户信息', async () => {renderUserManagement();// 查找登录表单const emailInput = screen.getByLabelText(/邮箱/i);const passwordInput = screen.getByLabelText(/密码/i);const loginButton = screen.getByRole('button', { name: /登录/i });// 输入登录信息fireEvent.change(emailInput, { target: { value: 'test@example.com' } });fireEvent.change(passwordInput, { target: { value: 'password123' } });// 点击登录fireEvent.click(loginButton);// 等待登录成功await waitFor(() => {expect(screen.getByText(/欢迎, Test User/i)).toBeInTheDocument();});// 验证用户信息显示expect(screen.getByText('test@example.com')).toBeInTheDocument();// 验证API调用expect(global.fetch).toHaveBeenCalledWith('http://localhost:3001/auth/login',expect.objectContaining({method: 'POST',headers: expect.objectContaining({'Content-Type': 'application/json'}),body: JSON.stringify({email: 'test@example.com',password: 'password123'})}));});test('应该处理登录失败的情况', async () => {renderUserManagement();const emailInput = screen.getByLabelText(/邮箱/i);const passwordInput = screen.getByLabelText(/密码/i);const loginButton = screen.getByRole('button', { name: /登录/i });// 输入错误的登录信息fireEvent.change(emailInput, { target: { value: 'wrong@example.com' } });fireEvent.change(passwordInput, { target: { value: 'wrongpassword' } });fireEvent.click(loginButton);// 等待错误消息显示await waitFor(() => {expect(screen.getByText(/登录失败/i)).toBeInTheDocument();});// 验证用户未登录expect(screen.queryByText(/欢迎/i)).not.toBeInTheDocument();});test('应该能够获取当前用户信息', async () => {// 先模拟已登录状态const authService = testRunner.mockServices.get('auth');const token = 'test_token_123';const user = { id: 1, email: 'test@example.com', name: 'Test User' };authService.tokens.set(token, user);// 设置localStorage中的tokenlocalStorage.setItem('authToken', token);renderUserManagement();// 等待用户信息加载await waitFor(() => {expect(screen.getByText(/Test User/i)).toBeInTheDocument();});// 验证API调用expect(global.fetch).toHaveBeenCalledWith('http://localhost:3001/auth/me',expect.objectContaining({headers: expect.objectContaining({'Authorization': 'Bearer test_token_123'})}));});});describe('用户数据管理', () => {test('应该能够获取用户列表', async () => {renderUserManagement();// 等待用户列表加载await waitFor(() => {expect(screen.getByText('John Doe')).toBeInTheDocument();expect(screen.getByText('Jane Smith')).toBeInTheDocument();expect(screen.getByText('Bob Johnson')).toBeInTheDocument();});// 验证用户信息显示expect(screen.getByText('john@example.com')).toBeInTheDocument();expect(screen.getByText('jane@example.com')).toBeInTheDocument();expect(screen.getByText('bob@example.com')).toBeInTheDocument();});test('应该能够创建新用户', async () => {renderUserManagement();// 查找创建用户表单const nameInput = screen.getByLabelText(/姓名/i);const emailInput = screen.getByLabelText(/邮箱/i);const createButton = screen.getByRole('button', { name: /创建用户/i });// 输入新用户信息fireEvent.change(nameInput, { target: { value: 'New User' } });fireEvent.change(emailInput, { target: { value: 'new@example.com' } });// 点击创建fireEvent.click(createButton);// 等待用户创建成功await waitFor(() => {expect(screen.getByText('New User')).toBeInTheDocument();});// 验证API调用expect(global.fetch).toHaveBeenCalledWith('http://localhost:3001/api/users',expect.objectContaining({method: 'POST',body: JSON.stringify({name: 'New User',email: 'new@example.com'})}));// 验证数据库中的数据const users = await testRunner.testDatabase.query('SELECT * FROM users');expect(users).toContainEqual(expect.objectContaining({name: 'New User',email: 'new@example.com'}));});test('应该能够编辑用户信息', async () => {renderUserManagement();// 等待用户列表加载await waitFor(() => {expect(screen.getByText('John Doe')).toBeInTheDocument();});// 点击编辑按钮const editButton = screen.getByTestId('edit-user-1');fireEvent.click(editButton);// 修改用户信息const nameInput = screen.getByDisplayValue('John Doe');fireEvent.change(nameInput, { target: { value: 'John Smith' } });// 保存修改const saveButton = screen.getByRole('button', { name: /保存/i });fireEvent.click(saveButton);// 等待修改成功await waitFor(() => {expect(screen.getByText('John Smith')).toBeInTheDocument();});// 验证原名称不再显示expect(screen.queryByText('John Doe')).not.toBeInTheDocument();});test('应该能够删除用户', async () => {renderUserManagement();// 等待用户列表加载await waitFor(() => {expect(screen.getByText('Bob Johnson')).toBeInTheDocument();});// 点击删除按钮const deleteButton = screen.getByTestId('delete-user-3');fireEvent.click(deleteButton);// 确认删除const confirmButton = screen.getByRole('button', { name: /确认删除/i });fireEvent.click(confirmButton);// 等待用户被删除await waitFor(() => {expect(screen.queryByText('Bob Johnson')).not.toBeInTheDocument();});});});describe('组件集成', () => {test('应该正确处理组件间的数据流', async () => {renderUserManagement();// 测试用户选择await waitFor(() => {expect(screen.getByText('John Doe')).toBeInTheDocument();});// 选择用户const userRow = screen.getByTestId('user-row-1');fireEvent.click(userRow);// 验证用户详情显示await waitFor(() => {expect(screen.getByTestId('user-details')).toBeInTheDocument();});// 验证用户详情内容expect(screen.getByText('用户ID: 1')).toBeInTheDocument();expect(screen.getByText('角色: user')).toBeInTheDocument();});test('应该正确处理加载状态', async () => {renderUserManagement();// 验证初始加载状态expect(screen.getByTestId('loading-spinner')).toBeInTheDocument();// 等待加载完成await waitFor(() => {expect(screen.queryByTestId('loading-spinner')).not.toBeInTheDocument();});// 验证内容已加载expect(screen.getByText('John Doe')).toBeInTheDocument();});});describe('错误处理', () => {test('应该处理网络错误', async () => {// 模拟网络错误global.fetch.mockRejectedValueOnce(new Error('Network Error'));renderUserManagement();// 等待错误消息显示await waitFor(() => {expect(screen.getByText(/网络错误/i)).toBeInTheDocument();});// 验证重试按钮存在expect(screen.getByRole('button', { name: /重试/i })).toBeInTheDocument();});test('应该处理服务器错误', async () => {// 模拟服务器错误global.fetch.mockResolvedValueOnce({ok: false,status: 500,json: async () => ({ error: 'Internal Server Error' })});renderUserManagement();// 等待错误消息显示await waitFor(() => {expect(screen.getByText(/服务器错误/i)).toBeInTheDocument();});});});describe('性能集成测试', () => {test('应该在合理时间内加载用户列表', async () => {const startTime = Date.now();renderUserManagement();await waitFor(() => {expect(screen.getByText('John Doe')).toBeInTheDocument();});const endTime = Date.now();const loadTime = endTime - startTime;// 验证加载时间小于2秒expect(loadTime).toBeLessThan(2000);});test('应该正确处理大量用户数据', async () => {// 生成大量测试数据const largeUserData = Array.from({ length: 1000 }, (_, i) => ({id: i + 1,name: `User ${i + 1}`,email: `user${i + 1}@example.com`,role: i % 2 === 0 ? 'user' : 'admin'}));await testRunner.testDatabase.seed({ users: largeUserData });const startTime = Date.now();renderUserManagement();// 等待虚拟滚动加载await waitFor(() => {expect(screen.getByText('User 1')).toBeInTheDocument();}, { timeout: 5000 });const endTime = Date.now();const loadTime = endTime - startTime;// 即使有大量数据,加载时间也应该合理expect(loadTime).toBeLessThan(3000);});});
});
4. E2E测试深度实践
4.1 E2E测试运行器
// E2E测试运行器
class E2ETestRunner {constructor(config = {}) {this.config = {browser: 'chromium',headless: true,viewport: { width: 1280, height: 720 },baseURL: 'http://localhost:3000',timeout: 30000,retries: 2,video: 'retain-on-failure',screenshot: 'only-on-failure',trace: 'retain-on-failure',...config};this.browser = null;this.context = null;this.page = null;this.testResults = [];this.screenshots = [];this.videos = [];this.init();}// 初始化async init() {const { chromium, firefox, webkit } = require('playwright');// 选择浏览器const browsers = { chromium, firefox, webkit };this.browserType = browsers[this.config.browser] || chromium;console.log(`🎭 Initializing E2E tests with ${this.config.browser}`);}// 启动浏览器async startBrowser() {try {this.browser = await this.browserType.launch({headless: this.config.headless,slowMo: this.config.slowMo || 0});this.context = await this.browser.newContext({viewport: this.config.viewport,baseURL: this.config.baseURL,recordVideo: this.config.video ? {dir: './test-results/videos/',size: this.config.viewport} : undefined});// 启用追踪if (this.config.trace) {await this.context.tracing.start({screenshots: true,snapshots: true,sources: true});}this.page = await this.context.newPage();// 设置默认超时this.page.setDefaultTimeout(this.config.timeout);// 添加控制台日志监听this.page.on('console', msg => {console.log(`🖥️ Console ${msg.type()}: ${msg.text()}`);});// 添加页面错误监听this.page.on('pageerror', error => {console.error('🚨 Page error:', error.message);});// 添加请求失败监听this.page.on('requestfailed', request => {console.error(`🌐 Request failed: ${request.url()} - ${request.failure()?.errorText}`);});console.log('🚀 Browser started successfully');} catch (error) {console.error('❌ Failed to start browser:', error);throw error;}}// 停止浏览器async stopBrowser() {try {if (this.config.trace && this.context) {await this.context.tracing.stop({path: `./test-results/traces/trace-${Date.now()}.zip`});}if (this.context) {await this.context.close();}if (this.browser) {await this.browser.close();}console.log('🛑 Browser stopped');} catch (error) {console.error('❌ Error stopping browser:', error);}}// 运行E2E测试async run(testSuites = []) {const startTime = Date.now();try {console.log('🎬 Starting E2E tests...');await this.startBrowser();const results = {total: 0,passed: 0,failed: 0,skipped: 0,tests: [],duration: 0};for (const suite of testSuites) {console.log(`📋 Running test suite: ${suite.name}`);const suiteResult = await this.runTestSuite(suite);results.total += suiteResult.total;results.passed += suiteResult.passed;results.failed += suiteResult.failed;results.skipped += suiteResult.skipped;results.tests.push(...suiteResult.tests);}await this.stopBrowser();const endTime = Date.now();results.duration = endTime - startTime;console.log(`✅ E2E tests completed in ${results.duration}ms`);console.log(`📊 Results: ${results.passed} passed, ${results.failed} failed, ${results.skipped} skipped`);return results;} catch (error) {console.error('❌ E2E test execution failed:', error);await this.stopBrowser();throw error;}}// 运行测试套件async runTestSuite(suite) {const results = {name: suite.name,total: 0,passed: 0,failed: 0,skipped: 0,tests: []};// 运行套件前置操作if (suite.beforeAll) {await suite.beforeAll(this.page, this.context);}for (const test of suite.tests) {const testResult = await this.runTest(test, suite);results.total++;results.tests.push(testResult);if (testResult.status === 'passed') {results.passed++;} else if (testResult.status === 'failed') {results.failed++;} else {results.skipped++;}}// 运行套件后置操作if (suite.afterAll) {await suite.afterAll(this.page, this.context);}return results;}// 运行单个测试async runTest(test, suite) {const startTime = Date.now();const testResult = {name: test.name,suite: suite.name,status: 'pending',duration: 0,error: null,screenshots: [],video: null};try {console.log(` 🧪 Running test: ${test.name}`);// 运行测试前置操作if (suite.beforeEach) {await suite.beforeEach(this.page, this.context);}// 运行测试await test.fn(this.page, this.context, this);testResult.status = 'passed';console.log(` ✅ Test passed: ${test.name}`);} catch (error) {testResult.status = 'failed';testResult.error = error.message;console.error(` ❌ Test failed: ${test.name}`);console.error(` Error: ${error.message}`);// 截图if (this.config.screenshot === 'only-on-failure' || this.config.screenshot === 'always') {const screenshotPath = `./test-results/screenshots/${suite.name}-${test.name}-${Date.now()}.png`;await this.page.screenshot({ path: screenshotPath, fullPage: true });testResult.screenshots.push(screenshotPath);}} finally {// 运行测试后置操作if (suite.afterEach) {await suite.afterEach(this.page, this.context);}const endTime = Date.now();testResult.duration = endTime - startTime;}return testResult;}// 页面操作助手async navigateTo(url) {await this.page.goto(url);await this.page.waitForLoadState('networkidle');}async waitForElement(selector, options = {}) {return await this.page.waitForSelector(selector, {timeout: this.config.timeout,...options});}async clickElement(selector) {await this.page.click(selector);}async fillInput(selector, value) {await this.page.fill(selector, value);}async getText(selector) {return await this.page.textContent(selector);}async takeScreenshot(name) {const screenshotPath = `./test-results/screenshots/${name}-${Date.now()}.png`;await this.page.screenshot({ path: screenshotPath, fullPage: true });this.screenshots.push(screenshotPath);return screenshotPath;}async waitForResponse(urlPattern, action) {const responsePromise = this.page.waitForResponse(urlPattern);await action();return await responsePromise;}async interceptRequest(urlPattern, handler) {await this.page.route(urlPattern, handler);}// 性能测试助手async measurePageLoad(url) {const startTime = Date.now();await this.page.goto(url);await this.page.waitForLoadState('networkidle');const endTime = Date.now();const loadTime = endTime - startTime;// 获取性能指标const metrics = await this.page.evaluate(() => {const navigation = performance.getEntriesByType('navigation')[0];return {domContentLoaded: navigation.domContentLoadedEventEnd - navigation.domContentLoadedEventStart,loadComplete: navigation.loadEventEnd - navigation.loadEventStart,firstPaint: performance.getEntriesByName('first-paint')[0]?.startTime || 0,firstContentfulPaint: performance.getEntriesByName('first-contentful-paint')[0]?.startTime || 0};});return {totalLoadTime: loadTime,...metrics};}// 可访问性测试助手async checkAccessibility() {const { injectAxe, checkA11y } = require('axe-playwright');await injectAxe(this.page);try {await checkA11y(this.page, null, {detailedReport: true,detailedReportOptions: { html: true }});return { passed: true, violations: [] };} catch (error) {return {passed: false,violations: error.violations || []};}}// 获取状态getStatus() {return {config: this.config,browser: {connected: !!this.browser,type: this.config.browser},context: {created: !!this.context},page: {created: !!this.page,url: this.page?.url() || null},results: {tests: this.testResults.length,screenshots: this.screenshots.length,videos: this.videos.length}};}
}
4.2 电商应用E2E测试示例
// e2e/ecommerce-flow.spec.js
const { E2ETestRunner } = require('../src/utils/testing');// E2E测试套件
const ecommerceTestSuite = {name: 'E-commerce Application Flow',beforeAll: async (page, context) => {// 设置测试环境await page.goto('/login');// 清理测试数据await page.evaluate(() => {localStorage.clear();sessionStorage.clear();});},afterAll: async (page, context) => {// 清理测试环境await page.evaluate(() => {localStorage.clear();sessionStorage.clear();});},beforeEach: async (page, context) => {// 每个测试前重置状态await page.goto('/');},afterEach: async (page, context) => {// 每个测试后清理await page.evaluate(() => {// 清理购物车if (window.store) {window.store.dispatch({ type: 'CLEAR_CART' });}});},tests: [{name: '用户注册流程',fn: async (page, context, runner) => {// 导航到注册页面await runner.navigateTo('/register');// 填写注册表单await runner.fillInput('[data-testid="register-name"]', 'Test User');await runner.fillInput('[data-testid="register-email"]', 'test@example.com');await runner.fillInput('[data-testid="register-password"]', 'password123');await runner.fillInput('[data-testid="register-confirm-password"]', 'password123');// 提交注册await runner.clickElement('[data-testid="register-submit"]');// 等待注册成功await runner.waitForElement('[data-testid="registration-success"]');// 验证跳转到登录页面await page.waitForURL('**/login');// 验证成功消息const successMessage = await runner.getText('[data-testid="registration-success"]');expect(successMessage).toContain('注册成功');}},{name: '用户登录流程',fn: async (page, context, runner) => {// 导航到登录页面await runner.navigateTo('/login');// 填写登录表单await runner.fillInput('[data-testid="login-email"]', 'test@example.com');await runner.fillInput('[data-testid="login-password"]', 'password123');// 提交登录await runner.clickElement('[data-testid="login-submit"]');// 等待登录成功await runner.waitForElement('[data-testid="user-menu"]');// 验证跳转到首页await page.waitForURL('**/dashboard');// 验证用户信息显示const userMenu = await runner.getText('[data-testid="user-menu"]');expect(userMenu).toContain('Test User');}},{name: '商品浏览和搜索',fn: async (page, context, runner) => {// 导航到商品页面await runner.navigateTo('/products');// 等待商品列表加载await runner.waitForElement('[data-testid="product-list"]');// 验证商品显示const products = await page.$$('[data-testid="product-item"]');expect(products.length).toBeGreaterThan(0);// 搜索商品await runner.fillInput('[data-testid="search-input"]', 'iPhone');await runner.clickElement('[data-testid="search-button"]');// 等待搜索结果await page.waitForSelector('[data-testid="search-results"]');// 验证搜索结果const searchResults = await page.$$('[data-testid="product-item"]');expect(searchResults.length).toBeGreaterThan(0);// 验证搜索结果包含关键词const firstProduct = await runner.getText('[data-testid="product-item"]:first-child [data-testid="product-name"]');expect(firstProduct.toLowerCase()).toContain('iphone');}},{name: '商品详情查看',fn: async (page, context, runner) => {// 导航到商品页面await runner.navigateTo('/products');// 点击第一个商品await runner.clickElement('[data-testid="product-item"]:first-child');// 等待商品详情页面加载await runner.waitForElement('[data-testid="product-details"]');// 验证商品详情信息await runner.waitForElement('[data-testid="product-name"]');await runner.waitForElement('[data-testid="product-price"]');await runner.waitForElement('[data-testid="product-description"]');await runner.waitForElement('[data-testid="product-images"]');// 验证添加到购物车按钮存在await runner.waitForElement('[data-testid="add-to-cart"]');// 验证商品评价部分await runner.waitForElement('[data-testid="product-reviews"]');}},{name: '购物车操作流程',fn: async (page, context, runner) => {// 先登录await runner.navigateTo('/login');await runner.fillInput('[data-testid="login-email"]', 'test@example.com');await runner.fillInput('[data-testid="login-password"]', 'password123');await runner.clickElement('[data-testid="login-submit"]');await runner.waitForElement('[data-testid="user-menu"]');// 导航到商品页面await runner.navigateTo('/products');// 添加第一个商品到购物车await runner.clickElement('[data-testid="product-item"]:first-child [data-testid="add-to-cart"]');// 等待添加成功提示await runner.waitForElement('[data-testid="cart-notification"]');// 验证购物车图标显示数量const cartCount = await runner.getText('[data-testid="cart-count"]');expect(cartCount).toBe('1');// 点击购物车图标await runner.clickElement('[data-testid="cart-icon"]');// 等待购物车页面加载await runner.waitForElement('[data-testid="cart-items"]');// 验证商品在购物车中const cartItems = await page.$$('[data-testid="cart-item"]');expect(cartItems.length).toBe(1);// 修改商品数量await runner.clickElement('[data-testid="quantity-increase"]');// 验证数量更新const quantity = await runner.getText('[data-testid="item-quantity"]');expect(quantity).toBe('2');// 验证总价更新const totalPrice = await runner.getText('[data-testid="cart-total"]');expect(totalPrice).toMatch(/\$\d+\.\d{2}/);}},{name: '结账流程',fn: async (page, context, runner) => {// 先添加商品到购物车(复用之前的步骤)await runner.navigateTo('/login');await runner.fillInput('[data-testid="login-email"]', 'test@example.com');await runner.fillInput('[data-testid="login-password"]', 'password123');await runner.clickElement('[data-testid="login-submit"]');await runner.waitForElement('[data-testid="user-menu"]');await runner.navigateTo('/products');await runner.clickElement('[data-testid="product-item"]:first-child [data-testid="add-to-cart"]');await runner.waitForElement('[data-testid="cart-notification"]');// 进入购物车await runner.clickElement('[data-testid="cart-icon"]');await runner.waitForElement('[data-testid="cart-items"]');// 点击结账按钮await runner.clickElement('[data-testid="checkout-button"]');// 等待结账页面加载await runner.waitForElement('[data-testid="checkout-form"]');// 填写配送信息await runner.fillInput('[data-testid="shipping-name"]', 'Test User');await runner.fillInput('[data-testid="shipping-address"]', '123 Test Street');await runner.fillInput('[data-testid="shipping-city"]', 'Test City');await runner.fillInput('[data-testid="shipping-zip"]', '12345');// 选择配送方式await runner.clickElement('[data-testid="shipping-standard"]');// 填写支付信息await runner.fillInput('[data-testid="card-number"]', '4111111111111111');await runner.fillInput('[data-testid="card-expiry"]', '12/25');await runner.fillInput('[data-testid="card-cvc"]', '123');await runner.fillInput('[data-testid="card-name"]', 'Test User');// 提交订单await runner.clickElement('[data-testid="place-order"]');// 等待订单确认页面await runner.waitForElement('[data-testid="order-confirmation"]');// 验证订单号const orderNumber = await runner.getText('[data-testid="order-number"]');expect(orderNumber).toMatch(/^ORD-\d+$/);// 验证订单详情await runner.waitForElement('[data-testid="order-items"]');await runner.waitForElement('[data-testid="order-total"]');await runner.waitForElement('[data-testid="shipping-info"]');}},{name: '订单历史查看',fn: async (page, context, runner) => {// 登录await runner.navigateTo('/login');await runner.fillInput('[data-testid="login-email"]', 'test@example.com');await runner.fillInput('[data-testid="login-password"]', 'password123');await runner.clickElement('[data-testid="login-submit"]');await runner.waitForElement('[data-testid="user-menu"]');// 导航到订单历史页面await runner.clickElement('[data-testid="user-menu"]');await runner.clickElement('[data-testid="order-history"]');// 等待订单列表加载await runner.waitForElement('[data-testid="order-list"]');// 验证订单显示const orders = await page.$$('[data-testid="order-item"]');expect(orders.length).toBeGreaterThan(0);// 点击查看订单详情await runner.clickElement('[data-testid="order-item"]:first-child [data-testid="view-order"]');// 等待订单详情加载await runner.waitForElement('[data-testid="order-details"]');// 验证订单详情信息await runner.waitForElement('[data-testid="order-status"]');await runner.waitForElement('[data-testid="order-date"]');await runner.waitForElement('[data-testid="order-items-detail"]');await runner.waitForElement('[data-testid="shipping-address"]');}},{name: '响应式设计测试',fn: async (page, context, runner) => {// 测试桌面视图await page.setViewportSize({ width: 1280, height: 720 });await runner.navigateTo('/');// 验证桌面导航await runner.waitForElement('[data-testid="desktop-nav"]');expect(await page.isVisible('[data-testid="mobile-menu-button"]')).toBe(false);// 测试平板视图await page.setViewportSize({ width: 768, height: 1024 });await page.reload();// 验证平板布局await runner.waitForElement('[data-testid="tablet-layout"]');// 测试手机视图await page.setViewportSize({ width: 375, height: 667 });await page.reload();// 验证移动端导航await runner.waitForElement('[data-testid="mobile-menu-button"]');expect(await page.isVisible('[data-testid="desktop-nav"]')).toBe(false);// 测试移动端菜单await runner.clickElement('[data-testid="mobile-menu-button"]');await runner.waitForElement('[data-testid="mobile-menu"]');// 恢复桌面视图await page.setViewportSize({ width: 1280, height: 720 });}},{name: '性能测试',fn: async (page, context, runner) => {// 测试首页加载性能const homeMetrics = await runner.measurePageLoad('/');// 验证加载时间expect(homeMetrics.totalLoadTime).toBeLessThan(3000);expect(homeMetrics.firstContentfulPaint).toBeLessThan(1500);// 测试商品页面加载性能const productsMetrics = await runner.measurePageLoad('/products');// 验证商品页面性能expect(productsMetrics.totalLoadTime).toBeLessThan(5000);expect(productsMetrics.domContentLoaded).toBeLessThan(2000);// 测试搜索性能const searchStart = Date.now();await runner.fillInput('[data-testid="search-input"]', 'test');await runner.clickElement('[data-testid="search-button"]');await runner.waitForElement('[data-testid="search-results"]');const searchTime = Date.now() - searchStart;expect(searchTime).toBeLessThan(2000);}},{name: '可访问性测试',fn: async (page, context, runner) => {// 测试首页可访问性await runner.navigateTo('/');const homeA11y = await runner.checkAccessibility();if (!homeA11y.passed) {console.warn('首页可访问性问题:', homeA11y.violations);}// 测试登录页面可访问性await runner.navigateTo('/login');const loginA11y = await runner.checkAccessibility();if (!loginA11y.passed) {console.warn('登录页面可访问性问题:', loginA11y.violations);}// 测试键盘导航await page.keyboard.press('Tab');const focusedElement = await page.evaluate(() => document.activeElement.tagName);expect(['INPUT', 'BUTTON', 'A']).toContain(focusedElement);// 测试屏幕阅读器支持const loginButton = await page.$('[data-testid="login-submit"]');const ariaLabel = await loginButton.getAttribute('aria-label');expect(ariaLabel).toBeTruthy();}}]
};// 运行E2E测试
async function runE2ETests() {const runner = new E2ETestRunner({browser: 'chromium',headless: false, // 开发时可以设置为false观察测试过程baseURL: 'http://localhost:3000',timeout: 30000,video: 'retain-on-failure',screenshot: 'only-on-failure'});try {const results = await runner.run([ecommerceTestSuite]);console.log('\n📊 E2E测试结果:');console.log(`总计: ${results.total}`);console.log(`通过: ${results.passed}`);console.log(`失败: ${results.failed}`);console.log(`跳过: ${results.skipped}`);console.log(`耗时: ${results.duration}ms`);if (results.failed > 0) {console.log('\n❌ 失败的测试:');results.tests.filter(test => test.status === 'failed').forEach(test => {console.log(` - ${test.name}: ${test.error}`);});}return results;} catch (error) {console.error('E2E测试执行失败:', error);throw error;}
}// 导出测试套件和运行函数
module.exports = {ecommerceTestSuite,runE2ETests
};
5. 最佳实践与总结
5.1 测试策略设计原则
测试金字塔实践
// 测试策略管理器
class TestStrategyManager {constructor() {this.strategies = {unit: {ratio: 0.7, // 70%的测试应该是单元测试focus: ['函数逻辑', '组件行为', '工具函数', '状态管理'],tools: ['Jest', 'React Testing Library', 'Enzyme'],coverage: { minimum: 80, target: 90 }},integration: {ratio: 0.2, // 20%的测试应该是集成测试focus: ['组件集成', 'API集成', '数据流', '用户交互'],tools: ['Jest', 'MSW', 'Testing Library'],coverage: { minimum: 60, target: 75 }},e2e: {ratio: 0.1, // 10%的测试应该是E2E测试focus: ['关键用户流程', '跨浏览器兼容性', '性能', '可访问性'],tools: ['Playwright', 'Cypress', 'Puppeteer'],coverage: { minimum: 40, target: 60 }}};this.qualityGates = {coverage: {statements: 80,branches: 75,functions: 80,lines: 80},performance: {unitTestSpeed: 1000, // ms per testintegrationTestSpeed: 5000,e2eTestSpeed: 30000},reliability: {flakyTestThreshold: 0.05, // 5%testStability: 0.95 // 95%}};}// 评估测试策略evaluateStrategy(testResults) {const evaluation = {distribution: this.analyzeTestDistribution(testResults),coverage: this.analyzeCoverage(testResults),performance: this.analyzePerformance(testResults),quality: this.analyzeQuality(testResults),recommendations: []};// 生成改进建议evaluation.recommendations = this.generateRecommendations(evaluation);return evaluation;}// 分析测试分布analyzeTestDistribution(testResults) {const total = testResults.unit.count + testResults.integration.count + testResults.e2e.count;return {unit: {actual: testResults.unit.count / total,expected: this.strategies.unit.ratio,status: this.getDistributionStatus('unit', testResults.unit.count / total)},integration: {actual: testResults.integration.count / total,expected: this.strategies.integration.ratio,status: this.getDistributionStatus('integration', testResults.integration.count / total)},e2e: {actual: testResults.e2e.count / total,expected: this.strategies.e2e.ratio,status: this.getDistributionStatus('e2e', testResults.e2e.count / total)}};}// 获取分布状态getDistributionStatus(type, actual) {const expected = this.strategies[type].ratio;const tolerance = 0.1; // 10%容差if (Math.abs(actual - expected) <= tolerance) {return 'optimal';} else if (actual > expected) {return 'over';} else {return 'under';}}// 生成改进建议generateRecommendations(evaluation) {const recommendations = [];// 测试分布建议Object.keys(evaluation.distribution).forEach(type => {const dist = evaluation.distribution[type];if (dist.status === 'under') {recommendations.push({type: 'distribution',priority: 'high',message: `增加${type}测试数量,当前比例${(dist.actual * 100).toFixed(1)}%,建议${(dist.expected * 100).toFixed(1)}%`});} else if (dist.status === 'over') {recommendations.push({type: 'distribution',priority: 'medium',message: `${type}测试比例过高,考虑重构为更低层次的测试`});}});// 覆盖率建议Object.keys(evaluation.coverage).forEach(metric => {const coverage = evaluation.coverage[metric];if (coverage < this.qualityGates.coverage[metric]) {recommendations.push({type: 'coverage',priority: 'high',message: `${metric}覆盖率不足,当前${coverage}%,要求${this.qualityGates.coverage[metric]}%`});}});// 性能建议if (evaluation.performance.averageTestTime > this.qualityGates.performance.unitTestSpeed) {recommendations.push({type: 'performance',priority: 'medium',message: '测试执行时间过长,考虑优化测试代码或并行执行'});}return recommendations;}
}
5.2 性能优化策略
测试执行优化
// 测试性能优化器
class TestPerformanceOptimizer {constructor() {this.optimizations = {parallel: true,cache: true,incremental: true,smartSelection: true};this.metrics = {executionTime: [],memoryUsage: [],cacheHitRate: 0,parallelEfficiency: 0};}// 优化测试执行async optimizeExecution(testSuites) {const optimizedSuites = [];for (const suite of testSuites) {const optimizedSuite = await this.optimizeSuite(suite);optimizedSuites.push(optimizedSuite);}return {suites: optimizedSuites,config: this.generateOptimizedConfig(),recommendations: this.generateOptimizationRecommendations()};}// 优化测试套件async optimizeSuite(suite) {const optimized = { ...suite };// 智能测试选择if (this.optimizations.smartSelection) {optimized.tests = await this.selectRelevantTests(suite.tests);}// 测试分组优化optimized.groups = this.optimizeTestGroups(optimized.tests);// 资源预加载optimized.preload = this.identifyPreloadResources(optimized.tests);return optimized;}// 智能测试选择async selectRelevantTests(tests) {const changedFiles = await this.getChangedFiles();const relevantTests = [];for (const test of tests) {const dependencies = await this.getTestDependencies(test);const isRelevant = dependencies.some(dep => changedFiles.some(file => dep.includes(file)));if (isRelevant || test.critical) {relevantTests.push(test);}}return relevantTests.length > 0 ? relevantTests : tests;}// 获取变更文件async getChangedFiles() {try {const { execSync } = require('child_process');const output = execSync('git diff --name-only HEAD~1', { encoding: 'utf8' });return output.trim().split('\n').filter(Boolean);} catch (error) {console.warn('无法获取变更文件,运行所有测试');return [];}}// 获取测试依赖async getTestDependencies(test) {// 简化的依赖分析const dependencies = [];if (test.file) {const fs = require('fs');const content = fs.readFileSync(test.file, 'utf8');// 提取import语句const imports = content.match(/import.*from\s+['"]([^'"]+)['"]/g) || [];imports.forEach(imp => {const match = imp.match(/from\s+['"]([^'"]+)['"]/);;if (match) {dependencies.push(match[1]);}});}return dependencies;}// 优化测试分组optimizeTestGroups(tests) {const groups = {fast: [], // 快速测试(<100ms)medium: [], // 中等测试(100ms-1s)slow: [], // 慢速测试(>1s)isolated: [] // 需要隔离的测试};tests.forEach(test => {if (test.isolated) {groups.isolated.push(test);} else if (test.estimatedTime < 100) {groups.fast.push(test);} else if (test.estimatedTime < 1000) {groups.medium.push(test);} else {groups.slow.push(test);}});return groups;}// 生成优化配置generateOptimizedConfig() {return {maxWorkers: this.optimizations.parallel ? '50%' : 1,cache: this.optimizations.cache,cacheDirectory: '.jest-cache',testTimeout: 10000,setupFilesAfterEnv: ['<rootDir>/src/setupTests.js'],collectCoverageFrom: ['src/**/*.{js,jsx,ts,tsx}','!src/**/*.d.ts','!src/index.js','!src/serviceWorker.js'],coverageThreshold: {global: {branches: 75,functions: 80,lines: 80,statements: 80}},watchPlugins: ['jest-watch-typeahead/filename','jest-watch-typeahead/testname']};}// 生成优化建议generateOptimizationRecommendations() {const recommendations = [];// 并行执行建议if (!this.optimizations.parallel) {recommendations.push({type: 'parallel',impact: 'high',message: '启用并行测试执行可以显著提升速度'});}// 缓存建议if (!this.optimizations.cache) {recommendations.push({type: 'cache',impact: 'medium',message: '启用测试缓存可以避免重复执行未变更的测试'});}// 增量测试建议if (!this.optimizations.incremental) {recommendations.push({type: 'incremental',impact: 'medium',message: '启用增量测试可以只运行相关的测试'});}return recommendations;}
}
5.3 开发体验提升
测试开发工具
// 测试开发助手
class TestDevelopmentHelper {constructor() {this.templates = new Map();this.snippets = new Map();this.generators = new Map();this.initializeTemplates();this.initializeSnippets();this.initializeGenerators();}// 初始化模板initializeTemplates() {// React组件测试模板this.templates.set('react-component', `
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import { {{componentName}} } from './{{componentName}}';describe('{{componentName}}', () => {test('应该正确渲染', () => {render(<{{componentName}} />);// 添加断言});test('应该处理用户交互', () => {render(<{{componentName}} />);// 添加交互测试});test('应该处理props变化', () => {const { rerender } = render(<{{componentName}} prop="value1" />);// 测试props变化rerender(<{{componentName}} prop="value2" />);// 添加断言});
});
`);// Hook测试模板this.templates.set('react-hook', `
import { renderHook, act } from '@testing-library/react';
import { {{hookName}} } from './{{hookName}}';describe('{{hookName}}', () => {test('应该返回初始状态', () => {const { result } = renderHook(() => {{hookName}}());// 添加断言});test('应该正确更新状态', () => {const { result } = renderHook(() => {{hookName}}());act(() => {// 执行状态更新});// 添加断言});
});
`);// 工具函数测试模板this.templates.set('utility-function', `
import { {{functionName}} } from './{{functionName}}';describe('{{functionName}}', () => {test('应该处理正常输入', () => {const result = {{functionName}}(/* 正常输入 */);expect(result).toBe(/* 期望结果 */);});test('应该处理边界情况', () => {// 测试边界情况});test('应该处理错误输入', () => {expect(() => {{{functionName}}(/* 错误输入 */);}).toThrow();});
});
`);}// 初始化代码片段initializeSnippets() {this.snippets.set('mock-api', `
// Mock API响应
const mockApiResponse = {data: {// 模拟数据},status: 200,statusText: 'OK'
};jest.mock('axios', () => ({get: jest.fn(() => Promise.resolve(mockApiResponse)),post: jest.fn(() => Promise.resolve(mockApiResponse)),put: jest.fn(() => Promise.resolve(mockApiResponse)),delete: jest.fn(() => Promise.resolve(mockApiResponse))
}));
`);this.snippets.set('mock-localStorage', `
// Mock localStorage
const localStorageMock = {getItem: jest.fn(),setItem: jest.fn(),removeItem: jest.fn(),clear: jest.fn()
};Object.defineProperty(window, 'localStorage', {value: localStorageMock
});
`);this.snippets.set('async-test', `
test('应该处理异步操作', async () => {// 等待异步操作完成await waitFor(() => {expect(screen.getByText('加载完成')).toBeInTheDocument();});// 或者使用findBy查询const element = await screen.findByText('异步内容');expect(element).toBeInTheDocument();
});
`);}// 初始化生成器initializeGenerators() {this.generators.set('component-test', this.generateComponentTest.bind(this));this.generators.set('hook-test', this.generateHookTest.bind(this));this.generators.set('integration-test', this.generateIntegrationTest.bind(this));}// 生成组件测试generateComponentTest(componentPath) {const fs = require('fs');const path = require('path');// 读取组件文件const componentContent = fs.readFileSync(componentPath, 'utf8');// 提取组件信息const componentInfo = this.analyzeComponent(componentContent);// 生成测试代码const testCode = this.generateTestFromTemplate('react-component', {componentName: componentInfo.name,props: componentInfo.props,events: componentInfo.events,states: componentInfo.states});// 写入测试文件const testPath = componentPath.replace(/\.(jsx?|tsx?)$/, '.test.$1');fs.writeFileSync(testPath, testCode);return testPath;}// 分析组件analyzeComponent(content) {const info = {name: '',props: [],events: [],states: []};// 提取组件名称const nameMatch = content.match(/(?:function|const)\s+(\w+)|class\s+(\w+)/);if (nameMatch) {info.name = nameMatch[1] || nameMatch[2];}// 提取propsconst propsMatch = content.match(/\{([^}]+)\}\s*=\s*props/);if (propsMatch) {info.props = propsMatch[1].split(',').map(prop => prop.trim());}// 提取事件处理器const eventMatches = content.match(/on\w+\s*=/g) || [];info.events = eventMatches.map(event => event.replace(/\s*=$/, ''));// 提取状态const stateMatches = content.match(/useState\(([^)]+)\)/g) || [];info.states = stateMatches.map((state, index) => `state${index}`);return info;}// 从模板生成测试generateTestFromTemplate(templateName, data) {let template = this.templates.get(templateName);// 替换模板变量Object.keys(data).forEach(key => {const regex = new RegExp(`\{\{${key}\}\}`, 'g');template = template.replace(regex, data[key]);});return template;}// 生成测试报告generateTestReport(results) {const report = {summary: {total: results.numTotalTests,passed: results.numPassedTests,failed: results.numFailedTests,skipped: results.numPendingTests,coverage: results.coverageMap},details: results.testResults.map(testFile => ({file: testFile.testFilePath,tests: testFile.testResults.map(test => ({name: test.title,status: test.status,duration: test.duration,error: test.failureMessages[0] || null}))})),recommendations: this.generateTestRecommendations(results)};return report;}// 生成测试建议generateTestRecommendations(results) {const recommendations = [];// 覆盖率建议if (results.coverageMap) {const coverage = results.coverageMap.getCoverageSummary();if (coverage.statements.pct < 80) {recommendations.push({type: 'coverage',priority: 'high',message: `语句覆盖率${coverage.statements.pct}%,建议提升至80%以上`});}if (coverage.branches.pct < 75) {recommendations.push({type: 'coverage',priority: 'medium',message: `分支覆盖率${coverage.branches.pct}%,建议提升至75%以上`});}}// 测试质量建议const failedTests = results.testResults.filter(test => test.numFailingTests > 0);if (failedTests.length > 0) {recommendations.push({type: 'quality',priority: 'high',message: `${failedTests.length}个测试文件包含失败的测试,需要修复`});}// 性能建议const slowTests = results.testResults.filter(test => test.perfStats.end - test.perfStats.start > 5000);if (slowTests.length > 0) {recommendations.push({type: 'performance',priority: 'medium',message: `${slowTests.length}个测试文件执行时间过长,考虑优化`});}return recommendations;}
}
5.4 技术选型建议
测试工具选择矩阵
测试类型 | 推荐工具 | 适用场景 | 优势 | 劣势 |
---|---|---|---|---|
单元测试 | Jest + RTL | React应用 | 生态完善、配置简单 | 学习曲线 |
单元测试 | Vitest | Vite项目 | 速度快、ESM支持 | 生态较新 |
集成测试 | Jest + MSW | API集成 | Mock能力强 | 配置复杂 |
E2E测试 | Playwright | 跨浏览器 | 功能全面、稳定 | 资源消耗大 |
E2E测试 | Cypress | 开发体验 | 调试友好、实时预览 | 浏览器限制 |
性能测试 | Lighthouse CI | Web性能 | 标准化指标 | 配置复杂 |
可访问性 | axe-core | 无障碍测试 | 规则全面 | 需要人工验证 |
5.5 未来发展趋势
AI驱动的测试
// AI测试助手(概念性实现)
class AITestingAssistant {constructor() {this.model = null; // AI模型接口this.testPatterns = new Map();this.bugPatterns = new Map();}// AI生成测试用例async generateTestCases(componentCode) {const analysis = await this.analyzeCode(componentCode);const testCases = await this.model.generate({prompt: `为以下React组件生成测试用例:\n${componentCode}`,context: {patterns: Array.from(this.testPatterns.values()),bestPractices: this.getTestingBestPractices()}});return this.validateGeneratedTests(testCases);}// 智能Bug检测async detectPotentialBugs(testResults) {const patterns = await this.model.analyze({data: testResults,patterns: Array.from(this.bugPatterns.values())});return patterns.map(pattern => ({type: pattern.type,confidence: pattern.confidence,description: pattern.description,suggestion: pattern.suggestion}));}// 自动化测试维护async maintainTests(codeChanges) {const affectedTests = await this.findAffectedTests(codeChanges);const updates = [];for (const test of affectedTests) {const update = await this.model.updateTest({originalTest: test.content,codeChanges: codeChanges,context: test.context});updates.push({file: test.file,changes: update.changes,confidence: update.confidence});}return updates;}
}
5.6 核心价值与收益
测试投资回报分析
// 测试ROI计算器
class TestingROICalculator {constructor() {this.metrics = {bugDetectionCost: 100, // 测试中发现bug的成本productionBugCost: 10000, // 生产环境bug的成本testMaintenanceCost: 50, // 测试维护成本developmentSpeedIncrease: 0.2, // 开发速度提升codeQualityImprovement: 0.3 // 代码质量提升};}// 计算测试ROIcalculateROI(testingData) {const costs = this.calculateCosts(testingData);const benefits = this.calculateBenefits(testingData);const roi = ((benefits - costs) / costs) * 100;return {roi: roi,costs: costs,benefits: benefits,paybackPeriod: costs / (benefits / 12), // 月breakdown: {bugPrevention: this.calculateBugPreventionValue(testingData),developmentSpeed: this.calculateSpeedValue(testingData),codeQuality: this.calculateQualityValue(testingData),maintenance: this.calculateMaintenanceValue(testingData)}};}// 计算测试成本calculateCosts(data) {return {development: data.testDevelopmentHours * data.hourlyRate,maintenance: data.testCount * this.metrics.testMaintenanceCost,infrastructure: data.ciCdCosts + data.toolingCosts,total: 0};}// 计算测试收益calculateBenefits(data) {const bugsPrevented = data.bugsFoundInTesting;const productionBugsSaved = bugsPrevented * 0.8; // 80%的bug会到生产环境return {bugPrevention: productionBugsSaved * this.metrics.productionBugCost,speedIncrease: data.developmentHours * data.hourlyRate * this.metrics.developmentSpeedIncrease,qualityImprovement: data.maintenanceHours * data.hourlyRate * this.metrics.codeQualityImprovement,total: 0};}
}
结语
前端测试是现代Web开发不可或缺的重要环节。通过建立完善的测试体系,我们能够:
- 提升代码质量:通过全面的测试覆盖,确保代码的正确性和稳定性
- 加速开发流程:自动化测试减少手动验证时间,提升开发效率
- 降低维护成本:早期发现问题,避免生产环境的高昂修复成本
- 增强团队信心:完善的测试让重构和新功能开发更加安全
- 改善用户体验:确保应用在各种场景下都能正常工作
测试不仅仅是质量保证的手段,更是现代软件工程实践的基础。投资于测试体系建设,将为项目的长期成功奠定坚实基础。
相关文章推荐:
- 前端工程化深度实践:从脚手架到部署的完整工程化解决方案
- 前端性能优化深度指南:从理论到实践的完整性能优化方案
- 前端状态管理深度实践:从Redux到Zustand的现代化状态管理方案
- 前端安全防护深度实践:从XSS到CSRF的完整安全解决方案
2. 单元测试深度实践
2.1 单元测试运行器
// 单元测试运行器
class UnitTestRunner {constructor(config = {}) {this.config = {framework: 'jest',environment: 'jsdom',setupFiles: [],testMatch: ['**/__tests__/**/*.test.{js,jsx,ts,tsx}'],collectCoverageFrom: [],coverageThreshold: {},moduleNameMapping: {},transform: {},...config};this.testFiles = [];this.testResults = [];this.mockRegistry = new Map();this.spyRegistry = new Map();this.init();}// 初始化init() {this.setupTestFramework();this.setupMockSystem();this.setupTestUtilities();}// 设置测试框架setupTestFramework() {// Jest配置this.jestConfig = {testEnvironment: this.config.environment,setupFilesAfterEnv: this.config.setupFiles,testMatch: this.config.testMatch,collectCoverageFrom: this.config.collectCoverageFrom,coverageThreshold: this.config.coverageThreshold,moduleNameMapping: this.config.moduleNameMapping,transform: {'^.+\\.(js|jsx|ts|tsx)$': 'babel-jest','^.+\\.css$': 'identity-obj-proxy','^(?!.*\\.(js|jsx|ts|tsx|css|json)$)': 'jest-transform-stub',...this.config.transform},transformIgnorePatterns: ['[/\\\\]node_modules[/\\\\].+\\.(js|jsx|ts|tsx)$','^.+\\.module\\.(css|sass|scss)$'],moduleFileExtensions: ['js', 'jsx', 'ts', 'tsx', 'json', 'node'],watchPlugins: ['jest-watch-typeahead/filename','jest-watch-typeahead/testname']};}// 设置Mock系统setupMockSystem() {this.mockUtils = {// 创建函数MockcreateFunctionMock: (implementation) => {const mock = jest.fn(implementation);this.mockRegistry.set(mock, {type: 'function',created: Date.now(),calls: []});return mock;},// 创建模块MockcreateModuleMock: (modulePath, mockImplementation) => {const mock = jest.mock(modulePath, () => mockImplementation);this.mockRegistry.set(modulePath, {type: 'module',implementation: mockImplementation,created: Date.now()});return mock;},// 创建对象MockcreateObjectMock: (object, methods = []) => {const mock = {};if (methods.length === 0) {methods = Object.getOwnPropertyNames(object.prototype || object).filter(name => typeof object[name] === 'function');}methods.forEach(method => {mock[method] = jest.fn();});this.mockRegistry.set(mock, {type: 'object',originalObject: object,mockedMethods: methods,created: Date.now()});return mock;},// 创建API MockcreateApiMock: (baseUrl, endpoints = {}) => {const mock = {baseUrl,endpoints: {},requests: []};Object.keys(endpoints).forEach(endpoint => {mock.endpoints[endpoint] = {response: endpoints[endpoint],calls: 0,lastCall: null};});// 模拟fetchglobal.fetch = jest.fn((url, options = {}) => {const endpoint = url.replace(baseUrl, '');const mockEndpoint = mock.endpoints[endpoint];mock.requests.push({url,options,timestamp: Date.now()});if (mockEndpoint) {mockEndpoint.calls++;mockEndpoint.lastCall = Date.now();return Promise.resolve({ok: true,status: 200,json: () => Promise.resolve(mockEndpoint.response),text: () => Promise.resolve(JSON.stringify(mockEndpoint.response))});}return Promise.reject(new Error(`No mock found for ${endpoint}`));});this.mockRegistry.set('api', mock);return mock;}};}// 设置测试工具setupTestUtilities() {this.testUtils = {// React组件测试工具renderComponent: (Component, props = {}, options = {}) => {const { render } = require('@testing-library/react');const { Provider } = require('react-redux');const { BrowserRouter } = require('react-router-dom');const AllTheProviders = ({ children }) => {let wrapped = children;// Redux Providerif (options.store) {wrapped = React.createElement(Provider, { store: options.store }, wrapped);}// Router Providerif (options.router !== false) {wrapped = React.createElement(BrowserRouter, {}, wrapped);}return wrapped;};return render(React.createElement(Component, props),{wrapper: AllTheProviders,...options});},// 异步测试工具waitForAsync: async (callback, timeout = 5000) => {const { waitFor } = require('@testing-library/react');return waitFor(callback, { timeout });},// 用户交互模拟userInteraction: {click: async (element) => {const { fireEvent } = require('@testing-library/react');fireEvent.click(element);await this.testUtils.waitForAsync(() => {});},type: async (element, text) => {const { fireEvent } = require('@testing-library/react');fireEvent.change(element, { target: { value: text } });await this.testUtils.waitForAsync(() => {});},submit: async (form) => {const { fireEvent } = require('@testing-library/react');fireEvent.submit(form);await this.testUtils.waitForAsync(() => {});}},// 快照测试createSnapshot: (component, props = {}) => {const renderer = require('react-test-renderer');const tree = renderer.create(React.createElement(component, props)).toJSON();expect(tree).toMatchSnapshot();return tree;},// 性能测试measurePerformance: async (callback, iterations = 100) => {const times = [];for (let i = 0; i < iterations; i++) {const start = performance.now();await callback();const end = performance.now();times.push(end - start);}return {average: times.reduce((a, b) => a + b, 0) / times.length,min: Math.min(...times),max: Math.max(...times),median: times.sort((a, b) => a - b)[Math.floor(times.length / 2)]};}};}// 运行测试async run(options = {}) {const startTime = Date.now();try {console.log('🧪 Running unit tests...');// 发现测试文件await this.discoverTestFiles();// 运行Jestconst jestResult = await this.runJest(options);// 处理结果const result = this.processJestResult(jestResult);const endTime = Date.now();result.duration = endTime - startTime;console.log(`✅ Unit tests completed in ${result.duration}ms`);return result;} catch (error) {console.error('❌ Unit test execution failed:', error);throw error;}}// 发现测试文件async discoverTestFiles() {const glob = require('glob');const path = require('path');this.testFiles = [];for (const pattern of this.config.testMatch) {const files = glob.sync(pattern, {cwd: process.cwd(),absolute: true});this.testFiles.push(...files);}console.log(`📁 Found ${this.testFiles.length} test files`);return this.testFiles;}// 运行Jestasync runJest(options = {}) {const jest = require('jest');const jestOptions = {...this.jestConfig,silent: options.silent || false,verbose: options.verbose || true,collectCoverage: options.coverage !== false,runInBand: !options.parallel,watchAll: options.watch || false};if (options.testNamePattern) {jestOptions.testNamePattern = options.testNamePattern;}if (options.testPathPattern) {jestOptions.testPathPattern = options.testPathPattern;}return new Promise((resolve, reject) => {jest.runCLI(jestOptions, [process.cwd()]).then(({ results }) => resolve(results)).catch(reject);});}// 处理Jest结果processJestResult(jestResult) {const tests = [];const errors = [];jestResult.testResults.forEach(testFile => {testFile.testResults.forEach(test => {tests.push({name: test.title,file: testFile.testFilePath,status: test.status,duration: test.duration,error: test.failureMessages.length > 0 ? test.failureMessages[0] : null});});if (testFile.failureMessage) {errors.push(testFile.failureMessage);}});return {success: jestResult.success,tests: tests,coverage: jestResult.coverageMap ? this.processCoverageData(jestResult.coverageMap) : null,errors: errors,numTotalTests: jestResult.numTotalTests,numPassedTests: jestResult.numPassedTests,numFailedTests: jestResult.numFailedTests,numPendingTests: jestResult.numPendingTests};}// 处理覆盖率数据processCoverageData(coverageMap) {const coverage = {};Object.keys(coverageMap).forEach(filePath => {const fileCoverage = coverageMap[filePath];coverage[filePath] = {statements: {total: fileCoverage.s ? Object.keys(fileCoverage.s).length : 0,covered: fileCoverage.s ? Object.values(fileCoverage.s).filter(Boolean).length : 0,percentage: 0},branches: {total: fileCoverage.b ? Object.keys(fileCoverage.b).length : 0,covered: fileCoverage.b ? Object.values(fileCoverage.b).filter(branch => branch.some(Boolean)).length : 0,percentage: 0},functions: {total: fileCoverage.f ? Object.keys(fileCoverage.f).length : 0,covered: fileCoverage.f ? Object.values(fileCoverage.f).filter(Boolean).length : 0,percentage: 0},lines: {total: fileCoverage.l ? Object.keys(fileCoverage.l).length : 0,covered: fileCoverage.l ? Object.values(fileCoverage.l).filter(Boolean).length : 0,percentage: 0}};// 计算百分比Object.keys(coverage[filePath]).forEach(metric => {const { total, covered } = coverage[filePath][metric];coverage[filePath][metric].percentage = total > 0 ? Math.round((covered / total) * 100) : 0;});});return coverage;}// 清理MockcleanupMocks() {// 清理Jest mocksjest.clearAllMocks();jest.resetAllMocks();jest.restoreAllMocks();// 清理自定义mocksthis.mockRegistry.clear();this.spyRegistry.clear();// 恢复全局对象if (global.fetch && global.fetch.mockRestore) {global.fetch.mockRestore();}}// 获取Mock统计getMockStats() {const stats = {totalMocks: this.mockRegistry.size,mockTypes: {},activeMocks: []};this.mockRegistry.forEach((mockInfo, mock) => {if (!stats.mockTypes[mockInfo.type]) {stats.mockTypes[mockInfo.type] = 0;}stats.mockTypes[mockInfo.type]++;stats.activeMocks.push({type: mockInfo.type,created: mockInfo.created,age: Date.now() - mockInfo.created});});return stats;}// 获取状态getStatus() {return {config: this.config,testFiles: this.testFiles.length,mockStats: this.getMockStats(),lastResults: this.testResults.slice(-5)};}// 清理cleanup() {this.cleanupMocks();this.testFiles = [];this.testResults = [];}
}