中文

通过本完整指南精通 React Testing Library (RTL)。学习为您的 React 应用编写有效、可维护、以用户为中心的测试,重点介绍最佳实践和真实案例。

React Testing Library:全面指南

在当今快节奏的 Web 开发领域,确保 React 应用的质量和可靠性至关重要。React Testing Library (RTL) 已成为一种流行且有效的解决方案,用于编写关注用户视角的测试。本指南全面概述了 RTL,涵盖了从基本概念到高级技巧的所有内容,使您能够构建健壮且可维护的 React 应用。

为什么选择 React Testing Library?

传统的测试方法通常依赖于实现细节,这使得测试变得脆弱,并且容易因微小的代码更改而中断。而 RTL 则鼓励您像用户与组件交互那样来测试组件,专注于用户看到和体验到的内容。这种方法有几个关键优势:

设置您的测试环境

在开始使用 RTL 之前,您需要设置您的测试环境。这通常涉及安装必要的依赖项并配置您的测试框架。

先决条件

安装

使用 npm 或 yarn 安装以下软件包:

npm install --save-dev @testing-library/react @testing-library/jest-dom jest babel-jest @babel/preset-env @babel/preset-react

或者,使用 yarn:

yarn add --dev @testing-library/react @testing-library/jest-dom jest babel-jest @babel/preset-env @babel/preset-react

软件包说明:

配置

在项目根目录下创建一个 `babel.config.js` 文件,内容如下:

module.exports = {
  presets: ['@babel/preset-env', '@babel/preset-react'],
};

更新您的 `package.json` 文件以包含一个测试脚本:

{
  "scripts": {
    "test": "jest"
  }
}

在项目根目录下创建一个 `jest.config.js` 文件来配置 Jest。一个最小化的配置可能如下所示:

module.exports = {
  testEnvironment: 'jsdom',
  setupFilesAfterEnv: ['/src/setupTests.js'],
};

创建一个 `src/setupTests.js` 文件,内容如下。这可以确保 Jest DOM 匹配器在您所有的测试中都可用:

import '@testing-library/jest-dom/extend-expect';

编写您的第一个测试

我们从一个简单的例子开始。假设您有一个显示问候消息的 React 组件:

// src/components/Greeting.js
import React from 'react';

function Greeting({ name }) {
  return <h1>Hello, {name}!</h1>;
}

export default Greeting;

现在,让我们为这个组件编写一个测试:

// src/components/Greeting.test.js
import React from 'react';
import { render, screen } from '@testing-library/react';
import Greeting from './Greeting';

test('渲染一条问候消息', () => {
  render(<Greeting name="World" />);
  const greetingElement = screen.getByText(/Hello, World!/i);
  expect(greetingElement).toBeInTheDocument();
});

说明:

要运行测试,请在终端中执行以下命令:

npm test

如果一切配置正确,测试应该会通过。

常见的 RTL 查询

RTL 提供了多种查询方法用于在 DOM 中查找元素。这些查询旨在模仿用户与您的应用交互的方式。

`getByRole`

此查询通过元素的 ARIA role 查找元素。尽可能使用 `getByRole` 是一个好习惯,因为它能促进可访问性,并确保您的测试对底层 DOM 结构的更改具有弹性。

<button role="button">Click me</button>
const buttonElement = screen.getByRole('button');
expect(buttonElement).toBeInTheDocument();

`getByLabelText`

此查询通过关联标签的文本查找元素。它对于测试表单元素很有用。

<label htmlFor="name">Name:</label>
<input type="text" id="name" />
const nameInputElement = screen.getByLabelText('Name:');
expect(nameInputElement).toBeInTheDocument();

`getByPlaceholderText`

此查询通过元素的占位符文本查找元素。

<input type="text" placeholder="Enter your email" />
const emailInputElement = screen.getByPlaceholderText('Enter your email');
expect(emailInputElement).toBeInTheDocument();

`getByAltText`

此查询通过图片的 alt 文本查找图片元素。为所有图片提供有意义的 alt 文本以确保可访问性非常重要。

<img src="logo.png" alt="Company Logo" />
const logoImageElement = screen.getByAltText('Company Logo');
expect(logoImageElement).toBeInTheDocument();

`getByTitle`

此查询通过元素的 title 属性查找元素。

<span title="Close">X</span>
const closeElement = screen.getByTitle('Close');
expect(closeElement).toBeInTheDocument();

`getByDisplayValue`

此查询通过元素的显示值查找元素。这对于测试带有预填充值的表单输入很有用。

<input type="text" value="Initial Value" />
const inputElement = screen.getByDisplayValue('Initial Value');
expect(inputElement).toBeInTheDocument();

`getAllBy*` 查询

除了 `getBy*` 查询,RTL 还提供了 `getAllBy*` 查询,它们返回一个匹配元素的数组。当您需要断言 DOM 中存在多个具有相同特征的元素时,这很有用。

<li>Item 1</li>
<li>Item 2</li>
<li>Item 3</li>
const listItems = screen.getAllByRole('listitem');
expect(listItems).toHaveLength(3);

`queryBy*` 查询

`queryBy*` 查询与 `getBy*` 查询类似,但如果没有找到匹配的元素,它们会返回 `null` 而不是抛出错误。当您想断言某个元素*不*存在于 DOM 中时,这很有用。

const missingElement = screen.queryByText('Non-existent text');
expect(missingElement).toBeNull();

`findBy*` 查询

`findBy*` 查询是 `getBy*` 查询的异步版本。它们返回一个 Promise,在找到匹配元素时解析。这对于测试异步操作(例如从 API 获取数据)很有用。

// 模拟异步数据获取
const fetchData = () => new Promise(resolve => {
  setTimeout(() => resolve('Data Loaded!'), 1000);
});

function MyComponent() {
  const [data, setData] = React.useState(null);

  React.useEffect(() => {
    fetchData().then(setData);
  }, []);

  return <div>{data}</div>;
}
test('异步加载数据', async () => {
  render(<MyComponent />);
  const dataElement = await screen.findByText('Data Loaded!');
  expect(dataElement).toBeInTheDocument();
});

模拟用户交互

RTL 提供了 `fireEvent` 和 `userEvent` API 来模拟用户交互,例如点击按钮、在输入字段中键入内容以及提交表单。

`fireEvent`

`fireEvent` 允许您以编程方式触发 DOM 事件。它是一个较低级的 API,可让您对触发的事件进行精细控制。

<button onClick={() => alert('Button clicked!')}>Click me</button>
import { fireEvent } from '@testing-library/react';

test('模拟按钮点击', () => {
  const alertMock = jest.spyOn(window, 'alert').mockImplementation(() => {});
  render(<button onClick={() => alert('Button clicked!')}>Click me</button>);
  const buttonElement = screen.getByRole('button');
  fireEvent.click(buttonElement);
  expect(alertMock).toHaveBeenCalledTimes(1);
  alertMock.mockRestore();
});

`userEvent`

`userEvent` 是一个更高级别的 API,可以更真实地模拟用户交互。它处理诸如焦点管理和事件顺序之类的细节,使您的测试更健壮、更不容易出错。

<input type="text" onChange={e => console.log(e.target.value)} />
import userEvent from '@testing-library/user-event';

test('模拟在输入字段中键入', () => {
  const inputElement = screen.getByRole('textbox');
  userEvent.type(inputElement, 'Hello, world!');
  expect(inputElement).toHaveValue('Hello, world!');
});

测试异步代码

许多 React 应用都涉及异步操作,例如从 API 获取数据。RTL 提供了多种工具来测试异步代码。

`waitFor`

`waitFor` 允许您在进行断言之前等待某个条件变为真。这对于测试需要一些时间才能完成的异步操作很有用。

function MyComponent() {
  const [data, setData] = React.useState(null);

  React.useEffect(() => {
    setTimeout(() => {
      setData('Data loaded!');
    }, 1000);
  }, []);

  return <div>{data}</div>;
}
import { waitFor } from '@testing-library/react';

test('等待数据加载', async () => {
  render(<MyComponent />);
  await waitFor(() => screen.getByText('Data loaded!'));
  const dataElement = screen.getByText('Data loaded!');
  expect(dataElement).toBeInTheDocument();
});

`findBy*` 查询

如前所述,`findBy*` 查询是异步的,并返回一个在找到匹配元素时解析的 Promise。这对于测试导致 DOM 更改的异步操作很有用。

测试 Hooks

React Hooks 是封装了有状态逻辑的可复用函数。RTL 提供了 `@testing-library/react-hooks` 中的 `renderHook` 工具(自 v17 起已弃用,推荐直接使用 `@testing-library/react`)来独立测试自定义 Hooks。

// src/hooks/useCounter.js
import { useState } from 'react';

function useCounter(initialValue = 0) {
  const [count, setCount] = useState(initialValue);

  const increment = () => {
    setCount(prevCount => prevCount + 1);
  };

  const decrement = () => {
    setCount(prevCount => prevCount - 1);
  };

  return { count, increment, decrement };
}

export default useCounter;
// src/hooks/useCounter.test.js
import { renderHook, act } from '@testing-library/react';
import useCounter from './useCounter';

test('增加计数器', () => {
  const { result } = renderHook(() => useCounter());

  act(() => {
    result.current.increment();
  });

  expect(result.current.count).toBe(1);
});

说明:

高级测试技巧

一旦您掌握了 RTL 的基础知识,就可以探索更高级的测试技巧,以提高测试的质量和可维护性。

模拟模块

有时,您可能需要模拟外部模块或依赖项,以便在测试期间隔离组件并控制其行为。Jest 为此提供了强大的模拟 API。

// src/api/dataService.js
export const fetchData = async () => {
  const response = await fetch('/api/data');
  const data = await response.json();
  return data;
};
// src/components/MyComponent.js
import React, { useState, useEffect } from 'react';
import { fetchData } from '../api/dataService';

function MyComponent() {
  const [data, setData] = useState(null);

  useEffect(() => {
    fetchData().then(setData);
  }, []);

  return <div>{data}</div>;
}
// src/components/MyComponent.test.js
import { render, screen, waitFor } from '@testing-library/react';
import MyComponent from './MyComponent';
import * as dataService from '../api/dataService';

jest.mock('../api/dataService');

test('从 API 获取数据', async () => {
  dataService.fetchData.mockResolvedValue({ message: 'Mocked data!' });

  render(<MyComponent />);

  await waitFor(() => screen.getByText('Mocked data!'));

  expect(screen.getByText('Mocked data!')).toBeInTheDocument();
  expect(dataService.fetchData).toHaveBeenCalledTimes(1);
});

说明:

Context Providers

如果您的组件依赖于 Context Provider,您需要在测试期间将组件包装在该 provider 中。这可确保组件能够访问 context 的值。

// src/contexts/ThemeContext.js
import React, { createContext, useState } from 'react';

export const ThemeContext = createContext();

export function ThemeProvider({ children }) {
  const [theme, setTheme] = useState('light');

  const toggleTheme = () => {
    setTheme(prevTheme => (prevTheme === 'light' ? 'dark' : 'light'));
  };

  return (
    <ThemeContext.Provider value={{ theme, toggleTheme }}>
      {children}
    </ThemeContext.Provider>
  );
}
// src/components/MyComponent.js
import React, { useContext } from 'react';
import { ThemeContext } from '../contexts/ThemeContext';

function MyComponent() {
  const { theme, toggleTheme } = useContext(ThemeContext);

  return (
    <div style={{ backgroundColor: theme === 'light' ? '#fff' : '#000', color: theme === 'light' ? '#000' : '#fff' }}>
      <p>Current theme: {theme}</p>
      <button onClick={toggleTheme}>Toggle Theme</button>
    </div>
  );
}
// src/components/MyComponent.test.js
import { render, screen, fireEvent } from '@testing-library/react';
import MyComponent from './MyComponent';
import { ThemeProvider } from '../contexts/ThemeContext';

test('切换主题', () => {
  render(
    <ThemeProvider>
      <MyComponent />
    </ThemeProvider>
  );

  const themeParagraph = screen.getByText(/Current theme: light/i);
  const toggleButton = screen.getByRole('button', { name: /Toggle Theme/i });

  expect(themeParagraph).toBeInTheDocument();

  fireEvent.click(toggleButton);

  expect(screen.getByText(/Current theme: dark/i)).toBeInTheDocument();
});

说明:

使用 Router 进行测试

在测试使用 React Router 的组件时,您需要提供一个模拟的 Router context。您可以使用 `react-router-dom` 中的 `MemoryRouter` 组件来实现这一点。

// src/components/MyComponent.js
import React from 'react';
import { Link } from 'react-router-dom';

function MyComponent() {
  return (
    <div>
      <Link to="/about">Go to About Page</Link>
    </div>
  );
}
// src/components/MyComponent.test.js
import { render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import MyComponent from './MyComponent';

test('渲染一个指向 about 页面的链接', () => {
  render(
    <MemoryRouter>
      <MyComponent />
    </MemoryRouter>
  );

  const linkElement = screen.getByRole('link', { name: /Go to About Page/i });
  expect(linkElement).toBeInTheDocument();
  expect(linkElement).toHaveAttribute('href', '/about');
});

说明:

编写有效测试的最佳实践

以下是使用 RTL 编写测试时应遵循的一些最佳实践:

结论

React Testing Library 是一个强大的工具,用于为您的 React 应用编写有效、可维护且以用户为中心的测试。通过遵循本指南中概述的原则和技巧,您可以构建满足用户需求的健壮可靠的应用。请记住,要从用户的角度进行测试,避免测试实现细节,并编写清晰简洁的测试。通过拥抱 RTL 并采纳最佳实践,无论您身在何处或您的全球受众有何特定要求,您都可以显著提高 React 项目的质量和可维护性。