React 测试完全指南:单元测试、组件测试、E2E、Mock API、视觉回归与 CI/CD 集成

React 应用从单元测试到 E2E 的完整测试策略:Vitest + React Testing Library 组件测试、Jest 配置、MSW Mock API、Playwright 端到端测试、Chromatic 视觉回归、覆盖率标准与 CI/CD 集成。附测试金字塔模型与生产级实践规范。

测试是 React 应用质量的最后防线,也是重构的信心来源。本文构建了从单元测试到 E2E 的完整测试体系,涵盖工具选型、代码规范、CI/CD集成全流程。


一、测试金字塔:投入比例与策略

        ┌──────────┐
        │   E2E    │   ← 5% 投入,验证关键用户流程 (Playwright/Cypress)
        │ (~20个)  │
       ├──────────┤
       │   集成   │    ← 15% 投入,组件间交互 (RTL + Mock API)
       │ (~200个) │
      ├──────────┤
      │   单元   │     ← 80% 投入,纯函数、工具逻辑 (Vitest/Jest)
      │ (~2000个)│
     └──────────┘

关键原则

  1. 单元测试成本低、速度快、定位精准,应占绝大多数
  2. 集成测试验证组件组合行为,重点放在数据流和交互上
  3. E2E 测试覆盖真实用户场景(注册 → 登录 → 下单),不要测试细节
  4. 不要在每个测试中测试实现细节(如是否调用了某个函数),测试输出行为

测试口诀:“如果消失了你担心吗?"——删除这个测试后,还能不能有同样的信心?如果不会,这个测试就是多余的。


二、Vitest:2025 年首选单元测试框架

2.1 为什么选 Vitest 而非 Jest?

特性JestVitest
Vite 集成需要额外配置原生支持
TypeScript需要 ts-jest原生支持
执行速度更快(基于 esbuild)
Watch 模式更快(HMR 级别)
ESM 支持需配置原生支持
UI 模式需插件内置 vitest --ui

对于 Vite 项目(现代 React 项目的主流),Vitest 是零配置、性能最优的选择。

2.2 安装与配置

npm install -D vitest @testing-library/react @testing-library/jest-dom jsdom
// vitest.config.ts
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],
  test: {
    environment: 'jsdom',
    globals: true,
    setupFiles: './tests/setup.ts',
    coverage: {
      provider: 'v8',
      reporter: ['text', 'json', 'html'],
      thresholds: {
        functions: 80,
        lines: 80,
      },
    },
  },
});

// tests/setup.ts
import '@testing-library/jest-dom';

2.3 单元测试:纯函数与工具

import { describe, it, expect } from 'vitest';
import { formatPrice, calculateDiscount, parseQueryString } from './utils';

describe('formatPrice', () => {
  it('formats number with currency symbol', () => {
    expect(formatPrice(1999.99)).toBe('¥1,999.99');
  });

  it('handles zero', () => {
    expect(formatPrice(0)).toBe('¥0.00');
  });

  it('rounds to 2 decimals', () => {
    expect(formatPrice(99.999)).toBe('¥100.00');
  });
});

describe('calculateDiscount', () => {
  it('applies percentage discount', () => {
    expect(calculateDiscount(100, 20)).toBe(80);
  });

  it('returns full price for zero discount', () => {
    expect(calculateDiscount(100, 0)).toBe(100);
  });

  it('caps discount at 100%', () => {
    expect(calculateDiscount(100, 150)).toBe(0);
  });
});

三、React Testing Library:组件测试核心

3.1 测试哲学

React Testing Library(RTL)的核心思想是测试组件像用户一样使用它。不测试内部实现,只测试可见的输出和行为。

RTL 的经典三原则

  1. 如果某件事让用户担心,测试它
  2. 如果某代码对用户体验有影响,测试它
  3. 不测试实现细节(不要测试 state、props、内部方法)

3.2 查询优先级

// 1. 最优先:查询语义化元素(无障碍)
screen.getByRole('button', { name: /submit/i });
screen.getByRole('textbox', { name: 'Email' });

// 2. 标签关联
screen.getByLabelText('Password');

// 3. placeholder
screen.getByPlaceholderText('Enter email');

// 4. 文本内容
screen.getByText('Welcome back');

// 5. 显示值(表单)
screen.getByDisplayValue('John Doe');

// 6. alt 文本(图片)
screen.getByAltText('Product thumbnail');

// 7. title
screen.getByTitle('Close menu');

// 8. 最后手段:test id
screen.getByTestId('user-card');

为什么 getByRole 最优先? 因为它反映的是无障碍访问(Accessibility),既是测试又是无障碍检查。如果元素没有正确的 role,屏幕阅读器也无法识别。

3.3 事件与交互测试

import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { PasswordInput } from './PasswordInput';

it('toggles password visibility', async () => {
  const user = userEvent.setup();
  render(<PasswordInput />);

  // 初始为密码类型(不可见)
  const input = screen.getByLabelText('Password');
  expect(input).toHaveAttribute('type', 'password');

  // 点击显示按钮
  const toggleBtn = screen.getByRole('button', { name: /show password/i });
  await user.click(toggleBtn);

  // 验证类型变为 text
  expect(input).toHaveAttribute('type', 'text');

  // 再次点击恢复
  await user.click(toggleBtn);
  expect(input).toHaveAttribute('type', 'password');
});

userEvent vs fireEvent

  • userEvent 模拟真实用户交互序列(click 包含 hover → mousedown → focus → mouseup → click)
  • fireEvent 直接触发单个事件,只用于 userEvent 不支持的特殊场景
  • 默认使用 userEvent

3.4 异步组件测试

import { render, screen, waitFor, within } from '@testing-library/react';

it('loads and displays user list', async () => {
  render(<UserList />);

  // 初始 loading 状态
  expect(screen.getByText(/loading/i)).toBeInTheDocument();

  // 等待数据加载
  const users = await screen.findByRole('list');
  expect(users).toBeInTheDocument();

  // 确认列表项数量
  const items = within(users).getAllByRole('listitem');
  expect(items).toHaveLength(3);

  // 确认特定用户存在
  expect(screen.getByText('Alice')).toBeInTheDocument();
});

it('handles error state', async () => {
  // Mock API 失败
  vi.spyOn(global, 'fetch').mockRejectedValue(new Error('Network error'));

  render(<UserList />);

  await waitFor(() => {
    expect(screen.getByText(/failed to load/i)).toBeInTheDocument();
  });

  // 恢复 mock
  vi.restoreAllMocks();
});

async 方法对照表

方法用法等待条件
findByawait screen.findByText('Loaded')元素出现(自带 1s 超时)
waitForawait waitFor(() => expect(...))自定义断言条件
waitForElementToBeRemovedawait waitForElementToBeRemoved(() => screen.getByText('Loading'))元素消失

3.5 Mock API:MSW(Mock Service Worker)

MSW 使用 Service Worker 拦截浏览器网络请求,统一 Mock API,既用于测试也用于开发。

// tests/mocks/handlers.ts
import { http, HttpResponse } from 'msw';

export const handlers = [
  http.get('/api/users', () => {
    return HttpResponse.json([
      { id: '1', name: 'Alice', email: 'alice@example.com' },
      { id: '2', name: 'Bob', email: 'bob@example.com' },
    ]);
  }),

  http.get('/api/users/:id', ({ params }) => {
    return HttpResponse.json({
      id: params.id,
      name: `User ${params.id}`,
      email: `user${params.id}@example.com`,
    });
  }),

  http.post('/api/login', async ({ request }) => {
    const body = await request.json();
    if (body.email === 'test@test.com' && body.password === '123456') {
      return HttpResponse.json({ token: 'fake-jwt-token', user: { id: '1', name: 'Test' } });
    }
    return new HttpResponse(null, { status: 401 });
  }),
];

// tests/setup.ts
import { setupServer } from 'msw/node';
import { handlers } from './mocks/handlers';

const server = setupServer(...handlers);

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

MSW 相比直接 Mock fetch 的优势

  • 统一 API 层拦截,代码完全不感知是 mock 还是真实请求
  • 开发环境也可使用(启动 dev server 时使用相同 handlers)
  • 支持 REST 和 GraphQL
  • 可覆盖网络错误、超时、慢速等边缘场景

四、E2E 测试:Playwright 完整实践

4.1 Playwright vs Cypress:选型对比

特性CypressPlaywright
浏览器支持Chromium/Firefox/WebKit(需插件)Chromium/Firefox/WebKit 原生
并发执行需商业版原生支持(多 worker)
API 测试强(内置 request)
执行速度更快(隔离执行)
移动端模拟需插件原生 device 枚举
调试优秀(Time travel)优秀(Trace viewer)
生态插件丰富GitHub 官方维护,增长快

2025 年推荐 Playwright:原生多浏览器、并发快、API 现代化、微软/Google 支持。

4.2 Playwright 配置与测试

// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  testDir: './e2e',
  fullyParallel: true,
  workers: process.env.CI ? 1 : undefined,
  reporter: [['html'], ['junit', { outputFile: 'test-results/junit.xml' }]],
  use: {
    baseURL: 'http://localhost:3000',
    trace: 'on-first-retry',
    screenshot: 'only-on-failure',
  },
  projects: [
    { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
    { name: 'firefox', use: { ...devices['Desktop Firefox'] } },
    { name: 'webkit', use: { ...devices['Desktop Safari'] } },
    { name: 'Mobile Chrome', use: { ...devices['Pixel 5'] } },
    { name: 'Mobile Safari', use: { ...devices['iPhone 12'] } },
  ],
  webServer: {
    command: 'npm run dev',
    url: 'http://localhost:3000',
    reuseExistingServer: !process.env.CI,
  },
});

4.3 关键用户流程 E2E

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

test.describe('Checkout Flow', () => {
  test.beforeEach(async ({ page }) => {
    // 登录
    await page.goto('/login');
    await page.fill('[name="email"]', 'test@example.com');
    await page.fill('[name="password"]', 'password123');
    await page.click('button[type="submit"]');
    await expect(page).toHaveURL('/dashboard');
  });

  test('complete purchase flow', async ({ page }) => {
    // 浏览商品
    await page.goto('/products');
    await page.click('text=Premium Widget');
    await expect(page.locator('h1')).toContainText('Premium Widget');

    // 加入购物车
    await page.click('button:has-text("Add to Cart")');
    await expect(page.locator('[data-testid="cart-count"]')).toHaveText('1');

    // 进入结账
    await page.click('text=Checkout');
    await page.fill('[name="address"]', '123 Test St');
    await page.fill('[name="city"]', 'Test City');
    await page.selectOption('[name="country"]', 'US');

    // 提交订单
    await page.click('button:has-text("Place Order")');

    // 验证成功页
    await expect(page).toHaveURL(/\/order\//);
    await expect(page.locator('h1')).toContainText('Order Confirmed');
  });

  test('handles payment failure', async ({ page }) => {
    // ... 模拟支付失败场景
  });
});

五、视觉回归测试:Chromatic

视觉回归测试检测 UI 的意外变化,特别适合 Design System 和有严格视觉规范的团队。

5.1 Chromatic 快速集成

npm install -D chromatic
npx chromatic --project-token=<your-token>
// .storybook/main.ts
export default {
  stories: ['../src/**/*.stories.@(js|jsx|ts|tsx)'],
  addons: ['@storybook/addon-essentials', '@chromatic-com/storybook'],
};

Chromatic 核心能力

  • 自动截图对比:每次 Story 在不同浏览器中截图对比
  • 交互测试:在 Storybook 中测试交互状态
  • UI Review:团队成员审核 UI 变更
  • 分支比较:PR 中可视化对比修改前后的 UI

六、测试策略矩阵

场景测试类型工具关键断言
工具函数(纯函数)单元测试Vitest输入输出的精确匹配
React 组件渲染组件测试RTL + Vitest元素存在性、文本内容
用户交互(点击、输入)组件测试RTL + userEvent状态变化后 UI 更新
API 调用集成测试MSW + RTLMock 响应后的 UI 渲染
组件间数据流集成测试RTL + Provider Mock子组件接收到正确 props
路由切换E2EPlaywrightURL 变化、页面内容
登录/注册/购买E2EPlaywright完整流程的端到端验证
响应式布局视觉回归Chromatic + Storybook不同视口下的像素对比
主题切换组件/视觉RTL + Chromatic暗黑/明亮模式截图对比

七、CI/CD 集成

# .github/workflows/test.yml
name: Test
on: [push, pull_request]

jobs:
  unit:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'
      - run: npm ci
      - run: npm run test:unit -- --coverage
      - uses: actions/upload-artifact@v4
        with:
          name: coverage
          path: coverage/

  e2e:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'
      - run: npm ci
      - run: npx playwright install --with-deps
      - run: npm run test:e2e
      - uses: actions/upload-artifact@v4
        if: failure()
        with:
          name: playwright-report
          path: playwright-report/

  visual:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'
      - run: npm ci
      - run: npx chromatic --project-token=${{ secrets.CHROMATIC_TOKEN }}

八、测试覆盖率目标与策略

模块目标覆盖率说明
工具函数(utils/)90%+纯函数,容易全覆盖
Hooks80%+测试状态变化逻辑
组件70-80%重点测试条件渲染和用户交互
页面(Pages)50-60%E2E 覆盖主要流程
集成 API60%+MSW 模拟关键接口

不要追求的指标

  • 100% 覆盖率不等于无 bug
  • 不要为了覆盖率而测试 trivial 代码(如纯 styled-components)
  • 重点关注分支覆盖(branch coverage)而非行覆盖(line coverage)

常见问题(FAQ)

什么时候开始写测试?

  • 新功能:TDD 或功能完成后立即补测试
  • Bug 修复:先写一个会失败的测试复现 bug,再修复,确保不再回归
  • 重构:重构前必须有足够测试覆盖
  • 遗留代码:核心路径优先覆盖,逐步补充

测试文件放在哪里?

策略文件位置适用
同目录Button.test.tsxButton.tsx 同级小型项目,文件数少
tests 目录tests/components/Button.test.tsx中大型项目,统一管理
Vite 约定__tests__ 目录有特殊配置需求

推荐:同目录方式(Button.tsx + Button.test.tsx + Button.stories.tsx),方便切换。

React Testing Library 和 Enzyme 怎么选?

选 RTL。Enzyme 已停止维护(不兼容 React 18+),RTL 是 React 官方推荐的测试方式。

E2E 测试太慢,怎么优化?

  1. 并行执行:Playwright 的 workers 配置
  2. 只测试关键路径:20-30 个核心场景,而非每个细节
  3. 跳过登录:使用 storageState 预存认证 cookie
  4. API 级别的 E2E:用 Playwright 的 request 做 API 测试,比 UI 快 10 倍
  5. Smart sharding:CI 中按 spec 文件分片到不同 runner

相关阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「frontend」更多文章