跳过主要内容

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

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

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

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

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

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

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是你的最佳安全网

Unit tests 的价值在于它们能捕捉到你认为不会发生的错误。在 React 中,这通常意味着组件仍然渲染,但用户依赖的行为已经改变。一个禁用的按钮变成可点击的。一个加载状态永远不会清除。一个替代消息在重构后消失。那些失败在 code 中很小,但在生产环境中很昂贵。

React 测试发生了一个重要的变化时 React Testing Library 成为主流模型,测试行为而不是内部实现,使团队朝着测试用户行为而不是组件属性或状态的方向发展,正如 React Native 的测试指南在 React Native 测试概述中所述。这个转变很重要,因为 React code 一直在变化。钩子被移动。组件被拆分。上下文被引入。一个依赖内部结构的测试在健康的重构中会破裂。一个依赖可见行为的测试通常会幸免。

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

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

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

弱测试保护的不是正确的东西:

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

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

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

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

设置您的现代React测试环境

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

一个干净的工作区,显示着一个显示React单元测试code的code编辑器的计算机屏幕。

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

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

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

测试React组件的关键流程是简单的:在jsdom环境中渲染组件,使用选择器类似于 getByText 或 getByRole触发交互,断言 DOM 变化,如描述的那样 , 触发交互,断言 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 A practical Jest setup usually looks like this: 如果您的团队使用 SWC 代替 Babel,那也没问题。关键是要保持一致性。选择一个路径并在仓库中标准化它。如果您想找到更广泛的 JavaScript 测试约定参考资料,__CAPGO_KEEP_0__的

添加依赖于您的测试套件的设置文件

一个合适的 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 当元素出现时解析 等待后拒绝 断言异步加载的内容在 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 apps hide a lot of important behavior outside components. State transitions live in hooks. Validation and formatting live in helper functions. Data shaping often happens before anything renders. If you only test visible components, you’ll miss a large part of the code that can still break production behavior.

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

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

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

不需要

, React, 或 Testing Library。纯函数可以使用纯 Jest 在 Node 环境中测试。 jsdom示例:

测试应该非常简单:

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

hook 是一个很好的例子:

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套件在两个地方会崩溃。它们在依赖边界上崩溃,在时间上崩溃。

async测试和模拟是区分玩具测试套件和可信赖测试套件的界限。 一项分析将46.5%的测试不稳定性归因于环境或资源相关的问题,如异步定时 在这个React单元测试分析中 in 比较图表,展示了高级React测试技术,特别是关注模拟依赖项与异步测试模拟边界,而不是每层

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

模拟边界,而不是每层

使用以下规则集

For a component that fetches account data, mock the network client or API module. Don’t mock the hook, the child row component, the loading spinner, and three utility functions unless the test truly needs isolation at those seams.

HTTP客户端、分析、浏览器仅API、原生桥

  • 异步测试和模拟是区分一个玩具测试套件和一个你可以在发布前信任的测试套件的界限 46.5%的测试不稳定性被归因于环境或资源相关的问题,如异步定时
  • 模拟不稳定平台API: matchMedia, 定时器,Electron 预载接口,Capacitor 插件在 jsdom 中不可用时
  • 避免默认模拟自己的内部实现: 自定义钩子,简单的子元素,局部工具

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

对于想要示例和模式的团队,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() 当你需要验证 console.error,一个存储方法,或一个导出 API 的调用。
  • 使用 jest.mock() 当导入一个模块会否则触发 I/O,本地 code,或超出单元边界的行为时。

许多教程都忽略了现代 React 中的错误路径测试。错误边界、延迟状态变化和异步回退 UI 都应该有优先级的测试,而不是仅仅是“happy path”点击示例。如果子组件抛出异常,断言回退 UI。如果请求失败,断言可见的恢复状态。如果按钮在加载期间被禁用,断言也应该如此。这些是用户记住的 bug。

提高测试质量和策略

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

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

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

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

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

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

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

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

不要单元测试什么

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

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

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

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

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

快照在哪里有帮助,在哪里有害

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

适用于稳定简单输出的组件,使用它们来检查结构性的差异。对于交互式或动态组件,避免使用快照,因为它们会产生噪音。开发者会忽略它们,反射性地更新它们。

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

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

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

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

一个只在开发人员笔记本上运行的测试套件是一种建议,而不是控制。

测试集成到CI/CD管道中

简化中国
自动化测试流程图

每次 pull request 都应该触发相同的 gate

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

  • 每次 pull request 都需要运行
  • 从 lockfile 安装依赖
  • 每次都使用相同的测试命令
  • 测试失败时快速失败
  • 只有测试通过后才发布 artifact

这是应用团队的持续部署实践的核心 . 在发布之前建立信心,而不是在发布之后对于许多团队来说,一個簡單的 __CAPGO_KEEP_0__ Actions 工作流程就足够了

一个简单的GitHub Actions工作流程对于许多团队来说已经足够了。

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

这不是什么高大上的东西,反而这是一个很好的点。最强大的管道通常都是最不令人惊讶的管道。

为什么这个问题对 Capacitor 和 Electron 来说更为重要

跨平台的React应用相比浏览器端应用更容易出现发布风险,因为相同的UIcode可能在不同容器中发布,容器中有不同的运行时假设。

以下是一些管道如何帮助的例子:

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

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

一个实际的 GitHub Actions 工作流

A更成熟的管道通常将责任分配为:

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

对于正在向Capacitor或Electron应用推送实时更新的团队,这是发布工具的关键环节。其中一个选项是 Capgo发布到Capgo的PR(Pull Request)中:CapacitorJS和Electron应用的签名Web包发布,支持回滚和基于频道的发布控制。在实践中,这意味着您的React测试任务可以作为任何Web包被推送到测试或生产环境之前的第一个硬门槛。

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.


如果您的团队通过 Capacitor 或 Electron 发布 React,发布安全性取决于更多的绿色本地测试。 Capgo 让团队有一个控制发布签名Web更新、目标发布渠道、回滚坏包的方式,而不必等待商店审查,这与CI管道的要求相符,后者要求在部署之前必须通过单元测试。

实时更新的Capacitor应用

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

来自马丁的人性化支持

立即开始

最新博客文章

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