每天一个高级前端知识 - Day 14

2 阅读6分钟

每天一个高级前端知识 - Day 14

今日主题:前端测试策略 - 单元测试、组件测试、E2E测试的完整实践

核心概念:测试金字塔与分层策略

测试不是越多越好,而是需要合理的分层和投资回报率。

        /\
       /  \      E2E测试 (少量,慢,贵)
      /____\     🎯 关键用户流程
     /      \    
    / 集成测试 \   (中等,中等)
   /__________\  🎯 组件交互、API集成
  /            \
 /   单元测试    \ (大量,快,便宜)
/______________\  🎯 函数、算法、边界条件

🎯 单元测试(Vitest/Jest)

// utils/math.ts
export function add(a: number, b: number): number {
  if (typeof a !== 'number' || typeof b !== 'number') {
    throw new Error('参数必须是数字');
  }
  return a + b;
}

export function factorial(n: number): number {
  if (n < 0) throw new Error('负数没有阶乘');
  if (n === 0 || n === 1) return 1;
  return n * factorial(n - 1);
}

export function debounce<T extends (...args: any[]) => any>(
  fn: T,
  delay: number
): T & { cancel: () => void } {
  let timer: ReturnType<typeof setTimeout> | null = null;
  
  const debounced = ((...args: Parameters<T>) => {
    if (timer) clearTimeout(timer);
    timer = setTimeout(() => fn(...args), delay);
  }) as T & { cancel: () => void };
  
  debounced.cancel = () => {
    if (timer) clearTimeout(timer);
    timer = null;
  };
  
  return debounced;
}
// utils/math.test.ts
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { add, factorial, debounce } from './math';

describe('数学工具函数', () => {
  describe('add', () => {
    it('应该正确计算两个正数之和', () => {
      expect(add(1, 2)).toBe(3);
      expect(add(100, 200)).toBe(300);
    });
    
    it('应该正确处理负数', () => {
      expect(add(-1, -2)).toBe(-3);
      expect(add(-5, 3)).toBe(-2);
    });
    
    it('应该正确处理浮点数', () => {
      expect(add(0.1, 0.2)).toBeCloseTo(0.3);
    });
    
    it('应该在参数无效时抛出错误', () => {
      expect(() => add('a' as any, 2)).toThrow('参数必须是数字');
      expect(() => add(1, null as any)).toThrow('参数必须是数字');
    });
  });
  
  describe('factorial', () => {
    it('应该正确计算阶乘', () => {
      expect(factorial(0)).toBe(1);
      expect(factorial(1)).toBe(1);
      expect(factorial(5)).toBe(120);
      expect(factorial(10)).toBe(3628800);
    });
    
    it('应该在输入负数时抛出错误', () => {
      expect(() => factorial(-1)).toThrow('负数没有阶乘');
    });
    
    // 性能测试
    it('应该在合理时间内完成大数计算', () => {
      const start = performance.now();
      factorial(100);
      const end = performance.now();
      expect(end - start).toBeLessThan(100);
    });
  });
  
  describe('debounce', () => {
    beforeEach(() => {
      vi.useFakeTimers();
    });
    
    afterEach(() => {
      vi.restoreAllMocks();
    });
    
    it('应该在延迟时间内只执行一次', () => {
      const fn = vi.fn();
      const debounced = debounce(fn, 100);
      
      debounced();
      debounced();
      debounced();
      
      expect(fn).not.toHaveBeenCalled();
      
      vi.advanceTimersByTime(100);
      expect(fn).toHaveBeenCalledTimes(1);
    });
    
    it('应该传递正确的参数', () => {
      const fn = vi.fn();
      const debounced = debounce(fn, 100);
      
      debounced('hello', 123);
      vi.advanceTimersByTime(100);
      
      expect(fn).toHaveBeenCalledWith('hello', 123);
    });
    
    it('应该支持取消操作', () => {
      const fn = vi.fn();
      const debounced = debounce(fn, 100);
      
      debounced();
      debounced.cancel();
      vi.advanceTimersByTime(100);
      
      expect(fn).not.toHaveBeenCalled();
    });
  });
});

⚛️ 组件测试(React Testing Library)

// components/LoginForm.tsx
import React, { useState } from 'react';

interface LoginFormProps {
  onSubmit: (email: string, password: string) => Promise<void>;
  isLoading?: boolean;
}

export const LoginForm: React.FC<LoginFormProps> = ({ onSubmit, isLoading = false }) => {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [errors, setErrors] = useState<{ email?: string; password?: string }>({});
  
  const validate = (): boolean => {
    const newErrors: typeof errors = {};
    
    if (!email) {
      newErrors.email = '邮箱不能为空';
    } else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
      newErrors.email = '邮箱格式不正确';
    }
    
    if (!password) {
      newErrors.password = '密码不能为空';
    } else if (password.length < 6) {
      newErrors.password = '密码至少6位';
    }
    
    setErrors(newErrors);
    return Object.keys(newErrors).length === 0;
  };
  
  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    if (!validate()) return;
    
    try {
      await onSubmit(email, password);
    } catch (error) {
      setErrors({ email: (error as Error).message });
    }
  };
  
  return (
    <form onSubmit={handleSubmit} data-testid="login-form">
      <div>
        <label htmlFor="email">邮箱</label>
        <input
          id="email"
          type="email"
          value={email}
          onChange={(e) => setEmail(e.target.value)}
          aria-invalid={!!errors.email}
          disabled={isLoading}
        />
        {errors.email && <span role="alert">{errors.email}</span>}
      </div>
      
      <div>
        <label htmlFor="password">密码</label>
        <input
          id="password"
          type="password"
          value={password}
          onChange={(e) => setPassword(e.target.value)}
          aria-invalid={!!errors.password}
          disabled={isLoading}
        />
        {errors.password && <span role="alert">{errors.password}</span>}
      </div>
      
      <button type="submit" disabled={isLoading}>
        {isLoading ? '登录中...' : '登录'}
      </button>
    </form>
  );
};
// components/LoginForm.test.tsx
import React from 'react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, it, expect, vi } from 'vitest';
import { LoginForm } from './LoginForm';

describe('LoginForm', () => {
  // 基础渲染测试
  it('应该正确渲染表单', () => {
    render(<LoginForm onSubmit={vi.fn()} />);
    
    expect(screen.getByLabelText(/邮箱/i)).toBeInTheDocument();
    expect(screen.getByLabelText(/密码/i)).toBeInTheDocument();
    expect(screen.getByRole('button', { name: /登录/i })).toBeInTheDocument();
  });
  
  // 用户交互测试
  it('应该在提交前进行验证', async () => {
    const onSubmit = vi.fn();
    render(<LoginForm onSubmit={onSubmit} />);
    
    // 提交空表单
    fireEvent.click(screen.getByRole('button', { name: /登录/i }));
    
    expect(await screen.findByText(/邮箱不能为空/i)).toBeInTheDocument();
    expect(await screen.findByText(/密码不能为空/i)).toBeInTheDocument();
    expect(onSubmit).not.toHaveBeenCalled();
  });
  
  // 异步操作测试
  it('应该在提交时显示加载状态', async () => {
    const onSubmit = vi.fn().mockImplementation(() => new Promise(resolve => setTimeout(resolve, 100)));
    render(<LoginForm onSubmit={onSubmit} />);
    
    await userEvent.type(screen.getByLabelText(/邮箱/i), 'test@example.com');
    await userEvent.type(screen.getByLabelText(/密码/i), 'password123');
    
    fireEvent.click(screen.getByRole('button', { name: /登录/i }));
    
    // 检查加载状态
    expect(screen.getByRole('button', { name: /登录中\.\.\./i })).toBeDisabled();
    
    await waitFor(() => {
      expect(screen.getByRole('button', { name: /登录/i })).toBeEnabled();
    });
  });
  
  // 可访问性测试
  it('应该在无效输入时标记错误状态', async () => {
    render(<LoginForm onSubmit={vi.fn()} />);
    
    const emailInput = screen.getByLabelText(/邮箱/i);
    fireEvent.blur(emailInput);
    
    expect(emailInput).toHaveAttribute('aria-invalid', 'true');
  });
  
  // 快照测试
  it('应该匹配快照', () => {
    const { container } = render(<LoginForm onSubmit={vi.fn()} />);
    expect(container).toMatchSnapshot();
  });
});

🔗 集成测试

// integration/auth.integration.test.tsx
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { setupServer } from 'msw/node';
import { http, HttpResponse } from 'msw';
import { Provider } from 'react-redux';
import { configureStore } from '@reduxjs/toolkit';
import { LoginPage } from '../pages/LoginPage';
import authReducer from '../store/authSlice';

// Mock API服务器
const server = setupServer(
  http.post('/api/login', async ({ request }) => {
    const body = await request.json();
    
    if (body.email === 'admin@example.com' && body.password === 'correct') {
      return HttpResponse.json({
        token: 'fake-jwt-token',
        user: { id: 1, email: body.email, name: 'Admin' }
      });
    }
    
    return HttpResponse.json(
      { message: '用户名或密码错误' },
      { status: 401 }
    );
  })
);

beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

describe('登录流程集成测试', () => {
  it('应该成功登录并跳转', async () => {
    const store = configureStore({
      reducer: { auth: authReducer }
    });
    
    const mockNavigate = vi.fn();
    render(
      <Provider store={store}>
        <LoginPage navigate={mockNavigate} />
      </Provider>
    );
    
    await userEvent.type(screen.getByLabelText(/邮箱/i), 'admin@example.com');
    await userEvent.type(screen.getByLabelText(/密码/i), 'correct');
    fireEvent.click(screen.getByRole('button', { name: /登录/i }));
    
    await waitFor(() => {
      expect(mockNavigate).toHaveBeenCalledWith('/dashboard');
      expect(localStorage.getItem('token')).toBe('fake-jwt-token');
    });
  });
  
  it('应该显示登录失败错误', async () => {
    const store = configureStore({
      reducer: { auth: authReducer }
    });
    
    render(
      <Provider store={store}>
        <LoginPage />
      </Provider>
    );
    
    await userEvent.type(screen.getByLabelText(/邮箱/i), 'wrong@example.com');
    await userEvent.type(screen.getByLabelText(/密码/i), 'wrong');
    fireEvent.click(screen.getByRole('button', { name: /登录/i }));
    
    expect(await screen.findByText(/用户名或密码错误/i)).toBeInTheDocument();
  });
});

🌐 E2E测试(Playwright)

// e2e/login.spec.ts
import { test, expect } from '@playwright/test';

test.describe('登录流程', () => {
  test.beforeEach(async ({ page }) => {
    await page.goto('http://localhost:3000/login');
  });
  
  test('应该成功登录', async ({ page }) => {
    // 填写表单
    await page.fill('[data-testid="email-input"]', 'user@example.com');
    await page.fill('[data-testid="password-input"]', 'password123');
    
    // 提交
    await page.click('[data-testid="login-button"]');
    
    // 等待跳转
    await page.waitForURL('http://localhost:3000/dashboard');
    
    // 验证登录成功
    await expect(page.locator('[data-testid="welcome-message"]')).toContainText('欢迎回来');
  });
  
  test('应该显示验证错误', async ({ page }) => {
    await page.click('[data-testid="login-button"]');
    
    // 验证错误消息
    await expect(page.locator('[data-testid="email-error"]')).toBeVisible();
    await expect(page.locator('[data-testid="password-error"]')).toBeVisible();
  });
  
  test('应该处理网络错误', async ({ page, context }) => {
    // 模拟离线状态
    await context.setOffline(true);
    
    await page.fill('[data-testid="email-input"]', 'user@example.com');
    await page.fill('[data-testid="password-input"]', 'password123');
    await page.click('[data-testid="login-button"]');
    
    await expect(page.locator('[data-testid="network-error"]')).toContainText('网络连接失败');
  });
});

test.describe('购物车流程', () => {
  test('应该添加商品到购物车', async ({ page }) => {
    await page.goto('http://localhost:3000/products');
    
    // 截图对比
    await expect(page).toHaveScreenshot('products-page.png');
    
    // 添加商品
    await page.click('[data-testid="add-to-cart-1"]');
    
    // 检查购物车数量
    await expect(page.locator('[data-testid="cart-count"]')).toHaveText('1');
    
    // 进入购物车
    await page.click('[data-testid="cart-icon"]');
    
    // 验证商品存在
    await expect(page.locator('[data-testid="cart-item-1"]')).toBeVisible();
  });
});

🛠️ 测试工具配置

// vitest.config.ts
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],
  test: {
    globals: true,
    environment: 'jsdom',
    setupFiles: ['./src/test/setup.ts'],
    coverage: {
      provider: 'v8',
      reporter: ['text', 'json', 'html'],
      exclude: [
        'node_modules/',
        'src/test/',
        '**/*.d.ts',
        '**/*.config.ts'
      ],
      thresholds: {
        lines: 80,
        functions: 80,
        branches: 75,
        statements: 80
      }
    },
    reporters: ['default', 'html'],
    testTimeout: 10000,
    hookTimeout: 10000
  }
});
// src/test/setup.ts
import '@testing-library/jest-dom/vitest';
import { cleanup } from '@testing-library/react';
import { afterEach } from 'vitest';

// 每个测试后清理
afterEach(() => {
  cleanup();
});

// Mock window.matchMedia
Object.defineProperty(window, 'matchMedia', {
  writable: true,
  value: vi.fn().mockImplementation(query => ({
    matches: false,
    media: query,
    onchange: null,
    addListener: vi.fn(),
    removeListener: vi.fn(),
    addEventListener: vi.fn(),
    removeEventListener: vi.fn(),
    dispatchEvent: vi.fn(),
  })),
});

// Mock IntersectionObserver
global.IntersectionObserver = class IntersectionObserver {
  constructor() {}
  observe() { return null; }
  unobserve() { return null; }
  disconnect() { return null; }
} as any;

🎯 测试策略最佳实践

// 1. AAA模式(Arrange-Act-Assert)
test('应该计算总价', () => {
  // Arrange
  const cart = [{ price: 10, quantity: 2 }, { price: 5, quantity: 1 }];
  
  // Act
  const total = calculateTotal(cart);
  
  // Assert
  expect(total).toBe(25);
});

// 2. 测试描述应该清晰
test('getUserById: 当用户存在时返回用户对象', () => {});
test('getUserById: 当用户不存在时返回null', () => {});

// 3. 避免测试实现细节
// ❌ Bad
test('应该调用setState', () => {
  expect(setState).toHaveBeenCalled();
});

// ✅ Good
test('点击按钮后显示成功消息', () => {
  // 测试用户可见的行为
});

// 4. 使用工厂函数减少重复
const createUser = (overrides = {}) => ({
  id: 1,
  name: 'Test User',
  email: 'test@example.com',
  ...overrides
});

test('应该更新用户邮箱', () => {
  const user = createUser();
  const updated = updateEmail(user, 'new@example.com');
  expect(updated.email).toBe('new@example.com');
});

📊 测试覆盖率目标

测试类型覆盖率目标执行时间维护成本
单元测试80%+秒级
组件测试70%+分钟级
集成测试60%+分钟级
E2E测试核心流程小时级

明日预告:前端架构设计 - 微前端、组件库、Monorepo的最佳实践

💡 测试哲学:测试不是为了100%覆盖率,而是为了信心——重构的勇气、上线的底气!