React 测试策略与最佳实践

中等 🟡React 生态
4 个标签
预计阅读时间:66 分钟
React测试JestReact Testing Library

React 测试策略与最佳实践

测试是保证 React 应用质量的重要手段,完善的测试体系可以及早发现 bug、提高代码可维护性、增强重构信心。React 生态提供了丰富的测试工具,从单元测试到端到端测试,开发者可以根据需求构建多层次的测试策略。

测试类型详解

单元测试:

单元测试是最基础的测试类型,用于测试单个组件或函数的行为。单元测试应该隔离测试,不依赖外部系统,执行快速,反馈及时。在 React 中,单元测试通常测试组件的渲染输出、状态变化、事件处理等。单元测试的覆盖率是衡量代码质量的重要指标,但不应过度追求 100% 覆盖率,而应关注关键逻辑和边界条件。

集成测试:

集成测试测试多个组件或模块之间的交互,模拟真实使用场景。集成测试关注组件之间的数据流、状态共享、API 调用等。集成测试比单元测试更接近真实用户行为,但执行速度较慢。在 React 应用中,集成测试通常测试表单提交流程、数据获取和渲染、路由导航等场景。

端到端测试 (E2E):

端到端测试模拟用户在真实浏览器中的操作,测试整个应用的流程。E2E 测试关注用户视角,验证用户体验和业务流程。E2E 测试执行最慢,但最能反映真实使用情况。常用的 E2E 测试工具包括 Playwright 和 Cypress,它们可以在真实浏览器中模拟用户操作。

代码示例

javascriptCode
// Jest 配置示例
// jest.config.js
module.exports = {
  testEnvironment: 'jsdom',
  setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
  moduleNameMapper: {
    '^@/(.*)$': '<rootDir>/src/$1',
    '\.(css|less|scss|sass)$': 'identity-obj-proxy'
  },
  transform: {
    '^.+\.(js|jsx|ts|tsx)$': ['babel-jest', { presets: ['next/babel'] }]
  },
  collectCoverageFrom: [
    'src/**/*.{js,jsx,ts,tsx}',
    '!src/**/*.d.ts',
    '!src/**/*.stories.{js,jsx,ts,tsx}'
  ]
};

// 组件渲染测试
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import Counter from './Counter';

describe('Counter Component', () => {
  test('renders initial count', () => {
    render(<Counter initialCount={0} />);
    expect(screen.getByText('Count: 0')).toBeInTheDocument();
  });
  
  test('increments count when button clicked', async () => {
    const user = userEvent.setup();
    render(<Counter initialCount={0} />);
    
    await user.click(screen.getByRole('button', { name: /increment/i }));
    
    expect(screen.getByText('Count: 1')).toBeInTheDocument();
  });
  
  test('decrements count when button clicked', async () => {
    const user = userEvent.setup();
    render(<Counter initialCount={5} />);
    
    await user.click(screen.getByRole('button', { name: /decrement/i }));
    
    expect(screen.getByText('Count: 4')).toBeInTheDocument();
  });
});

// 表单组件测试
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import LoginForm from './LoginForm';

describe('LoginForm', () => {
  const mockOnSubmit = jest.fn();
  
  beforeEach(() => {
    mockOnSubmit.mockClear();
  });
  
  test('submits form with correct values', async () => {
    const user = userEvent.setup();
    render(<LoginForm onSubmit={mockOnSubmit} />);
    
    await user.type(screen.getByLabelText(/email/i), 'test@example.com');
    await user.type(screen.getByLabelText(/password/i), 'password123');
    await user.click(screen.getByRole('button', { name: /submit/i }));
    
    await waitFor(() => {
      expect(mockOnSubmit).toHaveBeenCalledWith({
        email: 'test@example.com',
        password: 'password123'
      });
    });
  });
  
  test('shows validation error for invalid email', async () => {
    const user = userEvent.setup();
    render(<LoginForm onSubmit={mockOnSubmit} />);
    
    await user.type(screen.getByLabelText(/email/i), 'invalid-email');
    await user.click(screen.getByRole('button', { name: /submit/i }));
    
    expect(await screen.findByText(/invalid email/i)).toBeInTheDocument();
    expect(mockOnSubmit).not.toHaveBeenCalled();
  });
});

// 异步组件测试
import { render, screen, waitFor } from '@testing-library/react';
import UserProfile from './UserProfile';

// Mock fetch
global.fetch = jest.fn();

describe('UserProfile', () => {
  beforeEach(() => {
    fetch.mockClear();
  });
  
  test('shows loading state initially', () => {
    fetch.mockImplementation(() => new Promise(() => {}));
    render(<UserProfile userId="1" />);
    expect(screen.getByText(/loading/i)).toBeInTheDocument();
  });
  
  test('renders user data after fetch', async () => {
    fetch.mockResolvedValueOnce({
      ok: true,
      json: async () => ({ id: '1', name: 'John Doe', email: 'john@example.com' })
    });
    
    render(<UserProfile userId="1" />);
    
    await waitFor(() => {
      expect(screen.getByText('John Doe')).toBeInTheDocument();
      expect(screen.getByText('john@example.com')).toBeInTheDocument();
    });
  });
  
  test('shows error message on fetch failure', async () => {
    fetch.mockRejectedValueOnce(new Error('Network error'));
    
    render(<UserProfile userId="1" />);
    
    expect(await screen.findByText(/error/i)).toBeInTheDocument();
  });
});

// 自定义 Hook 测试
import { renderHook, act } from '@testing-library/react';
import { useCounter } from './useCounter';

describe('useCounter Hook', () => {
  test('initializes with default value', () => {
    const { result } = renderHook(() => useCounter());
    expect(result.current.count).toBe(0);
  });
  
  test('initializes with custom value', () => {
    const { result } = renderHook(() => useCounter(10));
    expect(result.current.count).toBe(10);
  });
  
  test('increments count', () => {
    const { result } = renderHook(() => useCounter());
    
    act(() => {
      result.current.increment();
    });
    
    expect(result.current.count).toBe(1);
  });
  
  test('decrements count', () => {
    const { result } = renderHook(() => useCounter(5));
    
    act(() => {
      result.current.decrement();
    });
    
    expect(result.current.count).toBe(4);
  });
});

// MSW API Mock 示例
import { rest } from 'msw';
import { setupServer } from 'msw/node';
import { render, screen, waitFor } from '@testing-library/react';
import UserList from './UserList';

const server = setupServer(
  rest.get('/api/users', (req, res, ctx) => {
    return res(
      ctx.json([
        { id: 1, name: 'John Doe' },
        { id: 2, name: 'Jane Smith' }
      ])
    );
  })
);

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

describe('UserList with MSW', () => {
  test('renders users from API', async () => {
    render(<UserList />);
    
    await waitFor(() => {
      expect(screen.getByText('John Doe')).toBeInTheDocument();
      expect(screen.getByText('Jane Smith')).toBeInTheDocument();
    });
  });
  
  test('handles API error', async () => {
    server.use(
      rest.get('/api/users', (req, res, ctx) => {
        return res(ctx.status(500));
      })
    );
    
    render(<UserList />);
    
    expect(await screen.findByText(/error/i)).toBeInTheDocument();
  });
});

// 快照测试
import { render } from '@testing-library/react';
import Card from './Card';

describe('Card Snapshot', () => {
  test('matches snapshot', () => {
    const { asFragment } = render(
      <Card title="Test Card" description="This is a test card" />
    );
    expect(asFragment()).toMatchSnapshot();
  });
  
  test('matches snapshot with custom className', () => {
    const { asFragment } = render(
      <Card 
        title="Test Card" 
        description="This is a test card" 
        className="custom-class"
      />
    );
    expect(asFragment()).toMatchSnapshot();
  });
});

测试工具详解

Jest - 测试框架核心:

Jest 是 Facebook 开发的 JavaScript 测试框架,提供了完整的测试解决方案。Jest 内置断言库、Mock 功能、快照测试、代码覆盖率报告等功能。Jest 的零配置理念使得开箱即用,同时支持灵活的自定义配置。Jest 的并行测试执行和智能观察模式可以显著提高测试效率。

React Testing Library - 组件测试最佳实践:

React Testing Library 是测试 React 组件的推荐工具,它鼓励测试组件行为而非实现细节。通过 queryBy、getBy、findBy* 等方法查询 DOM 元素,通过 userEvent 模拟用户交互。Testing Library 的核心理念是"测试应该像用户使用应用一样",这使得测试更加健壮,不会因为重构而频繁修改。

MSW - API Mock 利器:

Mock Service Worker (MSW) 是现代的 API Mock 工具,通过 Service Worker 拦截网络请求。MSW 可以在浏览器和 Node.js 环境中使用,支持 REST 和 GraphQL API。MSW 的优势在于不需要修改应用代码,Mock 的 API 行为与真实 API 一致,测试更加真实可靠。

最佳实践

测试原则:

测试用户可见的行为,而不是实现细节。使用语义化的查询方法(如 getByRole、getByText)而不是测试 ID。保持测试独立,每个测试应该能够单独运行。测试边界条件和错误情况,而不仅仅是正常流程。

测试组织:

使用 describe 嵌套组织相关测试用例。使用 beforeEach 和 afterEach 处理公共的设置和清理。将测试文件放在与被测试文件相同的目录下,命名为 .test.js 或 .spec.js。

持续集成:

在 CI 流程中运行所有测试,确保代码质量。配置测试覆盖率阈值,低于阈值时构建失败。使用并行测试加速 CI 执行时间。

测试金字塔与投资回报

为什么需要分层测试?

测试金字塔(Test Pyramid)由 Mike Cohn 提出,它描述了不同层级测试的合理配比。底层是数量最多、执行最快、成本最低的单元测试;中间是集成测试;顶层是数量最少、执行最慢、成本最高的端到端测试。金字塔的核心思想是:把大部分测试放在成本低、反馈快的底层,只用少量 E2E 测试覆盖关键用户旅程。

一个反面模式是"测试冰淇淋甜筒(Ice Cream Cone)":大量 E2E 测试、少量单元测试。这会导致 CI 缓慢、测试脆弱、定位问题困难。

| 层级 | 占比建议 | 单次执行速度 | 维护成本 | 定位问题精度 | 典型工具 |

| --- | --- | --- | --- | --- | --- |

| 单元测试 | 70% | 1-50ms | 低 | 高(精确到函数) | Jest / Vitest |

| 集成测试 | 20% | 50-500ms | 中 | 中(组件交互) | RTL + MSW |

| E2E 测试 | 10% | 2-30s | 高 | 低(整条流程) | Playwright / Cypress |

Kent C. Dodds 提出了"测试奖杯(Testing Trophy)"模型,强调集成测试的价值,因为它在"信心"和"成本"之间取得最佳平衡。对 React 应用,集成测试(渲染真实组件树、模拟真实交互、Mock 网络层)往往是性价比最高的投资。

一个数据对比: 假设一个中型 React 项目有 500 个测试用例。

| 配比方案 | 单元 | 集成 | E2E | CI 总耗时 | 重构后误报率 | 真实 bug 捕获率 |

| --- | --- | --- | --- | --- | --- | --- |

| 冰淇淋甜筒 | 50 | 100 | 350 | 约 25 分钟 | 高(40%) | 中 |

| 均衡金字塔 | 350 | 100 | 50 | 约 4 分钟 | 低(8%) | 高 |

| 测试奖杯 | 250 | 200 | 50 | 约 5 分钟 | 低(10%) | 很高 |

React Testing Library 查询优先级

为什么查询方式如此重要?

RTL 提供了多种查询方法,它们的优先级反映了"测试应该像用户一样使用应用"的哲学。优先使用无障碍属性(可访问性)查询,因为这既能测试功能,又能顺带验证无障碍性。

查询优先级(从高到低):

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

function LoginForm() {
  return (
    <form>
      <label htmlFor="email">邮箱</label>
      <input id="email" type="email" placeholder="请输入邮箱" />
      <button type="submit">登录</button>
      <img src="/logo.png" alt="公司 Logo" />
      <p data-testid="status">就绪</p>
    </form>
  );
}

describe('查询优先级演示', () => {
  beforeEach(() => render(<LoginForm />));

  // 1. 首选:可被所有人访问的查询(无障碍角色)
  test('getByRole - 最推荐', () => {
    // role 查询最健壮,同时验证了无障碍性
    expect(screen.getByRole('button', { name: '登录' })).toBeInTheDocument();
    expect(screen.getByRole('textbox', { name: '邮箱' })).toBeInTheDocument();
  });

  // 2. getByLabelText - 表单元素首选
  test('getByLabelText - 表单元素', () => {
    expect(screen.getByLabelText('邮箱')).toBeInTheDocument();
  });

  // 3. getByPlaceholderText - 没有 label 时的退路
  test('getByPlaceholderText', () => {
    expect(screen.getByPlaceholderText('请输入邮箱')).toBeInTheDocument();
  });

  // 4. getByText - 非交互元素(div、span、p)
  test('getByText', () => {
    expect(screen.getByText('就绪')).toBeInTheDocument();
  });

  // 5. getByAltText - 图片
  test('getByAltText', () => {
    expect(screen.getByAltText('公司 Logo')).toBeInTheDocument();
  });

  // 6. 最后的手段:getByTestId(前面都无法定位时)
  test('getByTestId - 最后手段', () => {
    expect(screen.getByTestId('status')).toHaveTextContent('就绪');
  });
});

三种查询变体的区别:

javascriptCode
// getBy*   - 找不到直接抛错,找到多个也抛错(断言元素必须存在)
// queryBy* - 找不到返回 null,用于断言"元素不存在"
// findBy*  - 返回 Promise,自动重试,用于异步出现的元素

describe('查询变体', () => {
  test('queryBy 断言元素不存在', () => {
    render(<div>已登录</div>);
    // 不能用 getByText 断言不存在,它会抛错;用 queryByText
    expect(screen.queryByText('登录')).not.toBeInTheDocument();
  });

  test('findBy 等待异步元素', async () => {
    render(<AsyncGreeting />);
    // findBy 内部封装了 waitFor,默认重试 1000ms
    expect(await screen.findByText('你好,World')).toBeInTheDocument();
  });

  test('getAllBy 处理多个元素', () => {
    render(
      <ul>
        <li>项 1</li>
        <li>项 2</li>
        <li>项 3</li>
      </ul>
    );
    expect(screen.getAllByRole('listitem')).toHaveLength(3);
  });
});

userEvent 深入:模拟真实用户交互

userEvent vs fireEvent:

`fireEvent` 直接派发单个 DOM 事件,而 `userEvent` 模拟真实用户的完整交互序列。例如点击一个按钮,真实用户会触发 pointerdown、mousedown、focus、pointerup、mouseup、click 一系列事件。`userEvent` 更贴近真实,能捕获更多 bug。

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

describe('userEvent 完整交互', () => {
  test('输入、清空、键盘操作', async () => {
    const user = userEvent.setup();
    render(<input aria-label="search" />);
    const input = screen.getByLabelText('search');

    // 输入文本
    await user.type(input, 'hello');
    expect(input).toHaveValue('hello');

    // 清空
    await user.clear(input);
    expect(input).toHaveValue('');

    // 特殊按键:{Enter}、{Backspace}、{ArrowLeft} 等
    await user.type(input, 'abc{Backspace}');
    expect(input).toHaveValue('ab');

    // 组合键(全选后删除)
    await user.type(input, '{Control>}a{/Control}{Delete}');
    expect(input).toHaveValue('');
  });

  test('下拉选择与复选框', async () => {
    const user = userEvent.setup();
    render(
      <>
        <select aria-label="城市">
          <option value="bj">北京</option>
          <option value="sh">上海</option>
        </select>
        <input type="checkbox" aria-label="同意条款" />
      </>
    );

    await user.selectOptions(screen.getByLabelText('城市'), 'sh');
    expect(screen.getByLabelText('城市')).toHaveValue('sh');

    await user.click(screen.getByLabelText('同意条款'));
    expect(screen.getByLabelText('同意条款')).toBeChecked();
  });

  test('悬停、双击、右键', async () => {
    const user = userEvent.setup();
    const onDoubleClick = jest.fn();
    const onContextMenu = jest.fn();
    render(
      <button onDoubleClick={onDoubleClick} onContextMenu={onContextMenu}>
        操作
      </button>
    );
    const btn = screen.getByRole('button');

    await user.dblClick(btn);
    expect(onDoubleClick).toHaveBeenCalledTimes(1);

    await user.pointer({ keys: '[MouseRight]', target: btn });
    expect(onContextMenu).toHaveBeenCalledTimes(1);
  });

  test('使用假定时器时需要配置 advanceTimers', async () => {
    jest.useFakeTimers();
    // 关键:userEvent 内部有延迟,需告知它推进假定时器
    const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
    render(<DebouncedSearch />);
    await user.type(screen.getByRole('textbox'), 'react');
    jest.advanceTimersByTime(500);
    expect(await screen.findByText(/搜索: react/)).toBeInTheDocument();
    jest.useRealTimers();
  });
});

Mock 策略全解

为什么需要 Mock?

测试应该隔离被测单元,避免依赖真实网络、真实定时器、真实第三方模块。Mock 让测试更快、更稳定、可复现。但过度 Mock 会让测试脱离现实,因此要把握"Mock 边界"——Mock 外部依赖(网络、时间、浏览器 API),但不 Mock 被测组件本身。

1. Mock 模块:

javascriptCode
// 完整 Mock 一个模块
jest.mock('axios');
import axios from 'axios';

test('mock axios', async () => {
  axios.get.mockResolvedValue({ data: { name: 'Alice' } });
  const res = await axios.get('/user');
  expect(res.data.name).toBe('Alice');
});

// 部分 Mock:保留真实实现,只覆盖某个导出
jest.mock('./utils', () => ({
  ...jest.requireActual('./utils'),
  formatDate: jest.fn(() => '2026-08-03'),
}));

// Mock 默认导出与具名导出混合
jest.mock('./api', () => ({
  __esModule: true,
  default: jest.fn(),
  fetchUser: jest.fn(),
}));

2. Mock 定时器:

javascriptCode
describe('假定时器', () => {
  beforeEach(() => jest.useFakeTimers());
  afterEach(() => jest.useRealTimers());

  test('防抖函数只在最后触发一次', () => {
    const fn = jest.fn();
    const debounced = debounce(fn, 300);
    debounced();
    debounced();
    debounced();
    expect(fn).not.toHaveBeenCalled();
    jest.advanceTimersByTime(300);
    expect(fn).toHaveBeenCalledTimes(1);
  });

  test('setInterval 轮询', () => {
    const poll = jest.fn();
    setInterval(poll, 1000);
    jest.advanceTimersByTime(3500);
    expect(poll).toHaveBeenCalledTimes(3);
  });
});

3. Mock 浏览器 API:

javascriptCode
// Mock matchMedia(jsdom 不实现)
beforeAll(() => {
  Object.defineProperty(window, 'matchMedia', {
    writable: true,
    value: jest.fn().mockImplementation((query) => ({
      matches: false,
      media: query,
      addEventListener: jest.fn(),
      removeEventListener: jest.fn(),
    })),
  });
});

// Mock IntersectionObserver
beforeAll(() => {
  global.IntersectionObserver = class {
    observe = jest.fn();
    unobserve = jest.fn();
    disconnect = jest.fn();
  };
});

// Mock localStorage
const localStorageMock = (() => {
  let store = {};
  return {
    getItem: (key) => store[key] || null,
    setItem: (key, value) => { store[key] = String(value); },
    removeItem: (key) => { delete store[key]; },
    clear: () => { store = {}; },
  };
})();
Object.defineProperty(window, 'localStorage', { value: localStorageMock });

测试 Context 与 Provider

问题: 依赖 Context 的组件在测试中直接渲染会报错,因为缺少 Provider。解决方案是提供一个自定义 render 函数,自动包裹所需的 Provider。

javascriptCode
// test-utils.tsx —— 自定义 render,一次性包裹所有 Provider
import { render as rtlRender } from '@testing-library/react';
import { ThemeProvider } from './ThemeContext';
import { AuthProvider } from './AuthContext';

function AllProviders({ children }) {
  return (
    <ThemeProvider>
      <AuthProvider>{children}</AuthProvider>
    </ThemeProvider>
  );
}

// 重新导出所有内容,并覆盖 render
export * from '@testing-library/react';
export function render(ui, options) {
  return rtlRender(ui, { wrapper: AllProviders, ...options });
}

// 使用:业务测试无需关心 Provider 细节
import { render, screen } from './test-utils';

test('组件能读取主题', () => {
  render(<ThemedButton />);
  expect(screen.getByRole('button')).toHaveClass('theme-light');
});

支持 initialState 的可配置 Provider:

javascriptCode
function renderWithAuth(ui, { user = null } = {}) {
  return render(
    <AuthContext.Provider value={{ user, login: jest.fn(), logout: jest.fn() }}>
      {ui}
    </AuthContext.Provider>
  );
}

test('未登录显示登录按钮', () => {
  renderWithAuth(<Navbar />, { user: null });
  expect(screen.getByRole('button', { name: '登录' })).toBeInTheDocument();
});

test('已登录显示用户名', () => {
  renderWithAuth(<Navbar />, { user: { name: 'Alice' } });
  expect(screen.getByText('Alice')).toBeInTheDocument();
});

测试路由(React Router)

javascriptCode
import { MemoryRouter, Routes, Route } from 'react-router-dom';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';

// 用 MemoryRouter 控制初始路由,无需真实浏览器
function renderWithRouter(ui, { route = '/' } = {}) {
  return render(<MemoryRouter initialEntries={[route]}>{ui}</MemoryRouter>);
}

test('访问 /about 渲染关于页', () => {
  renderWithRouter(
    <Routes>
      <Route path="/" element={<Home />} />
      <Route path="/about" element={<About />} />
    </Routes>,
    { route: '/about' }
  );
  expect(screen.getByRole('heading', { name: '关于我们' })).toBeInTheDocument();
});

test('点击链接跳转', async () => {
  const user = userEvent.setup();
  renderWithRouter(<App />);
  await user.click(screen.getByRole('link', { name: '关于' }));
  expect(await screen.findByRole('heading', { name: '关于我们' })).toBeInTheDocument();
});

测试状态管理(Redux / Zustand)

javascriptCode
// Redux:为每个测试创建独立 store,避免测试间污染
import { configureStore } from '@reduxjs/toolkit';
import { Provider } from 'react-redux';
import cartReducer from './cartSlice';

function renderWithStore(ui, { preloadedState } = {}) {
  const store = configureStore({
    reducer: { cart: cartReducer },
    preloadedState,
  });
  return {
    store,
    ...render(<Provider store={store}>{ui}</Provider>),
  };
}

test('加入购物车增加数量', async () => {
  const user = userEvent.setup();
  const { store } = renderWithStore(<ProductCard id="1" />, {
    preloadedState: { cart: { items: [] } },
  });
  await user.click(screen.getByRole('button', { name: '加入购物车' }));
  expect(store.getState().cart.items).toHaveLength(1);
});

// Zustand:在每个测试前重置 store 状态
import { useCartStore } from './store';

beforeEach(() => {
  useCartStore.setState({ items: [] });
});

test('Zustand action 更新状态', () => {
  useCartStore.getState().addItem({ id: '1', name: '商品' });
  expect(useCartStore.getState().items).toHaveLength(1);
});

测试错误边界与异常路径

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

class ErrorBoundary extends React.Component {
  state = { hasError: false };
  static getDerivedStateFromError() {
    return { hasError: true };
  }
  render() {
    if (this.state.hasError) return <div>出错了</div>;
    return this.props.children;
  }
}

function Bomb() {
  throw new Error('炸了');
}

test('错误边界捕获子组件异常', () => {
  // 抑制 React 打印的错误日志,让测试输出干净
  const spy = jest.spyOn(console, 'error').mockImplementation(() => {});
  render(
    <ErrorBoundary>
      <Bomb />
    </ErrorBoundary>
  );
  expect(screen.getByText('出错了')).toBeInTheDocument();
  spy.mockRestore();
});

无障碍性测试(jest-axe)

javascriptCode
import { render } from '@testing-library/react';
import { axe, toHaveNoViolations } from 'jest-axe';

expect.extend(toHaveNoViolations);

test('表单无无障碍违规', async () => {
  const { container } = render(<LoginForm />);
  const results = await axe(container);
  // 自动检测缺少 label、对比度不足、缺少 alt 等问题
  expect(results).toHaveNoViolations();
});

E2E 测试:Playwright 实战

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

test.describe('登录流程', () => {
  test('成功登录后跳转到仪表盘', async ({ page }) => {
    await page.goto('/login');
    await page.getByLabel('邮箱').fill('user@example.com');
    await page.getByLabel('密码').fill('password123');
    await page.getByRole('button', { name: '登录' }).click();

    // 等待导航并断言 URL 与页面内容
    await expect(page).toHaveURL('/dashboard');
    await expect(page.getByRole('heading', { name: '欢迎回来' })).toBeVisible();
  });

  test('拦截网络请求 Mock 后端', async ({ page }) => {
    // Playwright 可直接拦截并 Mock API,无需真实后端
    await page.route('**/api/user', (route) =>
      route.fulfill({ json: { name: 'Alice', role: 'admin' } })
    );
    await page.goto('/profile');
    await expect(page.getByText('Alice')).toBeVisible();
  });

  test('视觉回归截图对比', async ({ page }) => {
    await page.goto('/');
    // 首次运行生成基准图,后续对比像素差异
    await expect(page).toHaveScreenshot('homepage.png', { maxDiffPixels: 100 });
  });
});

Playwright 配置多浏览器与并行:

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

export default defineConfig({
  testDir: './tests',
  fullyParallel: true,          // 文件内并行
  retries: process.env.CI ? 2 : 0, // CI 失败重试,缓解偶发波动
  workers: process.env.CI ? 4 : undefined,
  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: 'mobile', use: { ...devices['iPhone 13'] } },
  ],
});

覆盖率:指标含义与合理阈值

覆盖率有四个维度,理解它们才能设置合理阈值,避免"为覆盖率而覆盖率"。

| 指标 | 含义 | 说明 |

| --- | --- | --- |

| Statements(语句) | 每条语句是否被执行 | 最基础,最容易达标 |

| Branches(分支) | if/else、三元、逻辑运算符各分支 | 最能反映逻辑覆盖质量 |

| Functions(函数) | 每个函数是否被调用 | 发现未测试的函数 |

| Lines(行) | 每行代码是否执行 | 与语句接近 |

javascriptCode
// jest.config.js —— 设置覆盖率阈值,低于则 CI 失败
module.exports = {
  collectCoverage: true,
  coverageThreshold: {
    global: {
      statements: 80,
      branches: 75,
      functions: 80,
      lines: 80,
    },
    // 对核心模块设更高标准
    './src/utils/': {
      branches: 90,
      functions: 90,
    },
  },
  coverageReporters: ['text', 'lcov', 'html'],
};

经验数据: 覆盖率与 bug 密度并非线性关系。业界普遍观察到:

| 覆盖率区间 | 边际价值 | 建议 |

| --- | --- | --- |

| 0% → 60% | 极高,快速捕获大量低级错误 | 优先补齐 |

| 60% → 80% | 高,覆盖主要分支 | 团队合理目标 |

| 80% → 90% | 中,覆盖边界与异常 | 核心模块追求 |

| 90% → 100% | 低,常测试到不值得的角落 | 不强求,避免脆弱测试 |

Vitest:更快的现代替代方案

Vitest 与 Jest API 高度兼容,但基于 Vite,启动更快、原生支持 ESM 和 TypeScript,是 Vite 项目的首选。

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

export default defineConfig({
  plugins: [react()],
  test: {
    environment: 'jsdom',
    globals: true,          // 无需 import describe/test/expect
    setupFiles: './vitest.setup.ts',
    coverage: {
      provider: 'v8',
      reporter: ['text', 'html'],
    },
  },
});

// 测试写法几乎与 Jest 一致,只需把 jest.fn 换成 vi.fn
import { describe, test, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';

test('vitest 测试组件', () => {
  const onClick = vi.fn();
  render(<button onClick={onClick}>点我</button>);
  screen.getByRole('button').click();
  expect(onClick).toHaveBeenCalled();
});

Jest vs Vitest 对比:

| 维度 | Jest | Vitest |

| --- | --- | --- |

| 启动速度 | 慢(需 babel/ts 转译) | 快(复用 Vite 转译) |

| ESM 支持 | 需配置,有坑 | 原生支持 |

| TypeScript | 需 ts-jest / babel | 开箱即用 |

| Watch 模式 | 一般 | 极快(HMR 级别) |

| 生态成熟度 | 非常成熟 | 快速成熟中 |

| 适用场景 | CRA、Next.js(默认) | Vite 项目 |

CI 集成完整示例

yamlCode
# .github/workflows/test.yml
name: Test
on: [push, pull_request]
jobs:
  unit-and-integration:
    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:ci -- --coverage --maxWorkers=2
      - uses: codecov/codecov-action@v4

  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 chromium
      - run: npm run build && npm run test:e2e
      - uses: actions/upload-artifact@v4
        if: failure()
        with:
          name: playwright-report
          path: playwright-report/

真实案例:一个搜索组件的完整测试

javascriptCode
// SearchableList.jsx
function SearchableList({ fetchUrl }) {
  const [query, setQuery] = React.useState('');
  const [items, setItems] = React.useState([]);
  const [loading, setLoading] = React.useState(false);
  const [error, setError] = React.useState(null);

  React.useEffect(() => {
    if (!query) { setItems([]); return; }
    let cancelled = false;
    setLoading(true);
    setError(null);
    fetch(fetchUrl + '?q=' + encodeURIComponent(query))
      .then((r) => { if (!r.ok) throw new Error('请求失败'); return r.json(); })
      .then((data) => { if (!cancelled) setItems(data); })
      .catch((e) => { if (!cancelled) setError(e.message); })
      .finally(() => { if (!cancelled) setLoading(false); });
    return () => { cancelled = true; };
  }, [query, fetchUrl]);

  return (
    <div>
      <input aria-label="搜索" value={query} onChange={(e) => setQuery(e.target.value)} />
      {loading && <p>加载中...</p>}
      {error && <p role="alert">{error}</p>}
      <ul>{items.map((it) => <li key={it.id}>{it.name}</li>)}</ul>
    </div>
  );
}

// SearchableList.test.jsx —— 用 MSW 覆盖成功、空、错误三条路径
import { rest } from 'msw';
import { setupServer } from 'msw/node';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';

const server = setupServer(
  rest.get('/api/search', (req, res, ctx) => {
    const q = req.url.searchParams.get('q');
    if (q === 'empty') return res(ctx.json([]));
    return res(ctx.json([{ id: 1, name: q + ' 结果' }]));
  })
);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

describe('SearchableList', () => {
  test('输入后展示搜索结果', async () => {
    const user = userEvent.setup();
    render(<SearchableList fetchUrl="/api/search" />);
    await user.type(screen.getByLabelText('搜索'), 'react');
    expect(await screen.findByText('react 结果')).toBeInTheDocument();
  });

  test('无结果时列表为空', async () => {
    const user = userEvent.setup();
    render(<SearchableList fetchUrl="/api/search" />);
    await user.type(screen.getByLabelText('搜索'), 'empty');
    // 等待 loading 消失后断言无列表项
    await waitForElementToBeRemoved(() => screen.queryByText('加载中...'));
    expect(screen.queryByRole('listitem')).not.toBeInTheDocument();
  });

  test('请求失败展示错误', async () => {
    server.use(rest.get('/api/search', (req, res, ctx) => res(ctx.status(500))));
    const user = userEvent.setup();
    render(<SearchableList fetchUrl="/api/search" />);
    await user.type(screen.getByLabelText('搜索'), 'boom');
    expect(await screen.findByRole('alert')).toHaveTextContent('请求失败');
  });
});

常见坑与规避

| 坑 | 现象 | 正确做法 |

| --- | --- | --- |

| 测试实现细节 | 重构后大量测试失败但功能正常 | 测行为不测实现,用 role/text 查询 |

| 缺少 act 警告 | 控制台 "not wrapped in act(...)" | 用 findBy/waitFor 等待异步更新 |

| 测试间状态污染 | 单独跑通过,一起跑失败 | beforeEach 重置 mock 与 store |

| 滥用 waitFor 包裹断言 | 超时才报错,定位慢 | 用 findBy 替代 waitFor + getBy |

| 假定时器忘记切回 | 后续测试莫名超时 | afterEach 调 useRealTimers |

| 过度 Mock | 测试全绿但线上崩 | 只 Mock 边界,用 MSW 而非 mock fetch |

| 快照过大 | 快照失去意义,无脑更新 | 小范围快照或改用显式断言 |

| E2E 依赖真实后端 | 测试不稳定、慢、难复现 | 用 route 拦截 Mock 网络 |

| 用 getByTestId 兜底一切 | 脱离用户视角 | 优先 role/label,testId 仅最后手段 |

最佳实践清单

测行为,不测实现:不断言内部 state、不检查具体 class 结构,而是断言用户能看到、能操作的东西。
AAA 结构:每个测试遵循 Arrange(准备)- Act(操作)- Assert(断言)三段式,清晰可读。
一个测试一个关注点:测试名描述行为("提交空表单显示错误"),失败时一眼知道哪里坏了。
优先 findBy 而非 waitFor + getBy:更简洁,报错信息更友好。
用 MSW 统一 Mock 网络:浏览器和 Node 环境行为一致,比手动 mock fetch 更真实。
自定义 render 封装 Provider:业务测试保持简洁,不重复样板。
在 CI 卡覆盖率阈值:防止覆盖率悄悄下滑,但不盲目追求 100%。
E2E 只测关键旅程:登录、下单、支付等核心路径,其余交给集成测试。

自定义 Hook 的进阶测试

自定义 Hook 无法脱离组件运行,`renderHook` 提供了一个宿主组件来承载 Hook,让我们直接测试其返回值与行为。

javascriptCode
import { renderHook, act, waitFor } from '@testing-library/react';

// 1. 测试带异步的 Hook
function useUser(id) {
  const [user, setUser] = React.useState(null);
  React.useEffect(() => {
    fetch('/api/user/' + id).then((r) => r.json()).then(setUser);
  }, [id]);
  return user;
}

test('useUser 加载数据', async () => {
  const { result } = renderHook(() => useUser('1'));
  expect(result.current).toBeNull();
  await waitFor(() => expect(result.current).toEqual({ id: '1', name: 'Alice' }));
});

// 2. 测试 rerender 时 props 变化
test('id 变化重新加载', async () => {
  const { result, rerender } = renderHook(({ id }) => useUser(id), {
    initialProps: { id: '1' },
  });
  await waitFor(() => expect(result.current?.id).toBe('1'));
  rerender({ id: '2' });
  await waitFor(() => expect(result.current?.id).toBe('2'));
});

// 3. 测试 cleanup(unmount 时清理)
test('unmount 时取消订阅', () => {
  const unsubscribe = jest.fn();
  renderHook(() => {
    React.useEffect(() => unsubscribe, []);
  }).unmount();
  expect(unsubscribe).toHaveBeenCalledTimes(1);
});

// 4. 需要 Provider 的 Hook:通过 wrapper 注入
test('useTheme 需要 Provider', () => {
  const wrapper = ({ children }) => <ThemeProvider>{children}</ThemeProvider>;
  const { result } = renderHook(() => useTheme(), { wrapper });
  expect(result.current.theme).toBe('light');
  act(() => result.current.toggleTheme());
  expect(result.current.theme).toBe('dark');
});

自定义匹配器与 jest-dom

`@testing-library/jest-dom` 提供了大量面向 DOM 的语义化断言,让测试更易读、报错更清晰。

javascriptCode
// 在 jest.setup.js 中一次性引入
import '@testing-library/jest-dom';

test('jest-dom 常用匹配器', () => {
  render(
    <form>
      <input aria-label="邮箱" required disabled value="a@b.com" readOnly />
      <button disabled>提交</button>
      <span hidden>隐藏文本</span>
    </form>
  );
  const input = screen.getByLabelText('邮箱');
  expect(input).toBeDisabled();
  expect(input).toBeRequired();
  expect(input).toHaveValue('a@b.com');
  expect(screen.getByRole('button')).toBeDisabled();
  expect(screen.getByText('隐藏文本')).not.toBeVisible();
});

// 编写自定义匹配器
expect.extend({
  toBeWithinRange(received, floor, ceiling) {
    const pass = received >= floor && received <= ceiling;
    return {
      pass,
      message: () =>
        '期望 ' + received + (pass ? ' 不' : ' ') + '在 ' + floor + '-' + ceiling + ' 范围内',
    };
  },
});

test('自定义范围匹配器', () => {
  expect(100).toBeWithinRange(90, 110);
});

调试失败的测试

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

test('调试技巧', () => {
  render(<ComplexComponent />);

  // 1. screen.debug() 打印当前 DOM(可传元素只打印局部)
  screen.debug();
  screen.debug(screen.getByRole('list'));

  // 2. 找不到元素时,logTestingPlaygroundURL 生成可视化调试链接
  screen.logTestingPlaygroundURL();

  // 3. 查看某元素有哪些可用的 role
  // 在断言前临时打印所有可访问角色
});

// 4. 设置更长的调试超时,避免调试时 findBy 提前失败
// configure({ asyncUtilTimeout: 5000 });

快照测试的正确姿势

快照测试适合"结构稳定、变化需要人工确认"的场景(如设计系统组件、序列化输出),但滥用会导致快照臃肿、无脑更新、失去意义。

javascriptCode
// 好的快照:小而聚焦,针对序列化数据
test('格式化函数输出稳定', () => {
  expect(formatInvoice({ amount: 100, tax: 0.1 })).toMatchInlineSnapshot(`
    {
      "amount": 100,
      "tax": 10,
      "total": 110,
    }
  `);
});

// 内联快照直接写在测试里,diff 时一目了然
// 反例:对整个复杂页面 toMatchSnapshot,任何微小改动都触发大 diff

快照测试适用性判断:

| 场景 | 是否推荐快照 | 替代方案 |

| --- | --- | --- |

| 纯函数序列化输出 | 推荐(inline snapshot) | - |

| 设计系统小组件 | 谨慎使用 | 显式断言关键属性 |

| 复杂业务页面 | 不推荐 | 行为断言 |

| API 响应结构 | 推荐 | - |

总结表格

| 主题 | 核心工具 | 关键要点 | 一句话建议 |

| --- | --- | --- | --- |

| 单元测试 | Jest / Vitest | 隔离、快速、精确定位 | 覆盖纯函数与自定义 Hook |

| 组件测试 | React Testing Library | 测行为、用无障碍查询 | getByRole 优先,testId 兜底 |

| 用户交互 | userEvent | 模拟完整事件序列 | 用 userEvent.setup() 而非 fireEvent |

| 网络 Mock | MSW | 浏览器/Node 一致、真实 | 覆盖成功/空/错误三路径 |

| 异步处理 | findBy / waitFor | 自动重试,避免 act 警告 | 优先 findBy |

| 状态管理 | 独立 store / setState 重置 | 避免测试间污染 | 每个测试独立 store |

| 覆盖率 | coverageThreshold | 分支覆盖最重要 | 目标 80%,核心 90% |

| E2E | Playwright / Cypress | 真实浏览器、关键旅程 | 拦截网络、CI 重试 |

| 无障碍 | jest-axe | 自动检测 a11y 违规 | 关键页面常态化检测 |

| CI 集成 | GitHub Actions | 并行、缓存、失败上传报告 | 单测与 E2E 分 job |

测试不是负担,而是重构的安全网和文档。构建以集成测试为主体、单元测试打底、E2E 守护关键路径的分层测试体系,配合真实的网络 Mock 和无障碍检测,才能在快速迭代中持续保持 React 应用的质量与可维护性。