发布于

前端测试策略全解析:单元测试、集成测试与E2E测试实战

作者

前端测试策略全解析:单元测试、集成测试与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');
    });
  });
});

总结

前端测试策略的核心要点:

  1. 测试金字塔:70%单元测试、20%集成测试、10%E2E测试
  2. 单元测试:快速、可靠、易维护的函数和组件测试
  3. 集成测试:验证模块间协作和API集成
  4. E2E测试:覆盖关键用户流程和业务场景
  5. 最佳实践:测试驱动开发、持续集成、覆盖率监控

完善的测试策略能够显著提高代码质量,减少生产环境bug,提升开发团队的信心和效率。