基于本文回答

播面 播面

刷题像听歌,多听自然懂
0
评论

如何在 React 中进行单元测试?(Jest + React Testing Library)

知识点图片

在 React 中进行单元测试,目前业界最标准的组合是 Jest(测试运行器/断言库)加上 React Testing Library (RTL)(用于渲染组件并模拟用户行为)。

RTL 的核心哲学是:“你的测试越像软件的使用方式,它们就越能给你信心。” 这意味着你应该测试用户看到的内容(如文本、按钮),而不是代码的实现细节(如 state 状态、类名)。

以下是一份循序渐进的实战指南。


1. 环境搭建

如果你是使用 Create React AppVite 创建的项目,这些库通常已经预装好了。

如果是手动配置,你需要安装以下依赖:

bash
npm install --save-dev jest jest-environment-jsdom @testing-library/react @testing-library/jest-dom @testing-library/user-event
  • Jest: 运行测试的框架。
  • @testing-library/react: 渲染 React 组件。
  • @testing-library/jest-dom: 提供了一组实用的自定义匹配器(如 toBeInTheDocument)。
  • @testing-library/user-event: 模拟真实的用户浏览器交互(比内置的 fireEvent 更强大)。

2. 基础测试:渲染与断言

假设我们有一个简单的组件 Greeting.js

jsx
// Greeting.js
import React from 'react';

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

export default Greeting;

编写测试 (Greeting.test.js):

javascript
import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom'; // 引入扩展的断言库
import Greeting from './Greeting';

test('renders correct greeting message', () => {
  // 1. 渲染组件
  render(<Greeting name="World" />);
  
  // 2. 查询元素 (模拟用户寻找屏幕上的文字)
  // 使用正则 /Hello, World!/i 忽略大小写
  const greetingElement = screen.getByText(/Hello, World!/i);
  
  // 3. 断言 (判断结果是否符合预期)
  expect(greetingElement).toBeInTheDocument();
});

3. 交互测试:点击与输入

测试用户交互时,推荐使用 user-event

假设有一个计数器组件 Counter.js

jsx
// Counter.js
import React, { useState } from 'react';

const Counter = () => {
  const [count, setCount] = useState(0);
  return (
    <div>
      <p>Current Count: {count}</p>
      <button onClick={() => setCount(count + 1)}>Increment</button>
    </div>
  );
};

export default Counter;

编写测试 (Counter.test.js):

javascript
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import '@testing-library/jest-dom';
import Counter from './Counter';

test('increments count when button is clicked', async () => {
  // 初始化 userEvent
  const user = userEvent.setup();
  
  render(<Counter />);
  
  // 验证初始状态
  const countElement = screen.getByText(/Current Count: 0/i);
  expect(countElement).toBeInTheDocument();
  
  // 找到按钮
  // getByRole 是首选查询方式,因为它最贴近无障碍阅读器和用户的使用习惯
  const button = screen.getByRole('button', { name: /increment/i });
  
  // 模拟点击 (这是一个异步操作)
  await user.click(button);
  
  // 验证点击后的状态
  expect(screen.getByText(/Current Count: 1/i)).toBeInTheDocument();
});

4. 异步测试:模拟 API 请求

在单元测试中,我们不应该发起真实的 HTTP 请求。我们需要 Mock(模拟)数据。

假设有一个用户列表组件 UserList.js

jsx
// UserList.js
import React, { useEffect, useState } from 'react';

const UserList = () => {
  const [users, setUsers] = useState([]);

  useEffect(() => {
    fetch('https://jsonplaceholder.typicode.com/users')
      .then((res) => res.json())
      .then((data) => setUsers(data));
  }, []);

  if (users.length === 0) return <div>Loading...</div>;

  return (
    <ul>
      {users.map((user) => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  );
};

export default UserList;

编写测试 (UserList.test.js):

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

// 模拟 fetch 函数
global.fetch = jest.fn();

test('renders users after fetching', async () => {
  // 1. 设定 Mock 返回的数据
  const mockUsers = [
    { id: 1, name: 'Alice' },
    { id: 2, name: 'Bob' },
  ];

  fetch.mockResolvedValueOnce({
    json: async () => mockUsers,
  });

  render(<UserList />);

  // 2. 验证 Loading 状态是否出现
  expect(screen.getByText(/Loading.../i)).toBeInTheDocument();

  // 3. 等待异步元素出现
  // 注意:这里必须用 findBy...,因为它会等待元素出现(默认超时 1000ms)
  // getBy... 如果找不到会立即报错
  const userAlice = await screen.findByText('Alice');
  
  expect(userAlice).toBeInTheDocument();
  expect(screen.getByText('Bob')).toBeInTheDocument();
  
  // 验证 fetch 被调用了一次
  expect(fetch).toHaveBeenCalledTimes(1);
});

5. 核心概念速查表

在使用 RTL 时,最让人困惑的是选择哪种查询方式(Query)。请遵循以下优先级:

A. 查询类型 (Query Types)

  1. getBy...:
    • 用于查找肯定存在的元素。
    • 如果找不到,测试直接失败(抛出错误)
    • 示例: getByText, getByRole.
  2. queryBy...:
    • 用于断言元素不存在
    • 如果找不到,返回 null(不会报错)。
    • 示例: expect(screen.queryByText(/error/i)).not.toBeInTheDocument().
  3. findBy...:
    • 用于异步操作(如 API 调用后)。
    • 返回一个 Promise,会重试直到元素出现或超时。
    • 必须配合 await 使用。

B. 查询优先级 (Priority)

  1. getByRole: 最推荐。如 getByRole('button', {name: 'Submit'})。这能确保你的应用对屏幕阅读器友好。
  2. getByLabelText: 适用于表单输入框。
  3. getByPlaceholderText: 适用于表单。
  4. getByText: 适用于非交互式文本(div, span, p)。
  5. getByTestId: 最后手段。当以上都无法定位时,在代码里加 data-testid="custom-element",然后用此方法查找。

6. 最佳实践总结

  1. 不要测试实现细节:不要去测组件内部的 state 是什么,或者组件用了什么 class。要测用户看到了什么,点击了什么。
  2. 使用 screen 对象:不要解构 render 的返回值,直接 import { screen } from ... 并在全局使用它,代码更简洁。
  3. 多用 user-event:尽量少用 fireEvent,因为 user-event 更真实地模拟了浏览器行为(例如:点击前会触发 hover,输入前会触发 focus)。
  4. Mock 外部依赖:所有的 API 请求、第三方复杂的库(如地图、图表)都应该被 Mock,保证单元测试的独立性和速度。

通过掌握以上模式,你就可以覆盖 React 开发中 90% 的单元测试场景了。

00:00
00:00