跳过主要内容

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

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

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

你在午餐前推送一个小的UI更改。看起来无害。按钮标签发生了变化,条件渲染变得简单,辅助钩子接收了一个新的branch。 pull请求干净,审查快,部署就出去了。

一个小时后,支持部门报告说,登录在一个平台上停止工作了。Web看起来正常。桌面shell有一个陈旧的渲染路径。移动端构建在异步状态变化后表现出不同。没有人注意到它,因为code有测试,但不是正确的测试,而且肯定没有可靠的系统来处理那些测试。

React 生产团队中进行单元测试的主要问题是什么?编写几个通过的测试并不是难事。构建一个仍然在重构、发布、修复和跨平台打包中保护您的测试套件才是难点。React 应用程序不会因为团队忘记如何调用而失败。它们会因为测试渐渐偏离实现细节、异步行为被掩盖、CI 将测试视为一个 checkbox 而不是发布门槛而失败。 render()现代单元测试 React 时,需要像一个安全系统一样工作。快速反馈本地。CI 中的确定性检查。清晰的边界,指出哪些内容属于单元测试,哪些不属于。即使 React 代码库通过浏览器、容器或 Electron 外壳发送,也更为重要。

Modern unit testing React works when it behaves like a safety system. Fast feedback locally. Deterministic checks in CI. Clear boundaries around what belongs in a unit test and what doesn’t. That matters even more when the same React codebase ships through browsers, Capacitor containers, or Electron shells.

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

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

单元测试赚取他们的价值时,它们捕捉到您认为无法发生的错误。 在React中,这通常意味着组件仍然渲染,但用户依赖的行为已经改变。 禁用的按钮变成可点击的。 加载状态永远不会清除。 替代消息在重构后消失。 这些故障在code中很小,但在生产中很昂贵。

React测试发生了一个重要的变化 当React Testing Library成为主流模型时,用于测试行为而不是内部结构,将团队推向测试用户行为的镜像而不是组件属性或状态,正如在 React Native测试概述中所反映的React Native的测试指导方针。 这个转变很重要,因为Reactcode不断地被重排。 钩子移动。 组件分裂。 上下文被引入。 与内部结构相关的测试在健康的重构中会中断。 与可见行为相关的测试通常会幸免。

一个单元测试应该保护什么

一个好的React单元测试保护一个小的契约:

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

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

  • 组件内部: 状态形状、私有方法、仅用于实现的属性
  • 框架机制: 是否 React 内部更新了一个钩子,准确地符合您的期望
  • 子项详细信息: 您不打算验证的嵌套组件拥有的标记

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

单元测试也位于更广泛的测试系统中。它们并不是试图证明整个应用程序从头到尾都能正常工作。它们是快速层,捕捉到回归问题之前,需要浏览器级别的测试或设备级别的验证通过。因此,它们是任何有意义的生产应用程序的自动化测试堆栈中的第一道防线 对于频繁发布的 React 团队,信心来自于这一分工。单元测试快速捕捉局部回归问题。集成测试验证接口。端到端测试确认关键路径。跳过单元层,下游的所有东西都必须承担太多的重量.

设置您的现代 React 测试环境

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

一个干净的工作区,显示 React 单元测试 __CAPGO_KEEP_0__ 在 __CAPGO_KEEP_1__ 编辑器中的计算机显示器

A clean workspace featuring a computer monitor displaying React unit testing code in a code editor.

__CAPGO_KEEP_0__

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

  • 一个测试运行器: Jest 仍然是常见的,特别是在旧的 React 代码库和企业 CI 堆栈中。
  • 一个浏览器样式的环境: jsdom 让组件测试渲染 DOM 输出。
  • 测试库工具: @testing-library/react 并且 @testing-library/jest-dom
  • 一个单独的设置入口点: 一个文件来注册匹配器和全局模拟

React 的测试指导原则强调的关键工作流程是简单的:在 jsdom 支持的环境中渲染组件,使用选择器如 getByTextgetByRole触发交互,断言 DOM 变化,正如所描述的那样 React 测试文档. 只有每台机器都运行相同的测试环境,这个工作流才会可靠。

一个实际的 Jest 配置通常如下所示:

// 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',
  },
};

如果您的团队使用 SWC 代替 Babel,那也没问题。重点不是转换器。重点是保持一致性。选择一个路径并在仓库中标准化它。如果您想获得更广泛的 JavaScript 测试约定参考文档,Capgo 的 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 模拟,您的 UI 依赖的,如 matchMedia, ResizeObserver,或 IntersectionObserver,如果您的组件库期望它们。

没有这个,开发者会自行修补全局变量。这会导致测试不一致,难以追踪的失败。一个人在本地运行通过,因为他们在文件中添加了一个手动模拟。CI失败,因为设置没有共享。

保持本地和CI行为一致

本地命令应该尽可能匹配CI命令。如果开发者在watch模式下运行时使用宽容的默认设置,但CI使用更严格的配置,会在合并后出现意外的失败。保持脚本明确:

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

一个简短的教程可以帮助新团队成员快速获得相同的基线:

最具影响力的设置选择是对默认值的纪律。将别名放在配置中。将环境模拟放在一个设置文件中。使用 jsdom 对于UI测试和可能的轻量环境进行纯实用程序时。每个测试所需的自定义行为越少,系统的可靠性就越高。

编写有意义的组件测试

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

React组件的单元测试的标准模式仍然是正确的: 渲染组件,使用用户中心的选择器查询UI,触发交互,断言DOM变化这可以将测试与实现细节,如状态或属性,隔离开来,如React测试指南所述 编写有意义的组件测试 . 最关键的是在应用模式时要适可而止。

模拟用户使用方式测试折叠面板

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

这些行为足够测试一些有用的场景:

  1. 初始渲染显示标题但不显示内容。
  2. 点击触发器显示内容。
  3. 再次点击会折叠它。
  4. 可访问性属性反映可见状态。

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

最好的组件测试看起来像一个你永远不想接收的bug报告。

根据意图选择查询

React Testing Library 提供了多种查询风格,但它们并不是完全可互换的。选择错误的风格会使测试变得嘈杂或误导。

查询类型 元素已找到 元素未找到 使用场景示例
getBy 立即返回元素 立即抛出错误 断言按钮或标题应该已经出现在屏幕上
queryBy 立即返回元素 返回 null 断言交互之前隐藏内容不应存在
findBy 当元素出现时,resolve 等待后拒绝 断言异步加载的内容在 fetch 或延迟更新后出现

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

  • 用于必须已经存在的东西 getBy 用于必须尚未存在的东西
  • 用于 UI 后来会改变 queryBy 如果测试从
  • for everything,通常意味着作者不确定组件何时更新。这种不确定性后来会导致不稳定性 findBy for things that must already exist.

for things that must not exist yet. findBy when the UI changes later.

A实用折叠面板示例

这里是一个代表性的组件:

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时打开”比“正确工作”更有用。 A few habits make component tests stronger:

如果组件难以通过 DOM 进行测试,这通常会揭示设计问题。可能它将状态隐藏在错误的位置。可能它缺乏语义标记。良好的测试通常会推动团队朝着更好的组件发展。

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

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

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

自定义钩子仍然需要 React 执行正确,所以使用 renderHook 并将状态更改的调用包装在 act().

一个小 useToggle 钩子是一个很好的例子:

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 或特性原语的产品团队,这个模式非常重要。钩子经常成为应用程序、设计系统或内部工具之间共享的接口。如果您正在设计可商用的行为资源,请参阅 针对制造商产品的钩子资源 可以帮助将钩子作为产品化的构建块而不是仅仅的实现细节。

纯粹的逻辑应该在测试中保持纯粹。

并不是所有的东西都需要 jsdom, React, 或 Testing Library。如果一个函数是纯粹的,使用纯 Jest 在 Node 环境中测试它。

示例:

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

这个测试应该非常简单:

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');
});

这里的赢利是速度和清晰度。当一个函数不需要一个渲染的树时,不要给它一个。React 特有的工具会增加开销。保持业务逻辑测试小、快、并且与它们验证的函数接近。

一个实用的分离方案很好:

  • 钩子: 使用 renderHook, act(),和 wrapper 提供者当需要时。
  • 工具: 使用简单的 Jest 和无 DOM。
  • 状态型横切逻辑: 将其拉入可测试的助手函数,当组件测试变得太过复杂时。

团队经常在组件测试中过度添加逻辑断言,这些断言属于更低层次的堆栈。将逻辑提取出来给了你两个好处。组件测试变得更加干净,逻辑测试变得更快。

精通高级技术:模拟和异步

大多数不靠谱的 React 套件在两个地方破裂。它们在依赖边界上破裂,在时间上破裂。

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

一个比较图表,展示了高级 React 测试技术,特别是模拟依赖项与异步测试的比较。

模拟边界,而不是每层

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

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

使用以下规则集

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

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

对于那些想要在runner API上提供示例和模式的团队,Capgo 测试教程 是一份实用的参考文档,尤其是在开发者上线时,尤其是那些只知道 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 除非你明确测试定时器行为并使用虚拟定时器,否则在测试中不应出现定时器

React 的测试生态系统还希望您尊重更新的语义 act() 测试库会处理很多这些问题,但如果您手动驱动状态或前进定时器,您仍然需要考虑更新刷新的时机

了解何时使用哪种模拟工具

不同模拟工具解决不同的问题

工具 最佳用途 常见错误
jest.fn() 独立虚拟回调或注入函数 使用它来替换一个模块时,只需一个简单的回调就足够了
jest.spyOn() 观察或覆盖一个真实对象或模块的方法 忘记恢复原始实现
jest.mock() 替换模块依赖于导入边界 默认模拟大型模块并丢失有意义的行为

示例帮助:

  • 当组件接受一个 jest.fn() 属性时 onSubmit 使用
  • 当您需要验证 jest.spyOn() 一个存储方法或一个导出__CAPGO_KEEP_0__调用 console.error, a storage method, or one exported API call.
  • 当导入模块将否则触发I/O、native __CAPGO_KEEP_0__或超出单元边界的行为时 jest.mock() when importing a module would otherwise hit I/O, native code, or behavior outside the unit boundary.

when a component takes an __CAPGO_KEEP_0__ prop.

提高测试质量和策略

许多团队仍然追求覆盖率,认为它与信心是一回事。然而,它们并不是。

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

质量测试的好处与高量测试套件的维护成本的对比图。

覆盖率是一张地图,而不是目标。

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

然而,它们并不是有用的,当它们推动开发者测试一些无关紧要的包装、静态的标记或一行代码的通过文件时。仅仅为了提高百分比而测试这些东西是不值得的。将覆盖率视为一个发现工具。如果没有测试的身份验证状态、计费操作、功能标志或更新提示,这是一个信号。如果没有测试的可视化图标组件,那通常不是一个问题。

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

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

什么不应该单元测试

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

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

  • 内部状态断言: 不要 isOpen 直接测试你可以测试面板是否打开。
  • 框架行为: 不要测试React是否调用了一个效果。测试效果改变的结果。
  • 第三方库内部: 测试您的集成与日期选择器或路由器,避免使用库的渲染逻辑。
  • 过度分解的单元测试: 如果您已经模拟了每个子组件和辅助函数,那么您可能不再测试有意义的行为。

坏的测试比缺失的测试更糟糕,因为它们阻止重构并仍然无法捕捉生产错误。

一个有用的启发式是边界所有权。测试您的code所拥有的内容。不要测试React、浏览器或成熟库已经拥有的内容,除非您的集成层改变了契约。

快照在哪里有帮助,在哪里会造成伤害

快照并不是无用的。它们只是容易被滥用。

在稳定、简单的输出组件中使用它们,使用结构性的差异来检查。对于交互式或高度动态的组件,避免使用它们,因为它们会产生噪音。开发人员会停止阅读它们并开始更新它们的习惯。

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

  • 对于条件渲染,断言关键文本的存在或不存在。
  • 对于视觉状态变化,断言重要的角色、标签或属性。
  • 对于错误和fallback,断言实际的消息或警告区域。

如果您的团队需要更广泛的质量流程,除了单元测试之外,一个坚实的伴侣是 应用质量保证流程 将测试、发布检查和回滚规划视为一个系统的思维方式是提高测试质量最快的方法。停止问 yourselves 有多少个测试。开始问哪些失败仍然可能到达用户。

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

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

当每个 pull 请求在干净环境中运行相同的检查,并且当这些检查失败时阻止合并时,测试集成才会成为操作的。

这听起来很明显,但许多团队仍然留下了关键的缺口。测试手动运行。覆盖报告是可选的。打包和发布任务在测试任务完成之前就开始了。这就是小 UI 回归进入更大的发布故障的方式。

将 React 自动化测试集成到 CI/CD 开发 pipeline 中的五步流程图。

一个 pull 请求应该触发相同的门控器

  • 为了让 React 单元测试像安全网一样起作用,CI 需要几个关键点:
  • 在每个 pull 请求中运行
  • 从锁文件中安装依赖项
  • 尽早失败于测试失败
  • 只有测试通过后才发布构建物

这是 应用团队的持续部署实践的核心

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

对于许多团队来说,一个简单的__CAPGO_KEEP_0__ Actions工作流程就足够了:

Why this matters more for Capacitor and Electron

为什么code 和 Electron 这些对此更有意义

跨平台的 React 应用程序比浏览器应用程序更容易出现发布风险,因为相同的 UI 组件通常在不同的容器中以不同的运行时假设发布。

  • Capacitor apps: code 应用程序:
  • Electron 应用程序: A 渲染组件可能依赖于预加载 API、窗口消息或桌面独特的状态,这些状态在普通浏览器测试中不会存在,除非故意模拟。
  • 共享发布列车: 如果您的部署过程没有严格控制发布,那么一个坏的捆绑包可能会影响多个目标。

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

一个实际的 GitHub Actions 工作流程

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

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

对于正在将实时更新推送到 Capacitor 或 Electron 应用程序的团队,这是发布工具的关键部分。 在该工作流程中,有一个选项是 Capgo,它发布了带有回滚支持和基于通道的发布控制的 CapacitorJS 和 Electron 应用程序的签名 Web 包。 在实践中,这意味着您的 React 测试任务可以作为任何 Web 包被推送到 staging 或生产交付之前的第一个硬门控。

操作规则很简单。 不要让发布基础设施弥补弱测试。 使用发布基础设施在可靠的测试已经过滤掉了坏变化之后。

可靠的测试系统会改变团队的行为。 工程师会因为更少的犹豫而合并。 审核人员会专注于边缘案例而不是手动重新运行基础案例。 发布经理会停止将每次部署视为赌博。 这是做好 React 单元测试的结果。


如果您的团队通过 Capacitor 或 Electron 发布 React,则发布安全取决于绿色本地测试以外的更多因素。 Capgo 给团队提供了一个控制发布签名 Web 更新、目标发布通道和回滚坏包的方式,而不需要等待商店审查,这与 CI pipeline 的要求相符,即在部署之前需要通过单元测试。

Capacitor应用的实时更新

当web层bug在live状态时,通过Capgo将修复推送,而不是等待几天的app store审批。用户在后台接收更新,而native层的更改仍在正常审批路径中。

来自Martin的人性化支持

立即开始

最新博客文章

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