React Testing Library

深入理解 React Testing Library 的测试理念:用户视角测试、query 优先级、act 包装、以及与 Enzyme 的区别。

React Testing Library

测试理念:以用户视角测试

React Testing Library(RTL)的核心理念是:测试应该像用户使用应用一样,而不是测试组件的内部实现。

这个理念来源一个简单的观察:

「你的代码是因为用户的需求而存在的,不是为了满足测试覆盖率。」

当你测试实现细节时,每次重构都可能破坏测试,即使功能没有任何问题。而当你测试用户能看到、能交互的部分,测试就会跟随真实需求,而不是代码结构。

测试金字塔

        /\
       /  \      E2E Tests (少)
      /----\     Integration Tests (中)
     /      \    Unit Tests (多)
    /--------\

RTL 主要用于 Integration Tests(集成测试),配合 Jest 的 Unit Tests 和 Cypress/Playwright 的 E2E Tests


RTL vs Enzyme

核心区别

Enzyme 允许你测试组件的「内部」:

// Enzyme 风格:测试组件内部状态
const wrapper = shallow(<Counter />);
wrapper.setState({ count: 5 });
expect(wrapper.state('count')).toBe(5);
wrapper.find('button').simulate('click');
expect(wrapper.state('count')).toBe(6);

RTL 风格:只测试输入(props)和输出(DOM):

// RTL 风格:只测试用户能看到和交互的部分
render(<Counter />);
expect(screen.getByText('Count: 0')).toBeInTheDocument();

fireEvent.click(screen.getByRole('button', { name: 'Increment' }));
expect(screen.getByText('Count: 1')).toBeInTheDocument();

为什么 RTL 更可取

  1. 不测试实现细节:重构组件不会破坏测试
  2. 强制正确的测试位置:你必须在正确的层级测试(用户交互)
  3. 更好的可维护性:测试跟随 UI 变化,而不是代码结构

Query 系统

Query 优先级

RTL 提供了多种 query,优先级从高到低:

优先级 Query 类型 示例
1 getByRole getByRole('button', { name: 'Submit' })
2 getByLabelText getByLabelText('Email')
3 getByPlaceholderText getByPlaceholderText('Enter email')
4 getByText getByText('Submit')
5 getByDisplayValue getByDisplayValue('[email protected]')
6 getByTestId getByTestId('submit-button')

最佳实践:优先使用高优先级的 query,它们更接近用户的认知方式。

常用 Query 示例

// 按 role 查找(最推荐)
const button = screen.getByRole('button', { name: 'Submit' });
const heading = screen.getByRole('heading', { level: 1 });
const link = screen.getByRole('link', { name: 'Learn more' });

// 按 label 查找表单元素
const emailInput = screen.getByLabelText('Email Address');
const passwordInput = screen.getByLabelText(/password/i); // 支持正则

// 按文本内容查找
const paragraph = screen.getByText('Hello, world!');
const listItems = screen.getAllByText(/item \d+/);

// 按 testId 查找(最后手段)
const submitButton = screen.getByTestId('submit-button');

Async Queries

处理异步操作:

// findBy:等待元素出现(默认超时 1000ms)
const successMessage = await screen.findByText('Saved!');

// waitFor:等待条件满足
await waitFor(() => {
  expect(screen.getByText('Hello')).toBeInTheDocument();
});

// waitFor 的超时和 interval 可以配置
await waitFor(
  () => expect(screen.getByText('Loaded')).toBeInTheDocument(),
  { timeout: 2000, interval: 100 }
);

Fire Events vs User Events

fireEvent

fireEvent 直接触发 DOM 事件:

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

test('handles click', () => {
  render(<Button>Click me</Button>);

  fireEvent.click(screen.getByRole('button'));
});

问题:fireEvent 不会模拟完整的用户交互(比如 input 事件的 target.value 设置)。

userEvent

@testing-library/user-event 模拟更真实的用户行为:

import userEvent from '@testing-library/user-event';

test('types in input', async () => {
  const user = userEvent.setup();
  render(<Input />);

  await user.type(screen.getByLabelText('Email'), '[email protected]');

  expect(screen.getByDisplayValue('[email protected]')).toBeInTheDocument();
});

userEvent 的优势:

  • 正确设置 inputvalue
  • 触发 change 事件
  • 处理 blur 等相关事件

Setup 和 Cleanup

测试文件的结构

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

// 共享的 setup
const setup = (jsx) => {
  return {
    user: userEvent.setup(),
    ...render(jsx),
  };
};

test('example', async () => {
  const { user } = setup(<Component />);

  await user.click(screen.getByRole('button'));
});

在每个测试后 cleanup

默认情况下,render 会自动清理。如果需要手动清理:

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

afterEach(cleanup);

常见测试场景

测试表单提交

test('submits form with correct data', async () => {
  const handleSubmit = jest.fn();
  const user = userEvent.setup();

  render(<LoginForm onSubmit={handleSubmit} />);

  await user.type(screen.getByLabelText('Email'), '[email protected]');
  await user.type(screen.getByLabelText('Password'), 'password123');
  await user.click(screen.getByRole('button', { name: 'Sign in' }));

  expect(handleSubmit).toHaveBeenCalledWith({
    email: '[email protected]',
    password: 'password123',
  });
});

测试异步数据加载

test('displays loading state then data', async () => {
  render(<UserProfile userId="1" />);

  // 初始状态:显示加载中
  expect(screen.getByRole('progressbar')).toBeInTheDocument();

  // 等待数据加载完成
  const heading = await screen.findByRole('heading', { name: 'John Doe' });
  expect(heading).toBeInTheDocument();

  // 加载指示器消失
  expect(screen.queryByRole('progressbar')).not.toBeInTheDocument();
});

测试错误状态

test('displays error message on failure', async () => {
  server.use(
    rest.get('/api/user', (req, res, ctx) => {
      return res(ctx.status(500), ctx.json({ message: 'Server Error' }));
    })
  );

  render(<UserProfile userId="1" />);

  const errorMessage = await screen.findByText('Something went wrong');
  expect(errorMessage).toBeInTheDocument();
});

Mock 和 Spy

Mock 函数

test('calls callback with arguments', () => {
  const callback = jest.fn();

  render(<Button onClick={callback} />);

  fireEvent.click(screen.getByRole('button'));

  expect(callback).toHaveBeenCalledTimes(1);
  expect(callback).toHaveBeenCalledWith(expect.objectContaining({
    type: 'click'
  }));
});

Mock 模块

jest.mock('./api', () => ({
  fetchUser: jest.fn()
}));

import { fetchUser } from './api';

test('loads user', async () => {
  fetchUser.mockResolvedValue({ name: 'Alice' });

  render(<UserProfile userId="1" />);

  expect(await screen.findByText('Alice')).toBeInTheDocument();
});

配合其他库

MSW (Mock Service Worker)

import { setupServer } from 'msw/node';
import { render, screen } from '@testing-library/react';
import { handlers } from './mocks/handlers';

const server = setupServer(...handlers);

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

test('loads user from API', async () => {
  render(<UserProfile userId="1" />);

  expect(await screen.findByText('John Doe')).toBeInTheDocument();
});

测试路由

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

render(
  <MemoryRouter initialEntries={['/users/1']}>
    <Routes>
      <Route path="/users/:id" element={<UserProfile />} />
    </Routes>
  </MemoryRouter>
);

面试中的表达

面试中聊到 Testing Library,通常是在考察你对测试理念和实践的理解:

React Testing Library 的核心理念是「以用户视角测试」。它不鼓励测试组件内部状态,而是测试用户能看到、能交互的部分。这不是偷懒,而是正确的测试哲学——你的测试应该跟随功能,而不是代码结构。

关于 query 的选择,我遵循的原则是:优先使用 getByRole,因为这是最接近用户认知的方式。如果找不到,再考虑 getByLabelTextgetByText,最后才是 getByTestId

关于 fireEvent 和 userEvent,我推荐使用 userEvent。它模拟更真实的用户行为,比如在 user.type 中会正确触发 inputchange 等事件,而 fireEvent 只是简单触发事件。


延展阅读