跳过主要内容

React单元测试:实用端到端指南

从设置到CI/CD,学习React单元测试。 本指南涵盖Jest、RTL、钩子、异步code、模拟和最佳实践,用于构建强大的跨平台应用。

马丁·多纳迪厄

马丁·多纳迪厄

[目标语言]

Unit Testing React: A Practical End-to-End Guide

在午餐前,你推送了一些看似无害的UI变化。按钮标签发生了变化,条件渲染变得简单,辅助钩子接收了一个新的branch。pull request看起来很干净,review很快,部署也顺利了。

一个小时后,支持团队报告说,某个平台的登录功能已经失效了。Web端看起来正常,桌面shell有一个陈旧的渲染路径,移动端的构建在异步状态变化后表现出不同。没有人注意到这个问题,因为code有测试,但不是正确的测试,而且没有可靠的测试系统。

这是生产团队中使用React进行单元测试的主要问题。编写一些通过的测试并不难。然而,构建一个能够在重构、发布、热修复和跨平台打包中保护您的事实测试套件才是难点。React应用程序不会因为团队忘记如何调用而失败。 render()它们会失败,因为测试会朝着实现细节倾斜,异步行为会被掩盖,CI会把测试当作一个checkbox而不是一个发布门槛。

当代的单元测试React时,需要像一个安全系统一样工作。快速的本地反馈。CI中的确定性检查。清晰的边界,表明哪些内容应该在单元测试中包含,哪些不应该。即使同一个React代码库通过浏览器、Capacitor容器或Electron shell来部署,也需要这样做。

目录

为什么React单元测试是您的最佳安全网

当单元测试捕捉到你认为不会发生的错误时,它们就证明了自己的价值。在 React 中,这通常意味着组件仍然渲染,但用户依赖的行为已经发生了变化。一个禁用的按钮变成可点击的。一个加载状态永远不会消失。一个替代消息在重构后消失。这些故障在 code 中很小,但是在生产环境中很昂贵。

React 测试发生了一个重要的变化 React Testing Library 成为主流模型,测试行为而不是内部实现推动团队朝着测试用户行为而不是组件属性或状态而去的方向发展,这也反映在 React Native 的测试指导方针中 React Native 测试概述。这个转变很重要,因为 React code 一直在变化。钩子函数移动。组件分裂。上下文被引入。一个与内部结构相关的测试在健康的重构中会破裂。一个与可见行为相关的测试通常会幸免。

单元测试应该保护什么

一个好的 React 单元测试保护一个小的合同:

  • 渲染输出: 用户是否看到正确的文本、标签、状态或替代内容?
  • 交互行为: 点击、输入或切换是否正确地改变了 UI?
  • 边界处理: 组件在接收预期输入、缺失数据或错误路径时是否表现正常?

弱测试保护了错误的东西:

  • 组件内部: 状态形状、私有方法、仅用于实现的 props
  • 框架机制: React 是否内部更新了 hook 的方式与您期望的一样
  • 子组件细节: 您不打算验证的嵌套组件拥有的标记

实用规则: 如果您可以在不改变用户看到或做的事情的情况下重构组件,那么测试也应该不需要改变

单元测试也位于更广泛的测试系统中。它们并不是试图证明整个应用程序从头到尾都能正常工作。它们是快速层,捕获回归问题之前需要浏览器级别测试或设备级别验证通过。因此,它们是任何有意义的测试堆栈中的第一道防线 automated testing for production apps.

对于频繁发布的 React 团队,信心来自于劳动的分配。单元测试快速捕捉本地回归。集成测试验证接口。端到端测试确认关键路径。跳过单元层,下游的所有东西都必须承担太多的重量。

设置您的现代 React 测试环境

脆弱的测试环境会在您写下第一个断言之前产生不稳定的测试。许多开发人员会责怪 Jest、jsdom 或 React,实际问题是本地机器和 CI 上的配置不一致。解决方案是使环境变得乏味。乏味是好的。

一尘不染的工作环境,显示着计算机监视器上的 React 单元测试 code 在 code 编辑器中

从可预测的运行器和环境开始

对于一个现代的 React 应用,特别是使用 Vite 创建的应用,基础设置应该包括:

  • 测试运行器: Jest 仍然是常见的,尤其是在旧的 React 代码库和企业 CI 堆栈中。
  • 浏览器环境: jsdom 让组件测试渲染 DOM 输出。
  • Testing Library 工具: @testing-library/react and @testing-library/jest-dom
  • A single setup entrypoint: One file to register matchers and global mocks

React的测试指导强调的关键工作流程是简单的:在jsdom-backed环境中渲染组件,使用选择器如 getByText or getByRole,触发交互,assert DOM变化,正如在 React测试文档中描述的那样。

如果每台机器都运行相同的测试环境,那么这个工作流程才会可靠。

// jest.config.js
module.exports = {
  testEnvironment: 'jsdom',
  setupFilesAfterEnv: ['<rootDir>/src/setupTests.js'],
  moduleNameMapper: {
    '\\.(css|less|scss)$': 'identity-obj-proxy',
    '^@/(.*)$': '<rootDir>/src/$1',
  },
  transform: {
    '^.+\\.(js|jsx|ts|tsx)$': 'babel-jest',
  },
};

If your team uses SWC instead of Babel, that’s fine. The point isn’t the transformer. The point is consistency. Choose one path and standardize it in the repo. If you want a good companion reference for broader JavaScript testing conventions, Capgo’s 如果您的团队使用SWC而不是Babel,那也没问题。关键点不是转换器。关键点是一致性。选择一个路径并在仓库中标准化它。如果您想为更广泛的JavaScript测试约定找到一个好的伴侣参考,请参阅__CAPGO_KEEP_0__的 JavaScript单元测试指南

将你的测试套件依赖的设置文件添加进来

一个合适的 setupTests.js 可以节省大量重复的噪音:

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

Object.defineProperty(window, 'matchMedia', {
  writable: true,
  value: jest.fn().mockImplementation(query => ({
    matches: false,
    media: query,
    onchange: null,
    addListener: jest.fn(),
    removeListener: jest.fn(),
    addEventListener: jest.fn(),
    removeEventListener: jest.fn(),
    dispatchEvent: jest.fn(),
  })),
});

这个文件是你一次解决环境差异的地方,而不是在二十个测试文件里。添加API的模拟,例如 matchMedia, ResizeObserver, IntersectionObserver,

,

,

,

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

,

, jsdom ,

写出有意义的组件测试

组织并没有问题写测试。他们的问题是写出六个月后仍然有意义的测试。

React 组件的单元测试标准模式仍然是正确的: 渲染组件,使用用户中心的选择器查询 UI,触发交互,断言 DOM 变化这使得测试与实现细节,如状态或属性,保持距离,如 React 测试指南 所述。 关键在于适当地应用该模式。测试折叠面板就像用户使用它一样

从基本的组件开始。它渲染一个按钮和一个标题。面板内容始终隐藏。点击按钮会显示内容并更新可访问性状态。

这足够了,来写几个有用的测试: Accordion 初始渲染显示标题但不显示内容

测试初始渲染显示标题但不显示内容

  1. 测试点击按钮显示内容
  2. 点击触发器会显示内容。
  3. 再次点击会将其折叠。
  4. 可访问性属性反映了可见状态。

最后一点经常被忽略。如果您的组件使用 aria-expanded, aria-controls,或基于角色的结构,请验证它们。这些不是实现细节。它们是用户界面合同的一部分。

最佳组件测试看起来像一个你不希望收到的bug报告。

根据意图选择查询

React Testing Library提供了多种查询样式,但它们并不是可互换的。选择错误的类型会使测试变得噪音或误导。

查询类型元素已找到元素未找到使用场景示例
getBy__CAPGO_KEEP_0____CAPGO_KEEP_0__断言按钮或标题应该已经在屏幕上
queryBy__CAPGO_KEEP_0____CAPGO_KEEP_0__ null断言交互之前隐藏内容不应存在
findBy等待元素出现时resolve等待超时后reject断言异步加载内容在 fetch 或延迟更新后出现

一个简单的思维模型有助于理解:

  • 使用 getBy 适用于必须已经存在的东西
  • 使用 queryBy 用于尚未存在的东西。
  • 使用 findBy 当 UI 变化时。

如果测试以 findBy 为起始,通常意味着作者不确定组件何时更新。这一不确定性会在后期变成脆弱性。

一个实用的折叠面板示例

以下是一个代表性的组件:

function Accordion({ title, children }) {
  const [open, setOpen] = React.useState(false);

  return (
    <section>
      <button
        aria-expanded={open}
        aria-controls="accordion-panel"
        onClick={() => setOpen(prev => !prev)}
      >
        {title}
      </button>
      {open ? (
        <div id="accordion-panel">
          {children}
        </div>
      ) : null}
    </section>
  );
}

而这里的测试形状是值得保留的:

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

test('renders the accordion title and hides content initially', () => {
  render(<Accordion title="Shipping details">Delivery takes 3 days</Accordion>);

  expect(screen.getByRole('button', { name: /shipping details/i })).toBeInTheDocument();
  expect(screen.queryByText(/delivery takes 3 days/i)).not.toBeInTheDocument();
});

test('reveals content when the trigger is clicked', () => {
  render(<Accordion title="Shipping details">Delivery takes 3 days</Accordion>);

  fireEvent.click(screen.getByRole('button', { name: /shipping details/i }));

  expect(screen.getByText(/delivery takes 3 days/i)).toBeInTheDocument();
});

test('updates aria-expanded when opened', () => {
  render(<Accordion title="Shipping details">Delivery takes 3 days</Accordion>);

  const button = screen.getByRole('button', { name: /shipping details/i });
  expect(button).toHaveAttribute('aria-expanded', 'false');

  fireEvent.click(button);

  expect(button).toHaveAttribute('aria-expanded', 'true');
});

缺失的东西同样重要。没有内部状态的断言。没有 setOpen 被调用的检查。没有整个渲染树的快照。这些测试会增加维护,而不是增强信心。

一些习惯使组件测试更强大:

  • 建议使用基于角色的查询: 按钮、标题、对话框、警告和输入通常应该通过角色找到。
  • 每个测试都应保持狭窄: 每个用户可见的行为都应有一个单独的测试,这样失败时才会更容易阅读。
  • 测试应以结果命名: “更新aria-expanded属性时打开”比“正常工作”更有用。

如果一个组件通过DOM难以测试,那通常会暴露一个设计问题。可能它将状态隐藏在错误的位置。可能它缺乏语义标记。良好的测试通常会促使团队改进组件。

测试自定义钩子和应用逻辑

React应用程序将很多重要的行为隐藏在组件之外。状态转换存储在钩子中。验证和格式化存储在辅助函数中。数据处理通常发生在任何内容渲染之前。如果您只测试可见的组件,您将会错过大量可能仍然会破坏生产行为的code。

钩子需要一个React-aware的调试器

一个自定义钩子仍然需要React来执行,因此应使用 renderHook 并将状态更改的调用包装在其中 act().

A small useToggle hook 是一个很好的例子:

import { useState, useCallback } from 'react';

export function useToggle(initialValue = false) {
  const [value, setValue] = useState(initialValue);
  const toggle = useCallback(() => setValue(current => !current), []);
  return { value, toggle };
}

其测试应该专注于公共契约:

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

test('returns the initial value', () => {
  const { result } = renderHook(() => useToggle(true));
  expect(result.current.value).toBe(true);
});

test('toggles the value', () => {
  const { result } = renderHook(() => useToggle(false));

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

  expect(result.current.value).toBe(true);
});

因为钩子本身就是一个单元,这个测试是有用的。你不是在测试 React 内部实现。你是在验证钩子的外部行为。

对于构建可重用的 UI 或功能原语的产品团队,这个模式非常重要。钩子经常成为应用程序、设计系统或内部工具之间共享的接口。如果你正在设计可商用的行为,资源 可以帮助将钩子视为可重用的产品块,而不是仅仅是实现细节。 纯粹的逻辑应该保持纯粹的测试

不需要

React、或 Testing Library。纯函数应该在 Node 环境中使用纯 Jest 进行测试。 jsdom示例:

这个测试应该非常简单:

export function formatDisplayName(firstName: string, lastName: string) {
  return `${firstName.trim()} ${lastName.trim()}`.trim();
}

resources on hooks for makers' products

import { formatDisplayName } from './formatDisplayName';

test('joins and trims both names', () => {
  expect(formatDisplayName(' Ada ', ' Lovelace ')).toBe('Ada Lovelace');
});

test('handles a missing last name', () => {
  expect(formatDisplayName('Ada', '')).toBe('Ada');
});

The win here is speed and clarity. When a function doesn’t need a rendered tree, don’t give it one. React-specific tooling adds overhead. Keep business logic tests small, fast, and close to the function they verify.

A practical split works well:

  • Hooks: Use __CAPGO_KEEP_0__ and __CAPGO_KEEP_1__ when needed. renderHook, act()Utilities:
  • Use plain Jest and no DOM. Stateful cross-cutting logic:
  • Pull it into testable helpers when the component test starts doing too much. Teams often overstuff component tests with logic assertions that belong lower in the stack. Pulling that logic out gives you two benefits. The component test gets cleaner, and the logic test gets faster.

Mastering Advanced Techniques Mocking and Async

Most unreliable React suites break in two places. They break at dependency boundaries, and they break around time.

__CAPGO_KEEP_2__

async测试和模拟是区分玩具测试套件和可信赖的测试套件的界限。 一项分析认为 46.5% 的测试不稳定性是由于环境或资源相关问题,如异步定时这个React单元测试分析中。 在React应用中,这直接映射到状态转换、延迟渲染、网络驱动UI和测试而不是等待确定性的

。 比较表格,展示了Advanced React Testing Techniques,特别是Mocking Dependencies versus Asynchronous Testing.

模拟边界,而不是每层

写一个误导性的测试的最快方法是模拟一半组件树,然后断言自己的模拟工作了。

对于一个fetch账户数据的组件,模拟网络客户端或API模块。 不要模拟hook、子行组件、加载指示器和三种工具函数,除非测试真正需要在这些接口上进行隔离。

使用以下规则集:

  • 模拟外部服务: HTTP客户端、分析、浏览器仅API、原生桥接
  • 模拟不稳定平台API: matchMedia,定时器,Electron预载接口,Capacitor插件在jsdom中不可用时
  • 避免默认情况下模拟自己的内部实现: 自定义钩子,简单的子组件,局部工具

如果测试通过是因为所有困难部分都被替换成了假的,那么它并没有带来太多的发布信心。

对于想要示例和模式的runner API的团队,Capgo Jest类别 是一个实用的参考库,尤其是在React但不熟悉测试机制的开发者上线时。

异步测试在时间不明确时会失败

异步失败通常来自三个错误之一:

  1. 测试太早地断言。
  2. 测试使用任意定时器等待。
  3. 组件更新超过一次,但测试仅模拟一次转换。

稳定的异步测试通常具有以下形状:

test('shows user details after data loads', async () => {
  render(<UserProfile userId="42" />);
  expect(screen.getByText(/loading/i)).toBeInTheDocument();

  expect(await screen.findByText(/account owner/i)).toBeInTheDocument();
});

或者,当您需要等待特定条件时:

await waitFor(() => {
  expect(screen.getByRole('alert')).toBeInTheDocument();
});

使用 findBy 当一个元素的外观是您关心的事件时使用。使用 waitFor 当条件更广泛或状态无法用单个查询表达时。避免 setTimeout 在测试中使用

除非您正在显式测试定时器行为并使用虚拟定时器,否则在测试中 act() React 的测试生态系统还希望您尊重

更新的语义。测试库处理了很多,但如果您手动驱动状态或前进定时器,您仍然需要考虑更新刷新的时机。

知道哪种模拟工具适合

不同的模拟工具解决不同的问题:最佳实践常见错误
jest.fn()独立的假回调函数或注入函数仅使用回调函数时替换整个模块
jest.spyOn()观察或覆盖一个真实对象或模块的方法忘记恢复原始实现
jest.mock()在导入边界替换模块依赖默认模拟大型模块并丢失有意义的行为

示例帮助:

  • 寻找 jest.fn() 当一个组件接受一个 onSubmit 属性时。
  • 使用 jest.spyOn() 当您需要验证 console.error,一个存储方法,或者一个导出API调用的
  • 使用 jest.mock() 当导入模块会否则触发I/O,native code,或行为超出单元边界时

现代React中的错误路径测试是一个被很多教程忽视的高级领域。错误边界、延迟状态变化和异步fallback UI deserve首等测试,而不是仅仅“happy path”点击示例。如果子组件抛出异常,断言fallback UI。如果请求失败,断言可见的恢复状态。如果按钮在加载期间被禁用,断言也一样。这些是用户记住的bug。

提高测试质量和策略

很多团队仍然在追求覆盖率,好像它和自信心是一回事。它不是。

您可以达到覆盖率目标,却仍然忽略那些真正重要的回归。一个包含浅表断言、广泛快照和模拟内部的测试套件,会给人一种安全感,同时也会增加维护成本。

一个图表比较质量测试的好处与高量测试套件的维护成本。

覆盖率是地图,而不是目标

覆盖率报告在回答一个问题时是有用的:哪些关键路径还没有保护呢?

它们在推动开发者测试无关紧要的包装、静态标记或一行通过文件时就不太有用。将覆盖率视为一个发现工具。如果没有测试的身份验证状态、计费操作、功能标志或更新提示,这是一个信号。如果没有测试的可视化图标组件,这通常不是。

健康的代码审查问题很简单:这个测试是否减少了发布风险?

  • 是: 它验证了用户可见的行为在关键路径上。
  • 也许: 它保护了容易在重构过程中破坏的业务逻辑。
  • 否: 它断言实现细节或重复了另一个测试的值。

不要单元测试什么

许多React指南仍然没有花费足够的时间来阐述排除。这个缺口很重要,因为过度模拟和实现细节测试会创建脆弱的套件,即使用户体验仍然会破裂,正如BrowserStack在其关于 不要在React中单元测试什么.

跳过或严格限制这些模式:

  • 内部状态断言: 不要直接测试你可以测试面板是否打开的场景。 isOpen 框架行为:
  • 不要测试 React 调用了一个 effect。测试 effect 的结果是什么。 第三方库内部:
  • 测试你的日期选择器或路由器的集成,而不是库自身的渲染逻辑。 过度分割的单元:
  • 如果你已经 mock 了所有的子组件和辅助函数,那么你可能不再在测试有意义的行为。 坏的测试比缺少测试更糟糕,因为它们阻止重构并且仍然无法捕捉生产错误。

一个有用的启发式是边界所有权。测试你的 __CAPGO_KEEP_0__ 所有的是什么。除非你的集成层改变了契约,否则不要测试 React、浏览器或成熟库已经拥有的东西。

A useful heuristic is boundary ownership. Test what your code owns. Don’t test what React, the browser, or a mature library already owns unless your integration layer changes the contract.

__CAPGO_KEEP_0__

快照并非无用。它们只是容易滥用。

适当地使用快照,仅在组件稳定、简单输出且广泛结构差异有意义时使用。避免在交互或高度动态组件中使用,因为它们会产生噪音。开发者会忽略它们并开始自动更新它们。

通常存在更好的替代方案:

  • 对于条件渲染,断言关键文本的存在或不存在。
  • 对于视觉状态变化,断言影响用户体验的角色、标签或属性。
  • 对于错误和备选方案,断言实际的错误消息或警告区域。

如果您的团队需要更广泛的质量流程,除了单元测试之外,一个可靠的伴侣是 应用质量保证工作流 将测试、发布检查和回滚计划视为一个系统。这是改善测试质量最快的思维方式。停止询问您有多少个测试。开始询问哪些失败仍然可能触及用户。

将测试集成到跨平台CI/CD管道中

仅在开发者笔记本上运行的测试集是建议,而不是控制。

测试集成到CI/CD管道中时,测试集成到CI/CD管道中时,测试集成为可操作的,当每个拉取请求在干净环境中运行相同的检查并在检查失败时阻止合并时。听起来很明显,但许多团队仍然留下了关键的缺口。测试手动运行。覆盖报告是可选的。打包和发布任务在测试任务完成之前就开始了。这是如何让小UI回归进入更大的发布故障中。

在CI/CD开发管道中,集成React自动化测试的五步流程图。

在每次提交 pull 请求时,应该触发相同的门控机制。

为了让React的单元测试像一个安全网一样发挥作用,CI需要一些基本的东西:

  • 在每次拉取请求时运行
  • 从锁文件安装依赖项
  • 每次都使用相同的测试命令
  • 测试失败时快速失败
  • 发布测试通过后才发布构建产物

这是核心部分 持续部署实践(App 团队)在发布之前建立信心,而不是之后。

A simple GitHub Actions workflow is enough for many teams:

name: test

on:
  pull_request:
  push:
    branches:
      - main

jobs:
  react-tests:
    runs-on: ubuntu-latest

    steps:
      - name: Check out code
        uses: actions/checkout@v4

      - name: Set up Node
        uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: npm

      - name: Install dependencies
        run: npm ci

      - name: Run unit tests
        run: npm run test:ci

This isn’t fancy, and that’s the point. The strongest pipelines are usually the least surprising ones.

为 Capacitor 和 Electron 而言,这一点更为重要

跨平台的 React 应用程序由于 UI code 在不同容器中以不同的运行时假设发布,因此比浏览器应用程序更容易出现发布风险

几个例子展示了管道的帮助:

  • Capacitor 应用程序: Web code 在本地可能会通过,但在插件桥接、离线状态或应用程序生命周期边缘案例发生行为变化后可能会失败
  • Electron 应用程序: 渲染组件可能依赖于预载 API、窗口消息或桌面状态,这些状态在普通浏览器测试中不会存在,除非故意模拟
  • 共享发布列车: 如果您的部署过程没有严格控制发布,一个坏的包裹可能会影响多个目标

因此,单元测试应该在打包作业之前运行,打包作业应该在发布作业之前运行。每个阶段都减少了风险。单元测试可以快速捕捉本地回归。平台打包验证环境假设。手动批准或分阶段发布处理最终发布的信心。

实用的 GitHub Actions 工作流

一个成熟的管道通常将责任分开:

  1. 测试任务: 快速单元和钩子测试
  2. 构建任务: 只有测试通过后才进行生产构建
  3. 包任务: Capacitor 同步、Electron 打包或 artifact 打包
  4. 发布任务: 只从已批准的分支或标签发布

对于正在将实时更新推送到 Capacitor 或 Electron 应用程序的团队,这是发布工具的关键所在。其中一个选项是在该工作流中 Capgo,

The operational rule is straightforward. Don’t let release infrastructure compensate for weak tests. Use release infrastructure after reliable tests have already filtered out bad changes.

A dependable testing system changes team behavior. Engineers merge with less hesitation. Reviewers focus on edge cases instead of re-running basics manually. Release managers stop treating every deploy like a gamble. That’s the outcome of doing unit testing React well.


If your team ships React through Capacitor or Electron, release safety depends on more than green local tests. Capgo gives teams a controlled way to publish signed web updates, target rollout channels, and roll back bad bundles without waiting on store review, which fits naturally behind a CI pipeline that already requires passing unit tests before deployment.

实时更新Capacitor应用

当web层bug处于活跃状态时,通过Capgo将修复推送给用户,而不是等待几天的应用商店审批。用户在后台接收更新,而原生变化仍然在正常审批路径中。

立即开始

最新博客

Capgo为您提供创建真正专业的移动应用所需的最佳见解。