Skip to content

组件测试 (React Testing Library)

概述

React Testing Library (RTL) 是 React 组件测试的标准工具,遵循 "测试用户行为而非实现细节" 的理念。

一、核心理念

测试原则

"你的测试越像用户使用软件的方式,它们就越能给你信心。"
—— Kent C. Dodds
传统测试RTL 测试
测试组件状态测试 DOM 输出
测试生命周期测试用户交互
浅渲染完整渲染
实现细节用户行为

二、安装与配置

bash
npm install --save-dev @testing-library/react @testing-library/jest-dom @testing-library/user-event

Setup 文件

javascript
// jest.setup.js 或 vitest.setup.ts
import '@testing-library/jest-dom';

// 可选:全局清理
import { cleanup } from '@testing-library/react';
afterEach(() => {
  cleanup();
});

三、查询方法

查询优先级 (推荐顺序)

javascript
// 1. getByRole - 最推荐,符合可访问性
getByRole('button', { name: /submit/i })
getByRole('textbox', { name: /email/i })
getByRole('heading', { level: 1 })

// 2. getByLabelText - 表单元素首选
getByLabelText(/username/i)

// 3. getByPlaceholderText - 没有 label 时
getByPlaceholderText(/enter email/i)

// 4. getByText - 非交互元素
getByText(/welcome/i)

// 5. getByDisplayValue - 表单当前值
getByDisplayValue(/john@example.com/i)

// 6. getByAltText - 图片
getByAltText(/company logo/i)

// 7. getByTitle - title 属性
getByTitle(/close/i)

// 8. getByTestId - 最后手段
getByTestId('custom-element')

查询变体

前缀无匹配多个匹配返回异步
getBy抛错抛错元素
queryBynull抛错元素/null
findBy抛错抛错Promise
getAllBy抛错数组
queryAllBy[]数组
findAllBy抛错Promise

使用场景

javascript
// 元素必须存在
const button = screen.getByRole('button');

// 元素可能不存在
const error = screen.queryByText(/error/i);
expect(error).not.toBeInTheDocument();

// 等待元素出现 (异步)
const data = await screen.findByText(/loaded/i);

// 多个元素
const items = screen.getAllByRole('listitem');
expect(items).toHaveLength(3);

四、用户事件

user-event vs fireEvent

javascript
import userEvent from '@testing-library/user-event';
import { fireEvent } from '@testing-library/react';

// ❌ fireEvent - 直接触发 DOM 事件
fireEvent.click(button);
fireEvent.change(input, { target: { value: 'test' } });

// ✅ userEvent - 模拟真实用户行为
const user = userEvent.setup();
await user.click(button);
await user.type(input, 'test');

常用交互

javascript
const user = userEvent.setup();

// 点击
await user.click(element);
await user.dblClick(element);
await user.tripleClick(element); // 选中文本

// 键盘输入
await user.type(input, 'Hello World');
await user.type(input, '{enter}');
await user.type(input, '{backspace}');

// 清空并输入
await user.clear(input);
await user.type(input, 'new value');

// 键盘操作
await user.keyboard('{Shift>}A{/Shift}'); // Shift+A
await user.keyboard('[ControlLeft>][KeyC][/ControlLeft]'); // Ctrl+C

// Tab 导航
await user.tab();
await user.tab({ shift: true }); // Shift+Tab

// 选择
await user.selectOptions(select, ['option1', 'option2']);
await user.deselectOptions(select, 'option1');

// 悬停
await user.hover(element);
await user.unhover(element);

// 上传文件
const file = new File(['content'], 'file.txt', { type: 'text/plain' });
await user.upload(input, file);

// 剪贴板
await user.copy();
await user.cut();
await user.paste();

五、异步测试

waitFor

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

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

// 配置选项
await waitFor(
  () => expect(mockFn).toHaveBeenCalled(),
  {
    timeout: 3000,      // 超时时间
    interval: 100,      // 检查间隔
    onTimeout: (error) => { /* 超时回调 */ }
  }
);

findBy (推荐)

javascript
// findBy = getBy + waitFor
const element = await screen.findByText(/loaded/i);

// 等同于
await waitFor(() => screen.getByText(/loaded/i));

waitForElementToBeRemoved

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

// 等待元素消失
await waitForElementToBeRemoved(() => screen.queryByText(/loading/i));

六、渲染与重渲染

基础渲染

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

test('renders component', () => {
  render(<Button>Click me</Button>);
  
  expect(screen.getByRole('button')).toHaveTextContent('Click me');
});

带 Provider 渲染

javascript
// test-utils.js
import { render } from '@testing-library/react';
import { ThemeProvider } from 'styled-components';
import { Provider } from 'react-redux';
import { BrowserRouter } from 'react-router-dom';

const AllProviders = ({ children }) => {
  return (
    <Provider store={store}>
      <ThemeProvider theme={theme}>
        <BrowserRouter>
          {children}
        </BrowserRouter>
      </ThemeProvider>
    </Provider>
  );
};

const customRender = (ui, options) =>
  render(ui, { wrapper: AllProviders, ...options });

export * from '@testing-library/react';
export { customRender as render };

重渲染

javascript
test('re-renders with new props', () => {
  const { rerender } = render(<Counter count={0} />);
  expect(screen.getByText('0')).toBeInTheDocument();
  
  rerender(<Counter count={1} />);
  expect(screen.getByText('1')).toBeInTheDocument();
});

七、常见测试模式

表单测试

javascript
test('submits form with valid data', async () => {
  const handleSubmit = jest.fn();
  const user = userEvent.setup();
  
  render(<LoginForm onSubmit={handleSubmit} />);
  
  // 填写表单
  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 }));
  
  // 验证
  expect(handleSubmit).toHaveBeenCalledWith({
    email: 'test@example.com',
    password: 'password123'
  });
});

条件渲染测试

javascript
test('shows error when loading fails', async () => {
  // Mock API 失败
  server.use(
    rest.get('/api/user', (req, res, ctx) => {
      return res(ctx.status(500));
    })
  );
  
  render(<UserProfile />);
  
  // 等待错误显示
  expect(await screen.findByRole('alert')).toHaveTextContent(/error/i);
  
  // 确认用户信息未显示
  expect(screen.queryByText(/john/i)).not.toBeInTheDocument();
});

模态框测试

javascript
test('opens and closes modal', async () => {
  const user = userEvent.setup();
  render(<App />);
  
  // 打开模态框
  await user.click(screen.getByRole('button', { name: /open modal/i }));
  
  // 验证模态框内容
  expect(screen.getByRole('dialog')).toBeInTheDocument();
  expect(screen.getByText(/modal content/i)).toBeInTheDocument();
  
  // 关闭模态框
  await user.click(screen.getByRole('button', { name: /close/i }));
  
  // 验证模态框已关闭
  await waitForElementToBeRemoved(() => screen.queryByRole('dialog'));
});

API Mock (MSW)

javascript
import { rest } from 'msw';
import { setupServer } from 'msw/node';

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

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

test('loads and displays users', async () => {
  render(<UserList />);
  
  // 等待数据加载
  expect(await screen.findByText('John')).toBeInTheDocument();
  expect(screen.getByText('Jane')).toBeInTheDocument();
});

八、自定义 Matcher

jest-dom 扩展

javascript
import '@testing-library/jest-dom';

// 可见性
expect(element).toBeVisible();
expect(element).not.toBeVisible();

// 存在性
expect(element).toBeInTheDocument();
expect(element).toBeEmptyDOMElement();

// 属性
expect(element).toHaveAttribute('href', '/home');
expect(element).toHaveClass('active');
expect(element).toHaveStyle({ color: 'red' });

// 表单状态
expect(input).toBeDisabled();
expect(input).toBeEnabled();
expect(input).toBeRequired();
expect(checkbox).toBeChecked();
expect(input).toHaveValue('test');
expect(input).toBeInvalid();

// 焦点
expect(input).toHaveFocus();

// 文本
expect(element).toHaveTextContent(/hello/i);
expect(element).toHaveDisplayValue('selected option');

// 可访问性
expect(element).toHaveAccessibleName('Submit');
expect(element).toHaveAccessibleDescription('Submit the form');

九、调试技巧

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

// 打印 DOM
screen.debug(); // 打印整个 DOM
screen.debug(element); // 打印特定元素

// 获取 DOM 字符串
console.log(prettyDOM(element));

// 打印可访问性树
screen.logTestingPlaygroundURL(); // 生成 Playground URL

// 查看所有可用查询
import { screen } from '@testing-library/dom';
screen.getByRole(''); // 报错时会展示可用 role

面试高频题

Q1: 为什么推荐 getByRole 而不是 getByTestId?

:

  1. getByRole 测试可访问性,确保组件对屏幕阅读器友好
  2. 更接近用户实际使用方式
  3. 降低测试与实现的耦合
  4. getByTestId 是最后手段,因为用户看不到 test-id

Q2: 什么时候用 queryBy vs getBy?

:

  • getBy: 元素必须存在,否则抛错
  • queryBy: 断言元素不存在时使用
javascript
expect(screen.queryByText(/error/i)).not.toBeInTheDocument();

Q3: userEvent 和 fireEvent 的区别?

:

  • fireEvent: 直接触发 DOM 事件
  • userEvent: 模拟完整的用户交互(如 type 会触发 focus、keydown、input、change 等一系列事件)

Q4: 如何测试自定义 Hook?

: 使用 @testing-library/react-hooksrenderHook

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

test('useCounter', () => {
  const { result } = renderHook(() => useCounter());
  
  expect(result.current.count).toBe(0);
  
  act(() => {
    result.current.increment();
  });
  
  expect(result.current.count).toBe(1);
});

前端面试知识库