文章目录

React 测试:Jest 与 React Testing Library

发布于 2026-04-06 06:42:22 · 浏览 21 次 · 评论 0 条

React 测试:Jest 与 React Testing Library

在现代前端开发中,测试已经成为了不可或缺的一环。随着应用复杂度不断提升,手动测试不仅耗时,而且难以覆盖所有边界情况。自动化测试能够帮助你在代码重构、添加新功能时快速发现问题,增强对代码质量的信心。本文将介绍 React 项目中最常用的两种测试工具——Jest 和 React Testing Library,并带你从零开始掌握它们的核心用法。


1. 为什么需要测试驱动开发

在编写代码之前先写测试,这种开发方式被称为测试驱动开发(TDD)。TDD 的核心思想是「红-绿-重构」循环:首先编写一个失败的测试(红),然后编写最少量的代码让测试通过(绿),最后优化代码结构(重构)。这种循环能带来几个显著好处:提前发现 bug,避免问题在后期放大;明确接口设计,迫使你思考组件的使用方式;安全重构,确保修改代码不会破坏已有功能。

对于 React 应用而言,组件是构建界面的基本单元。每个组件都应该有一个清晰的职责边界,这使得为组件编写单元测试变得自然而然。接下来我们将搭建一个完整的测试环境,并学习如何测试 React 组件。


2. 环境搭建与依赖安装

2.1 使用 Create React App 或 Vite 创建项目

如果你是从零开始,推荐使用 Create React App 或 Vite 来创建项目,因为它们已经内置了对 Jest 的支持。

使用 Vite 创建项目

npm create vite@latest my-react-app -- --template react
cd my-react-app
npm install

使用 Create React App 创建项目

npx create-react-app my-react-app
cd my-react-app
npm start

2.2 手动安装测试依赖

如果你在已有项目中添加测试功能,需要手动安装以下依赖:

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

安装完成后,需要在 package.json 中添加测试脚本:

"scripts": {
  "test": "jest",
  "test:watch": "jest --watch",
  "test:coverage": "jest --coverage"
}

为了支持 ES6 语法和 JSX,你还需要配置 Babel。安装 Babel 相关依赖:

npm install --save-dev @babel/preset-env @babel/preset-react

创建 babel.config.js 配置文件:

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

3. Jest 核心概念与语法

Jest 是 Facebook 开发的一个测试框架,它的设计理念是「零配置」——大多数情况下,你只需要写测试用例,Jest 会自动完成其余工作。

3.1 测试的基本结构

Jest 使用 testit 函数来定义测试用例,两者的功能完全相同,只是语义略有区别:test 更强调「测试什么」,it 更强调「它应该做什么」。

// math.test.js
function add(a, b) {
  return a + b;
}

function subtract(a, b) {
  return a - b;
}

describe('数学函数测试', () => {
  test('add 函数应该正确计算两个数的和', () => {
    expect(add(2, 3)).toBe(5);
  });

  test('subtract 函数应该正确计算两个数的差', () => {
    expect(subtract(5, 3)).toBe(2);
  });
});

在这个例子中,describe 用来将相关的测试用例分组,expect 用来断言某个值的实际结果,toBe 是最常用的匹配器,用于判断两个值是否严格相等。

3.2 常用匹配器

Jest 提供了丰富的匹配器来满足不同的断言需求:

test('各种匹配器示例', () => {
  const value = { name: 'React' };

  // 相等性测试
  expect(value).toEqual({ name: 'React' });  // 深度相等(推荐用于对象和数组)
  expect(value).toBe(value);  // 严格相等(引用相同)

  // 布尔值测试
  expect(null).toBeNull();  // 值是否为 null
  expect(undefined).toBeUndefined();  // 值是否为 undefined
  expect(true).toBeTruthy();  // 真值
  expect(0).toBeFalsy();  // 假值

  // 数值测试
  expect(10).toBeGreaterThan(5);  // 大于
  expect(10).toBeLessThanOrEqual(10);  // 小于等于
  expect(0.1 + 0.2).toBeCloseTo(0.3);  // 浮点数近似相等

  // 字符串测试
  expect('Hello React').toContain('React');  // 包含子串
  expect('Hello').toMatch(/^Hel/);  // 正则匹配

  // 数组测试
  expect([1, 2, 3]).toContain(2);  // 包含元素
  expect([1, 2, 3]).toHaveLength(3);  // 长度断言

  // 对象测试
  expect({ a: 1 }).toHaveProperty('a');  // 属性存在
});

3.3 异步测试

处理异步代码时,Jest 支持多种测试方式:

// async.test.js

// 方式一:使用 Promise
test('异步函数测试(Promise)', () => {
  return fetchData().then(data => {
    expect(data).toEqual({ success: true });
  });
});

// 方式二:使用 async/await
test('异步函数测试(async/await)', async () => {
  const data = await fetchData();
  expect(data).toEqual({ success: true });
});

// 方式三:使用 .resolves / .rejects
test('Promise 应该被解决', () => {
  return expect(fetchData()).resolves.toEqual({ success: true });
});

test('Promise 应该被拒绝', () => {
  return expect(fetchError()).rejects.toThrow('Error');
});

3.4 钩子函数与测试隔离

describe 块内部可以使用四个钩子函数来管理测试生命周期:

钩子函数 执行时机 典型用途
beforeAll 所有测试执行前运行一次 创建共享资源、数据库连接
beforeEach 每个测试执行前运行 重置状态、初始化数据
afterEach 每个测试执行后运行 清理临时文件、恢复设置
afterAll 所有测试执行后运行 断开连接、清理全局资源
describe('用户管理模块', () => {
  let testUser;

  beforeEach(() => {
    testUser = { id: 1, name: '测试用户' };
  });

  test('应该能获取用户信息', () => {
    const user = getUser(testUser.id);
    expect(user.name).toBe('测试用户');
  });

  test('应该能更新用户', () => {
    const updatedUser = updateUser(testUser.id, { name: '新名称' });
    expect(updatedUser.name).toBe('新名称');
    // 下个测试前会重置 testUser
  });
});

4. React Testing Library 入门

React Testing Library 的设计哲学与 Enzyme 等传统测试工具截然不同。它不关心组件的内部实现细节——不鼓励测试组件的 state、props 或方法调用——而是专注于测试组件的「公开接口」:用户能看到什么、能做什么。这种理念被称为「行为驱动测试」。

4.1 渲染组件

render 函数是 React Testing Library 的核心,它将 React 组件渲染到 DOM 中,并提供查询 API 来获取元素:

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

test('显示欢迎消息', () => {
  render(<Greeting name="张三" />);

  // 通过文本内容查找元素
  const element = screen.getByText('你好,张三!');
  expect(element).toBeInTheDocument();
});

4.2 查询 API 详解

React Testing Library 提供了多种查询方式来定位元素,它们在语义和实用性上有所不同:

查询类型 特点 失败时行为 推荐场景
getBy 找不到则抛出错误 立即失败 确认元素一定存在
getAllBy 返回所有匹配项 找不到则抛出错误 检查列表内容
queryBy 找不到返回 null 不抛错,返回 null 检查元素不存在
queryAllBy 返回所有匹配项 找不到返回空数组 检查多个元素
findBy 异步等待出现 超时则失败 等待异步加载的内容
findAllBy 异步等待所有匹配项 超时则失败 等待多个异步元素
import { render, screen } from '@testing-library/react';
import UserList from './UserList';

test('用户列表渲染测试', async () => {
  render(<UserList />);

  // 同步查询:元素必须立即存在
  const loading = screen.queryByText('加载中...');
  expect(loading).not.toBeInTheDocument();

  // 异步查询:等待数据加载完成
  const users = await screen.findAllByRole('listitem');
  expect(users).toHaveLength(3);
});

4.3 元素属性查询

除了文本内容,还可以通过多种方式查询元素:

test('各种查询方式', () => {
  render(
    <form>
      <label htmlFor="email">邮箱</label>
      <input 
        type="email" 
        id="email" 
        placeholder="请输入邮箱"
        data-testid="email-input"
      />
      <button type="submit">提交</button>
    </form>
  );

  // 按角色查找(最佳实践,推荐优先使用)
  const button = screen.getByRole('button', { name: /提交/i });

  // 按标签关联查找
  const emailInput = screen.getByLabelText('邮箱');

  // 按占位符查找
  const placeholderInput = screen.getByPlaceholderText('请输入邮箱');

  // 按 alt 文本查找(图片等)
  const image = screen.getByAltText('用户头像');

  // 按 data-testid 查找(最后手段)
  const testInput = screen.getByTestId('email-input');

  // 按显示文本查找(完整或部分)
  const text = screen.getByText('邮箱');
  const partialText = screen.getByText(/邮箱/);  // 正则匹配
});

4.4 用户交互模拟

@testing-library/user-event 是官方推荐的用户交互库,比原生事件更贴近真实用户行为:

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

test('表单提交测试', async () => {
  const user = userEvent.setup();  // v14+ 需要 setup
  render(<LoginForm />);

  // 输入文本
  await user.type(screen.getByLabelText(/用户名/i), 'admin');
  await user.type(screen.getByLabelText(/密码/i), '123456');

  // 点击复选框
  await user.click(screen.getByRole('checkbox', { name: /记住我/i }));

  // 点击按钮
  await user.click(screen.getByRole('button', { name: /登录/i }));

  // 验证提交后的结果
  await screen.findByText('登录成功');
});

常见交互操作的模拟方式:

操作 方法 说明
点击 user.click(element) 支持单击、双击、右键
悬停 user.hover(element) 触发 hover 事件
输入 user.type(element, text) 模拟键盘输入,可包含特殊键
清空 user.clear(element) 清空输入框内容
选择 user.selectOptions(element, value) 选择下拉选项
拖拽 user.dragAndDrop(source, target) 拖拽操作

5. 实战:一个完整组件测试示例

让我们通过一个完整的计数器组件来演示如何综合运用 Jest 和 React Testing Library:

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

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

  const increment = () => setCount(c => c + 1);
  const decrement = () => setCount(c => c - 1);
  const reset = () => setCount(initialValue);

  return (
    <div>
      <h1>计数器</h1>
      <p data-testid="count-display">当前计数:{count}</p>
      <button onClick={decrement} aria-label="减少">-</button>
      <button onClick={increment} aria-label="增加">+</button>
      <button onClick={reset} aria-label="重置">重置</button>
    </div>
  );
}

export default Counter;

对应的测试文件:

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

describe('Counter 组件测试', () => {
  test('应该正确渲染初始值', () => {
    render(<Counter initialValue={10} />);

    expect(screen.getByTestId('count-display')).toHaveTextContent('当前计数:10');
  });

  test('点击增加按钮应该使计数加 1', async () => {
    const user = userEvent.setup();
    render(<Counter initialValue={0} />);

    const incrementButton = screen.getByRole('button', { name: /增加/i });
    await user.click(incrementButton);
    await user.click(incrementButton);

    expect(screen.getByTestId('count-display')).toHaveTextContent('当前计数:2');
  });

  test('点击减少按钮应该使计数减 1', async () => {
    const user = userEvent.setup();
    render(<Counter initialValue={5} />);

    const decrementButton = screen.getByRole('button', { name: /减少/i });
    await user.click(decrementButton);

    expect(screen.getByTestId('count-display')).toHaveTextContent('当前计数:4');
  });

  test('点击重置按钮应该恢复到初始值', async () => {
    const user = userEvent.setup();
    render(<Counter initialValue={0} />);

    // 先增加几次
    const incrementButton = screen.getByRole('button', { name: /增加/i });
    await user.click(incrementButton);
    await user.click(incrementButton);
    await user.click(incrementButton);

    // 验证当前值
    expect(screen.getByTestId('count-display')).toHaveTextContent('当前计数:3');

    // 点击重置
    const resetButton = screen.getByRole('button', { name: /重置/i });
    await user.click(resetButton);

    // 验证重置成功
    expect(screen.getByTestId('count-display')).toHaveTextContent('当前计数:0');
  });

  test('测试边界情况:负数计数', async () => {
    const user = userEvent.setup();
    render(<Counter initialValue={0} />);

    const decrementButton = screen.getByRole('button', { name: /减少/i });
    await user.click(decrementButton);

    expect(screen.getByTestId('count-display')).toHaveTextContent('当前计数:-1');
  });
});

在这个测试文件中,我们覆盖了四个核心场景:初始渲染、递增操作、递减操作和重置功能。每个测试都遵循「Arrange-Act-Assert」模式:首先准备测试环境(Arrange),然后执行用户操作(Act),最后验证结果是否符合预期(Assert)。


6. 测试异步组件

现代应用中经常需要处理异步数据加载、API 调用等场景。React Testing Library 提供了 waitForwaitForElementToBeRemoved 等工具来帮助测试异步行为:

// UserProfile.jsx
import { useState, useEffect } from 'react';

function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    async function fetchUser() {
      setLoading(true);
      try {
        const response = await fetch(`/api/users/${userId}`);
        const data = await response.json();
        setUser(data);
      } catch (err) {
        setError(err);
      } finally {
        setLoading(false);
      }
    }
    
    fetchUser();
  }, [userId]);
  
  if (loading) return <p>加载中...</p>;
  if (error) return <p>加载失败</p>;
  if (!user) return <p>用户不存在</p>;
  
  return (
    <div>
      <h2>{user.name}</h2>
      <p>{user.email}</p>
    </div>
  );
}

export default UserProfile;
```

对应的测试文件:

```jsx
// UserProfile.test.jsx
import { render, screen, waitFor, waitForElementToBeRemoved } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import UserProfile from './UserProfile';

// Mock fetch API
beforeEach(() => {
  jest.spyOn(global, 'fetch').mockReset();
});

afterEach(() => {
  jest.restoreAllMocks();
});

describe('UserProfile 异步测试', () => {
  test('应该显示加载状态,然后显示用户信息', async () => {
    const mockUser = { name: '张三', email: 'zhangsan@example.com' };
    
    // 模拟 fetch 延迟返回
    global.fetch.mockImplementationOnce(() =>
      new Promise(resolve => 
        setTimeout(() => resolve({
          ok: true,
          json: () => Promise.resolve(mockUser)
        }), 100)
      )
    );
    
    render(<UserProfile userId={1} />);
    
    // 验证加载状态
    expect(screen.getByText('加载中...')).toBeInTheDocument();
    
    // 等待加载状态消失
    await waitForElementToBeRemoved(() => screen.queryByText('加载中...'));
    
    // 验证用户信息显示
    expect(screen.getByText('张三')).toBeInTheDocument();
    expect(screen.getByText('zhangsan@example.com')).toBeInTheDocument();
  });
  
  test('应该处理 API 错误', async () => {
    global.fetch.mockImplementationOnce(() =>
      Promise.reject(new Error('网络错误'))
    );
    
    render(<UserProfile userId={999} />);
    
    // 等待错误信息出现
    await screen.findByText('加载失败');
  });
  
  test('用户 ID 变化时应该重新获取数据', async () => {
    const user1 = { name: '用户1', email: 'user1@example.com' };
    const user2 = { name: '用户2', email: 'user2@example.com' };
    
    global.fetch
      .mockImplementationOnce(() => 
        Promise.resolve({ ok: true, json: () => Promise.resolve(user1) })
      )
      .mockImplementationOnce(() => 
        Promise.resolve({ ok: true, json: () => Promise.resolve(user2) })
      );
    
    const { rerender } = render(<UserProfile userId={1} />);
    
    // 等待第一个用户加载完成
    await screen.findByText('用户1');
    
    // 改变 userId,重新渲染
    rerender(<UserProfile userId={2} />);
    
    // 验证新用户信息
    await screen.findByText('用户2');
    expect(screen.queryByText('用户1')).not.toBeInTheDocument();
  });
});
```

---

## 7. Mock 技术详解

在单元测试中,我们经常需要隔离被测组件与外部依赖(如 API、第三方库等)。Jest 提供了强大的 Mock 功能来帮助我们完成这项工作。

### 7.1 函数 Mock

使用 `jest.fn()` 创建模拟函数,可以追踪函数调用情况:

```javascript
test('函数调用追踪', () => {
  const mockCallback = jest.fn();
  
  // 调用模拟函数
  ['a', 'b', 'c'].forEach(item => mockCallback(item));
  
  // 验证调用次数
  expect(mockCallback).toHaveBeenCalledTimes(3);
  
  // 验证调用参数
  expect(mockCallback).toHaveBeenCalledWith('a');
  expect(mockCallback).toHaveBeenCalledWith('b');
  expect(mockCallback).toHaveBeenCalledWith('c');
  
  // 获取所有调用记录
  console.log(mockCallback.mock.calls);
});
```

### 7.2 模块 Mock

当组件依赖外部模块时,可以使用 `jest.mock()` 进行整体 Mock:

```javascript
// api.js
export const fetchUser = (id) => 
  fetch(`/api/users/${id}`).then(res => res.json());

export const updateUser = (id, data) => 
  fetch(`/api/users/${id}`, {
    method: 'PUT',
    body: JSON.stringify(data)
  }).then(res => res.json());

测试文件:

// UserProfile.test.jsx
import { render, screen } from '@testing-library/react';
import UserProfile from './UserProfile';
import * as api from './api';

// Mock 整个模块
jest.mock('./api');

test('应该显示从 API 获取的用户数据', async () => {
  // 配置 Mock 函数的返回值
  api.fetchUser.mockResolvedValue({
    id: 1,
    name: '李四',
    email: 'lisi@example.com'
  });

  render(<UserProfile userId={1} />);

  await screen.findByText('李四');
  expect(screen.getByText('lisi@example.com')).toBeInTheDocument();

  // 验证 API 被正确调用
  expect(api.fetchUser).toHaveBeenCalledWith(1);
});

7.3 定时器 Mock

涉及 setTimeoutsetInterval 的代码需要特殊处理:

// delayedMessage.js
export function showDelayedMessage(message, delay) {
  return new Promise(resolve => {
    setTimeout(() => {
      resolve(message);
    }, delay);
  });
}

测试文件:

// delayedMessage.test.js
import { showDelayedMessage } from './delayedMessage';

test('定时器测试', () => {
  jest.useFakeTimers();  // 启用假定时器

  const promise = showDelayedMessage('Hello', 2000);

  // 快进时间
  jest.advanceTimersByTime(2000);

  // 等待 Promise 完成
  return expect(promise).resolves.toBe('Hello');
});

8. 测试最佳实践

在实际项目中,遵循良好的测试习惯能够让你的测试更可靠、更易维护:

保持测试专注且独立。每个测试用例应该只验证一个行为,避免在单个测试中检查多个不相关的功能点。这不仅使测试失败时的错误信息更清晰,也便于定位问题所在。更重要的是,所有测试必须能够以任意顺序独立运行,不能依赖其他测试的执行结果或副作用。

优先测试行为,而非实现。不要测试组件的内部 state、ref 或方法调用。用户的操作是基于可见的 UI 进行的,因此测试也应该基于用户视角编写。如果有一天你重构了组件的内部实现,只要行为不变,测试应该继续保持通过。

// ❌ 错误示例:测试实现细节
test('状态更新测试', () => {
  render(<Counter />);
  const button = screen.getByTestId('increment-button');
  fireEvent.click(button);
  expect(screen.getByTestId('counter-component').state.count).toBe(1);
});

// ✅ 正确示例:测试用户行为结果
test('点击按钮后计数应该增加', async () => {
  const user = userEvent.setup();
  render(<Counter />);
  await user.click(screen.getByRole('button', { name: '+' }));
  expect(screen.getByText('当前计数:1')).toBeInTheDocument();
});

为真实的用户交互编写测试。使用 userEvent 而非原始的 fireEvent,因为它更贴近真实的用户行为。userEvent 会触发一系列相关的事件(如 focus、input、change),而 fireEvent 只触发单个事件。

合理使用测试数据工厂。当需要创建相似的测试数据时,使用工厂函数来生成,避免代码重复:

// test-utils.js
export function createUser(overrides = {}) {
  return {
    id: 1,
    name: '测试用户',
    email: 'test@example.com',
    role: 'user',
    ...overrides
  };
}

// 测试文件中使用
test('应该正确显示管理员信息', () => {
  const admin = createUser({ name: '管理员', role: 'admin' });
  render(<UserCard user={admin} />);
  expect(screen.getByText('管理员')).toBeInTheDocument();
});

9. 代码覆盖率

代码覆盖率反映了测试对代码的覆盖程度。Jest 内置了覆盖率报告功能,使用 jest --coverage 命令即可生成。常见的覆盖率指标包括:

指标 含义 参考标准
语句覆盖率 每条语句是否被执行 80%+
分支覆盖率 每个条件分支是否都被测试 80%+
函数覆盖率 每个函数是否被调用 80%+
行覆盖率 代码每行是否被测试 80%+

需要注意的是,高覆盖率并不意味着高质量的测试。盲目追求 100% 覆盖率可能导致为无意义的代码编写测试,或者只测试简单的 happy path。更重要的是确保关键业务逻辑和用户交互流程被充分测试。


10. 常见问题与解决方案

测试找不到元素。首先确认元素是否在渲染后立即存在,如果是异步加载的内容,需要使用 findBy 或等待异步操作完成。其次检查元素是否被 display: nonevisibility: hidden 隐藏,这类元素对屏幕阅读器不可见,React Testing Library 默认也不会返回。

测试结果不稳定。这通常是由于测试之间的状态污染造成的。确保在 afterEach 钩子中清理副作用,使用 cleanup() 函数重置 DOM,并且避免在测试之间共享可变状态。

Mock 不起作用。确认 jest.mock() 调用位于文件顶部,在任何 imports 之前。检查 Mock 的实现是否正确返回了测试所需的数据格式,特别是处理 Promise 时要注意使用 mockResolvedValue 而非直接返回值。

性能问题。如果测试套件运行缓慢,考虑将测试文件拆分为更小的单元,使用 test.only 只运行当前正在开发的测试,并为慢速 I/O 操作添加 Mock。


掌握 Jest 和 React Testing Library 的组合使用,能够让你在保持高效开发节奏的同时,确保代码质量和应用的稳定性。测试不是负担,而是你重构代码时的安全网、添加新功能时的定心丸。从今天开始,为你的每一个 React 组件编写测试吧。

评论 (0)

暂无评论,快来抢沙发吧!

扫一扫,手机查看

扫描上方二维码,在手机上查看本文