React Testing Library 测试库

精选 在手动测试 React 应用时,我们可以选择在简化的测试环境中渲染单个组件树,也可以在真实的浏览器环境中运行完整的应用(端到端测试)。但对于自动化测试,推荐使用 React Testing Library (RTL),因为它采用以用户为中心的设计方式并且易于维护。

#入门介绍

React Testing Library 构建于 DOM Testing Library 之上,通过查询和操作真实 DOM 节点来测试 React 组件,避免对组件内部实现细节产生依赖。

#基础级别 (Basic level)

#1. 核心目标与解决方案 (Purpose & Solution)

RTL 通过关注用户可见的行为来提升测试的可维护性:

  • 测试在实际 DOM 节点中运行。
  • 查询选择器真实模拟用户的交互方式。
  • 必要时使用 data-testid 作为保底回退选择。
  • 鼓励并践行可访问性 (Accessibility) 规范。

#2. 基础组件渲染测试 (A basic component render test)

受测组件 (App.js):

const title = 'Hello, World!';

function App() {
  return <div>{title}</div>;
}

export default App;

测试脚本 (App.test.js):

import { render } from '@testing-library/react';
import App from './App';

describe('App', () => {
  test('renders App component', () => {
    render(<App />);
  });
});

添加 debug 方法查看渲染 DOM 树:

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

describe('App', () => {
  test('renders App component', () => {
    render(<App />);
    screen.debug();
  });
});

控制台输出内容 (Console Output):

<body>
  <div>
    <div>Hello, World!</div>
  </div>
</body>

#3. 为什么选择 RTL 而非 Enzyme?

  1. 基于用户真实交互编写测试,而非依赖内部私有 API。
  2. 在代码重构后保持良好的可维护性,不易因内部重构而崩坏。
  3. 直观且符合语义的查询语法 (getByText, getByAltText 等)。

#4. RTL 查询选择器 (Queries in RTL)

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

test('should show login form', () => {
  render(<Login />);
  const input = screen.getByLabelText('Username');
  // 事件交互与断言校验
});

单元素查询选择器 (Single element queries):

  • getBy*: 找不到匹配元素时直接抛出 Exception 异常
  • queryBy*: 找不到匹配元素时返回 null
  • findBy*: 返回异步 Promise

多元素查询选择器 (Multiple elements queries):

  • getAllBy*: 找不到匹配元素时抛出异常
  • queryAllBy*: 找不到匹配元素时返回空数组 []
  • findAllBy*: 返回包含元素的异步 Promise 数组

#5. 组件树测试粒度 (Component tree testing level)

  • 优先在用户交互层面进行测试,除非有特定需求,否则不必为每一个子组件单独编写隔离测试。

#中级进阶 (Intermediate level)

#1. Jest 与 RTL 的区别 (Jest vs RTL)

  • Jest: 测试运行器与断言库 (提供 describe, test, expect 等 API)。
  • RTL: 专门为 React 设计的 DOM 工具集;可在 Jest(或其它测试运行器)中运行。

#2. 使用 MSW 进行网络 Mock 拦截 (Mocking with MSW)

// fetch.test.jsx
import React from 'react';
import { rest } from 'msw';
import { setupServer } from 'msw/node';
import { render, fireEvent, waitFor, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import Fetch from '../fetch';

const server = setupServer(
  rest.get('/greeting', (req, res, ctx) =>
    res(ctx.json({ greeting: 'hello there' }))
  )
);

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

test('loads and displays greeting', async () => {
  render(<Fetch url="/greeting" />);
  fireEvent.click(screen.getByText('Load Greeting'));
  await waitFor(() => screen.getByRole('heading'));
  expect(screen.getByRole('heading')).toHaveTextContent('hello there');
  expect(screen.getByRole('button')).toBeDisabled();
});

test('handles server error', async () => {
  server.use(rest.get('/greeting', (req, res, ctx) => res(ctx.status(500))));
  render(<Fetch url="/greeting" />);
  fireEvent.click(screen.getByText('Load Greeting'));
  await waitFor(() => screen.getByRole('alert'));
  expect(screen.getByRole('alert')).toHaveTextContent('Oops, failed to fetch!');
  expect(screen.getByRole('button')).not.toBeDisabled();
});

#3. render 函数高级配置项 (render options)

import { render } from '@testing-library/react';
import '@testing-library/jest-dom';

test('renders a message', () => {
  const table = document.createElement('table');
  const { container } = render(<TableBody {...props} />, {
    container: document.body.appendChild(table),
    baseElement: document.body,
    hydrate: true,
    legacyRoot: true,
    queries: {
      /* 自定义查询选择器 */
    }
  });
  expect(container).toBeInTheDocument();
});

#4. renderHook 测试自定义 Hook (renderHook usage)

import { renderHook } from '@testing-library/react';

test('returns logged in user', () => {
  const { result, rerender } = renderHook(
    ({ name } = {}) => useLoggedInUser(name),
    { initialProps: { name: 'Alice' } }
  );
  expect(result.current).toEqual({ name: 'Alice' });
  rerender({ name: 'Bob' });
  expect(result.current).toEqual({ name: 'Bob' });
});

#⚡ 高级特性与技巧 (Advanced Level)

#1. 添加自定义查询选择器 (Adding custom queries)

const dom = require('@testing-library/dom');
const { queryHelpers, buildQueries } = require('@testing-library/react');

// 重写覆盖默认的 testId 属性名称
export const queryByTestId = queryHelpers.queryByAttribute.bind(
  null,
  'data-test-id'
);
export const queryAllByTestId = queryHelpers.queryAllByAttribute.bind(
  null,
  'data-test-id'
);
export function getAllByTestId(container, id, ...rest) {
  const els = queryAllByTestId(container, id, ...rest);
  if (!els.length)
    throw queryHelpers.getElementError(
      `No element with [data-test-id="${id}"]`,
      container
    );
  return els;
}
export function getByTestId(container, id, ...rest) {
  const els = getAllByTestId(container, id, ...rest);
  if (els.length > 1)
    throw queryHelpers.getElementError(
      `Multiple elements with [data-test-id="${id}"]`,
      container
    );
  return els[0];
}

或者使用 buildQueries 进行构建:

const queryAllByDataCy = (...args) =>
  queryHelpers.queryAllByAttribute('data-cy', ...args);
const [
  queryByDataCy,
  getAllByDataCy,
  getByDataCy,
  findAllByDataCy,
  findByDataCy
] = buildQueries(
  queryAllByDataCy,
  (c, v) => `Found multiple elements with data-cy="${v}"`,
  (c, v) => `Unable to find element with data-cy="${v}"`
);

#2. 跳过自动清理清理 (Skipping auto cleanup)

  • 通过 CLI 命令行设置:cross-env RTL_SKIP_AUTO_CLEANUP=true jest
  • 或添加至 Jest 的 setupFilesimport '@testing-library/react/dont-cleanup-after-each';

#3. 从 Enzyme 迁移至 RTL (Migrating from Enzyme)

  1. 安装 RTL 与 jest-dom
  2. shallow/mount 替换为 render + screen
  3. 渐进式逐步迁移原有测试用例。

#4. 局部范围内部查询 (Querying within elements)

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

const { getByText } = render(<MyComponent />);
const section = getByText('messages');
const hello = within(section).getByText('hello');

#5. 集成测试最佳实践 (Integration testing)

import { render, cleanup, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import nock from 'nock';
import App from '../App';

const REPOS = [{ name: 'repo1', description: '...' }];

beforeAll(() =>
  nock('https://api.github.com')
    .persist()
    .get('/users/alice/repos')
    .reply(200, REPOS)
);
afterEach(cleanup);

test('user sees public repos', async () => {
  render(<App />);
  userEvent.type(screen.getByPlaceholderText('Enter username'), 'alice');
  userEvent.click(screen.getByRole('button', { name: /submit/i }));
  await waitFor(() =>
    REPOS.forEach((r) => expect(screen.getByText(r.name)).toBeInTheDocument())
  );
  expect(screen.queryByText('Loading...')).toBeNull();
});

#总结 (Conclusion)

本快速备忘手册涵盖了从基础到高级的 RTL 用法——渲染、查询选择器、网络 Mock 拦截、自定义 Query 选择器以及集成测试,帮助您编写稳健、易于维护的测试套件。