Skip to main content

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

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

Martin Donadieu

Martin Donadieu

内容营销总监

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

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

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

That’s the main problem with unit testing React in production teams. Writing a few passing tests isn’t hard. Building a suite that still protects you during refactors, release trains, hotfixes, and cross-platform packaging is the hard part. React apps don’t fail because a team forgot how to call render(). They fail because tests drift toward implementation details, async behavior gets papered over, and CI treats testing like a checkbox instead of a release gate.

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中,这通常意味着组件仍然渲染,但用户依赖的行为已经改变。禁用的按钮变成可点击的。加载状态永远不会清除。fallback消息在重构后消失。这些故障在code中很小,但在生产环境中很昂贵。

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

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

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

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

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

  • 组件内部: 状态形状、私有方法、仅用于实现的props
  • 框架机制: 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 开发团队中,频繁发布的信心来自于劳动的分配。单元测试快速捕获局部回归。集成测试验证接口。端到端测试确认关键路径。跳过单元层,下游的所有东西都必须承担太多的重量。

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

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

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’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 对于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 如果测试以
  • 用于所有内容时,它通常意味着作者不确定组件何时更新。这种不确定性后来会导致不稳定性。 findBy __CAPGO_KEEP_0__

__CAPGO_KEEP_0__ findBy __CAPGO_KEEP_0__

一个实用的折叠面板示例

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

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 在测试中,rendered树的整个快照并没有被调用。那些测试会增加维护工作,而不是增强信心。

让组件测试更强大的是几个习惯:

  • 基于角色的查询: 在通常情况下,按钮、标题、对话框、警告框和输入框应该通过角色来找到。
  • 保持每个测试单独专注: 每个测试只包含一个用户可见的行为,保持失败信息清晰。
  • 根据测试结果命名测试 “更新 aria-expanded 时打开”比“正常工作”更有用。

如果一个组件难以通过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提供了相关信息 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 除非你正在测试定时器行为并使用虚拟定时器,否则在测试中不应发生更新。

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

知道哪种模拟工具应该使用

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

工具 最佳用途 常见错误
jest.fn() 独立虚拟回调或注入函数 使用它来替换一个模块时,只需一个简单的回调就足够了
jest.spyOn() 观察或覆盖一个真实对象或模块的方法 忘记恢复原始实现
jest.mock() Replace a module dependency at import boundary Mocking large modules by default and losing meaningful behavior

Examples help:

  • Reach for jest.fn() when a component takes an onSubmit prop.
  • Use jest.spyOn() when you need to verify console.error, a storage method, or one exported API call.
  • Use jest.mock() when importing a module would otherwise hit I/O, native code, or behavior outside the unit boundary.

One advanced area many guides underserve is error-path testing in modern React. Error boundaries, delayed state changes, and async fallback UIs deserve first-class tests, not just the “happy path” click example. If a child throws, assert the fallback UI. If a request fails, assert the visible recovery state. If a button is disabled during loading, assert that too. Those are the bugs users remember.

提高测试质量和策略

很多团队仍然追求覆盖率,认为它和信心是一回事。它不是。

你可以达到覆盖率目标,却仍然会错过关键的回归测试。一个包含浅度断言、广泛的快照和模拟内部逻辑的测试套件,会给人一种安全感,同时也会增加维护成本。

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

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

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

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

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

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

什么不应该单元测试

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

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

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

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

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

快照的帮助和伤害

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

谨慎使用快照来测试具有稳定、简单输出的组件,特别是当一个广泛的结构差异是有意义的时。避免使用它们来测试交互式或高度动态的组件,因为它们会产生噪音。开发人员会停止阅读它们并开始更新它们的反应性.

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

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

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

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

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

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

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

一个五步流程图,说明如何将React自动化测试集成到CI/CD开发管道中。

拉取请求应该触发相同的门控器

  • 为了让React的单元测试像安全网一样发挥作用,CI需要几个必备的东西:
  • 在每个拉取请求上运行
  • 从锁文件中安装依赖项
  • 测试失败时快速失败
  • 只有测试通过后才发布构建产物

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

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

Cross-platform React apps carry more release risk than browser-only apps because the same UI code often ships in different containers with different runtime assumptions.

为什么__CAPGO_KEEP_0__ 和 Electron 对此更为重要

  • 跨平台的 React 应用程序比浏览器应用程序更容易出问题,因为相同的 UI Capacitor 在不同的容器中可能会有不同的运行时假设 Web code may pass locally but fail when a plugin bridge, offline state, or app lifecycle edge case changes behavior after packaging.
  • 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 一致,该 pipeline 已经要求在部署之前通过单元测试。

Capacitor实时更新

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

立即开始

最新博客文章

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