跳过主要内容

Unit Testing React: A实用端到端指南

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

Martin Donadieu

Martin Donadieu

内容营销人员

Unit Testing React: A实用端到端指南

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

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

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

现代单元测试 React 时,它会像一个安全系统一样工作。快速反馈本地。CI 中的确定性检查。清晰的边界,表明哪些属于单元测试,哪些不属于。即使在同一个 React 代码库通过浏览器、Capacitor 容器或 Electron 外壳发送时,这也很重要。

目录

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

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

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

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

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

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

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

  • 组件内部: 状态形状、私有方法、仅用于实现的属性
  • 框架机制: Whether React internally updated a hook in the exact way you expected
  • Child details: Markup owned by nested components you don’t mean to verify here

Practical rule: If you can refactor the component without changing what the user sees or does, the test shouldn’t need to change either.

Unit tests also sit in a broader testing system. They’re not trying to prove the whole app works end to end. They’re the fast layer that catches regressions before you need a browser-level test or a device-level validation pass. That’s why they’re the first line of defense in any sensible stack of automated testing for production apps For React teams shipping often, confidence comes from this division of labor. Unit tests catch local regressions quickly. Integration tests verify the seams. End-to-end tests confirm the critical paths. Skip the unit layer, and everything slower downstream has to carry too much weight..

Setting Up Your Modern React Testing Environment

A fragile test environment creates flaky tests before you’ve written a single assertion. Many developers blame Jest, jsdom, or React when the underlying problem is inconsistent configuration across local machines and CI. The fix is to make the environment boring. Boring is good here.

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

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

在 React 开发团队中,快速交付的信心来自于这种劳动的分工。单元测试快速捕获局部回归。集成测试验证接口。端到端测试确认关键路径。跳过单元层,下游的所有测试都需要承担太大的压力。

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

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

React的测试指导强调的关键工作流程是简单的:在jsdom-backed环境中渲染组件,使用选择器如 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',
  },
};

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 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 for UI测试和可能的轻量环境进行纯实用程序时。每个测试需要的自定义行为越少,系统的可靠性就越高。

编写有意义的组件测试

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

React组件的标准单元测试模式仍然是正确的: 渲染组件,使用用户中心的选择器查询UI,触发交互,断言DOM变化,这可以将测试与实现细节,如状态或属性,隔离开来,正如在 React测试指南中描述的那样. 使用该模式时,关键是要恰到好处地应用它。

像用户一样测试折叠面板

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

这些行为足够测试出几个有用的功能:

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

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

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

根据意图选择查询

React Testing Library 给你几个查询样式,但它们并不是可以互换的。选择错误的样式会让测试变得嘈杂或误导。

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

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

  • 用于那些必须已经存在的东西 getBy 用于那些必须不存在的东西
  • 用于当 UI 在后期发生变化时 queryBy 如果测试以
  • for everything 开始,通常意味着作者不确定组件何时更新。这一不确定性会在后期变成脆弱性。 findBy __CAPGO_KEEP_0__

__CAPGO_KEEP_1__ findBy __CAPGO_KEEP_2__

实用折叠面板示例

这是一个典型的组件:

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 几个习惯使组件测试更强大:

优先使用基于角色的查询:

  • 按钮、标题、对话框、警告和输入通常应该通过角色来查找。 每个测试都应保持狭窄:
  • 一个用户可见的行为一个测试,失败时可读性更好。 测试命名为结果:
  • Name tests after outcomes: 当打开时更新aria-expanded

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

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

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

钩子需要一个对React有感知的调试器

一个自定义钩子仍然需要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() 忘记恢复原始实现 __CAPGO_KEEP_0__
jest.mock() 替换模块依赖于导入边界 模拟大型模块默认情况下会丢失有意义的行为

示例帮助:

  • 当一个组件接受一个 jest.fn() 属性时, onSubmit 使用
  • 当您需要验证一个存储方法或一个导出__CAPGO_KEEP_0__的调用时 jest.spyOn() 使用 console.error当导入一个模块会否则触发I/O、原生API或超出单元边界的行为时
  • 现代React中的一个高级领域,许多指南都忽略了的是错误路径测试。错误边界、延迟状态变化和异步fallback UI deserve first-class tests,不仅仅是“happy path”点击示例。如果一个子组件抛出异常,断言fallback UI。如果一个请求失败,断言可见的恢复状态。如果一个按钮在加载期间被禁用,断言也一样。那些是用户记住的bug。 jest.mock() when importing a module would otherwise hit I/O, native code, or behavior outside the unit boundary.

属性时,

提高测试质量和策略

很多团队仍然追求覆盖率像它是一样的东西和信心。它不是。

你可以击中一个覆盖率目标并仍然错过那些重要的回归。一个包含浅表断言、广泛快照和模拟内部的测试套件会创建安全的外观,同时也增加了维护成本。

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

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

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

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

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

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

什么不应该单元测试

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

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

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

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

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

快照的帮助和伤害

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

适当使用它们,特别是对于具有稳定、简单输出的组件,使用它们来检查广泛的结构差异。避免使用它们来检查交互式或高度动态的组件,因为它们会产生噪音。开发人员会停止阅读它们并开始更新它们的反应性。

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

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

If your team needs a broader quality process beyond unit tests, a solid companion is an 应用质量保障工作流 that treats tests, release checks, and rollback planning as one system. That’s the mindset shift that improves test quality fastest. Stop asking how many tests you have. Start asking which failures could still reach users.

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

A test suite that only runs on a developer laptop is a suggestion, not a control.

测试集成

The suite becomes operational when every pull request runs the same checks in a clean environment and blocks merges when those checks fail. That sounds obvious, but many teams still leave critical gaps. Tests run manually. Coverage reports are optional. Packaging and release jobs start before test jobs have finished. That’s how small UI regressions slip into bigger release failures.

CI/CD管道中的React自动化测试集成

A five-step flowchart illustrating the process of integrating React automated tests into a CI/CD development pipeline.

  • 每次pull请求都应该触发相同的门控
  • For unit testing React to act like a safety net, CI needs a few essentials:
  • 每次pull请求都应该运行
  • 快速失败测试失败
  • 只有测试通过后才发布构建物

这是 应用团队的持续部署实践的核心在发布前建立信心,而不是在发布后

对于许多团队来说,只需简单的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 应用程序由于 UI code 在不同容器中以不同的运行时假设进行部署而面临更高的发布风险

以下是一些例子,展示了管道如何发挥作用

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

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

实用的 GitHub Actions 工作流

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

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

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

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

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


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

Capacitor实时更新

当web层bug出现时,通过Capgo发布修复,而不是等待几天的app store审批。用户在后台接收更新,而native变化仍然在正常的审批路径中

立即开始

博客最新文章

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