React 组件测试实战:用 Jest 与 React Testing Library 编写 UI 测试(The Odin Project 课程精讲)
2026/9/15 13:20:19 网站建设 项目流程

React 组件测试实战:用 Jest 与 React Testing Library 编写 UI 测试(The Odin Project 课程精讲)

【免费下载链接】curriculumThe open curriculum for learning web development项目地址: https://gitcode.com/GitHub_Trending/cu/curriculum

导读

本篇技术指南围绕 The Odin Project 开源课程(curriculum 仓库)中的 React 测试入门课程展开,系统讲解在 React 应用中引入 Jest 与 React Testing Library 的完整流程:从测试环境所需的依赖包、首次查询(render / screen / getByRole),到用userEvent模拟真实用户点击,再到快照测试的原理、优点与陷阱。学完本篇,你将能够独立为一个 React 组件搭建测试环境、编写可维护的 UI 断言,并准确判断"何时该用快照、何时不该用快照"。

本篇文章的主体为旧版课程文档 archive/javascript/react_js/react_testing_part_one.md,并以仓库中同主题的新版课程 react/react_testing/introduction_to_react_testing.md 及第二部分 archive/javascript/react_js/react_testing_part_two.md 作为纵深补充,帮助你同时理解 Jest 与 Vitest 两套主流工具链下的 React 测试写法。

为什么需要 UI 测试

在进入具体 API 之前,先明确一个前提:之前的课程(例如 javascript/testing_javascript/testing_basics.md)已经介绍了 Jest 与测试驱动开发(TDD)的基础。但当时所写的测试大多只覆盖纯 JavaScript 逻辑——比如战舰(Battleship)游戏的核心规则——并没有真正把 DOM 渲染出来。

正如仓库新版课程 react/react_testing/introduction_to_react_testing.md 所指出的:底层逻辑测试通过,并不代表 UI 真的显示了预期内容,也不代表用户能按预期与页面交互。你可能把某个状态结构从对象改成了数组,结果列表不再显示预期的文字;也可能在组件里加了一个条件分支,导致拖拽卡片的最后几个放置目标失效。这些 bug 只有把 DOM 纳入测试范围才能暴露出来。

UI 测试的价值正在于此:它验证"网页是否包含预期内容、行为是否符合预期",并在需求不再被满足时第一时间发出告警。随着项目变复杂,UI 测试(连同非 UI 测试)的价值只会越来越大。

测试一个 React 应用需要哪些包

课程原文档在 "Setting Up" 一节明确列出了在测试文件中需要引入的依赖:

import React from "react"; import { ... } from "@testing-library/react"; import "@testing-library/jest-dom"; // optional import userEvent from "@testing-library/user-event"; import TestComponent from "path-to-test-component";

各包的职责如下:

  • @testing-library/react:提供核心 API,最常用的是render函数——把组件渲染到内存 DOM 中供测试断言使用。
  • @testing-library/jest-dom:提供一组便捷的自定义匹配器(断言函数),例如toBeInTheDocument。Jest 本身已经内置大量匹配器,因此这个包并非强制使用,但能显著提升断言的可读性。
  • @testing-library/user-event:提供userEventAPI,用于模拟用户与网页的交互(点击、键入、聚焦等)。其备选方案是从@testing-library/react导入的fireEventAPI。课程原文档特别强调:fireEventuserEvent的劣化替代品,实践中应始终优先使用userEvent
  • jest无需显式导入:Jest 会自动检测*.test.js*.test.jsx后缀的测试文件。

好消息是:如果你使用create-react-app初始化 React 项目,上述所有包都已预装,package.json中的测试脚本也已预先配置好,直接npm test即可。

新旧课程工具链对比:Jest 与 Vitest

值得注意的是,仓库中的课程本身也在演进。旧版课程(本篇主体)基于Jest,而新版课程 react/react_testing/introduction_to_react_testing.md 在引入 Vite 后切换到了Vitest(与 Vite 深度集成的测试运行器)。两者在测试写法上高度同构:

  • 旧版:jest.fn()describe/it/expect来自 Jest 全局环境;
  • 新版:vi.fn()describe/it/expect需要从vitest显式导入。

新课程还强调:即使你在vite.config.js中设置了globals: true,ESLint 仍可能不识别这些全局变量,最直接的解决办法是改为在测试文件中显式导入所需全局变量,此时甚至可以省略globals: true。此外,新课程的安装命令明确为:

npm install @testing-library/user-event --save-dev

并且其运行环境依赖jsdom——它在内存中模拟 DOM(含事件系统),但并不真正进行浏览器式的视觉布局,这让测试既能解析 DOM 内容又足够轻量。

第一次查询:render 与 screen

掌握了依赖包之后,先写第一个测试。课程原文档给出了一个最简组件及其测试:

// App.js import React from "react"; const App = () => <h1>Our First Test</h1>; export default App;
// App.test.js import React from "react"; import { render, screen } from "@testing-library/react"; import App from "./App"; describe("App component", () => { it("renders correct heading", () => { render(<App />); expect(screen.getByRole("heading").textContent).toMatch(/our first test/i); }); });

在终端执行npm test App.test.js,测试即可通过。拆解这段代码:

  • render(<App />):把组件渲染到测试环境中。render返回一个对象,你可以通过解构获取其中的方法(例如container)。更推荐的做法是使用下面要讲的screen
  • screen.getByRole("heading"):按 ARIA 角色查询标题元素。getByRole只是 React Testing Library 十几种查询方法之一。
  • toMatch(/our first test/i):正则表达式配合i标志实现大小写不敏感的文本匹配,这正是课程新版文档 react/react_testing/introduction_to_react_testing.md 中强调的写法。

查询的三种类型:getBy / queryBy / findBy

React Testing Library 的查询方法按返回行为分为三类:

  • getBy*:找不到元素时直接抛出错误,用于断言"元素必然存在";
  • queryBy*:找不到时返回null,适合断言"元素不存在";
  • findBy*:异步查询,返回一个 Promise,适合等待元素在异步渲染后出现。

为什么 ByRole 查询是首选

课程原文档特别强调:ByRole系列方法是查询的首选,尤其是配合name选项使用。例如,上面的断言可以增强特异性:

getByRole("heading", { name: "Our First Test" })

ByRole查询的价值在于:它基于元素的语义角色而非实现细节(如 class 名),因此无论用户通过鼠标、键盘还是辅助技术(屏幕阅读器等)导航页面,查询到的都是同一个可访问的 UI 结构。换句话说,按角色查询天然保证了无障碍性。如果内置查询方法都不够用,React Testing Library 还提供了data-testid兜底方案(ByTestId查询),但应作为最后手段。

模拟用户事件:userEvent

用户与网页的交互方式多种多样。虽然真实用户反馈不可替代,但通过测试我们仍能为组件建立相当程度的信心。课程原文档给出了一个点击按钮改变标题的组件:

// App.js import React, { useState } from "react"; const App = () => { const [heading, setHeading] = useState("Magnificent Monkeys"); const clickHandler = () => { setHeading("Radical Rhinos"); }; return ( <> <button type="button" onClick={clickHandler}> Click Me </button> <h1>{heading}</h1> </> ); }; export default App;

对应的测试套件如下:

// App.test.js import React from "react"; import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import App from "./App"; describe("App component", () => { it("renders magnificent monkeys", () => { // since screen does not have the container property, we'll destructure render to obtain container for this test const { container } = render(<App />); expect(container).toMatchSnapshot(); }); it("renders radical rhinos after button click", async () => { const user = userEvent.setup(); render(<App />); const button = screen.getByRole("button", { name: "Click Me" }); await user.click(button); expect(screen.getByRole("heading").textContent).toMatch(/radical rhinos/i); }); });

这个测试套件透露了三个关键实践:

  1. 优先使用screen而非解构renderscreen对象集中了所有查询方法,不必每次渲染都维护解构列表。唯一的例外是本例第一个测试——screen没有container属性,因此解构render来取得container用于快照断言。
  2. 模拟点击后再断言状态变化:第二个测试先render(<App />),再用screen.getByRole("button", { name: "Click Me" })精确定位按钮,await user.click(button)触发点击,最后断言标题文本变为radical rhinos
  3. 每个测试都要重新render:React Testing Library 会在每个测试结束后自动卸载已渲染的组件,因此必须为每个测试单独渲染。当某个组件的测试较多时,beforeEach(Jest 生命周期钩子)可以派上用场。

userEvent v14 的异步 API 迁移

注意第二个测试的回调函数是async的,原因在于user.click()需要await。课程原文档明确说明:自 user-event 14.0.0 起,user-event API 已更新为异步,以模拟真实用户交互的异步本质。而一些旧教程或资料可能仍在演示同步写法:

// This is the old approach of using userEvent. it("renders radical rhinos after button click", () => { render(<App />); const button = screen.getByRole("button", { name: "Click Me" }); userEvent.click(button); expect(screen.getByRole("heading").textContent).toMatch(/radical rhinos/i); });

这种同步写法依然受支持(setup()在内部被隐式触发),目的是平滑 v13 到 v14 的迁移。但在新代码中应始终使用userEvent.setup()+await的异步模式。

快照测试:原理、优点与陷阱

快照文件长什么样

第二个测试运行后,Jest 会自动生成一个关联的快照文件(App.test.js.snap),内容如下:

// App.test.js.snap // Jest Snapshot v1 exports[`magnificent monkeys render 1`] = ` <div> <button type="button" > Click Me </button> <h1> Magnificent Monkeys </h1> </div> `;

快照本质上是组件渲染结果的 HTML 表示。此后每次运行该断言,Jest 都会把当前渲染结果与快照文件比对:只要App发生哪怕一丁点变化,测试就会失败。在新版课程的 Vitest 语境下,快照头部则显示为Vitest Snapshot v1(见 react/react_testing/introduction_to_react_testing.md),原理完全一致。

快照测试的优点

课程原文档列出了快照的两大优势:

  • 快且易写:一条toMatchSnapshot断言就替代了多行断言代码。例如上面的例子中,我们不需要分别断言按钮存在、标题存在——一次快照比对全部覆盖。
  • 阻止意外变更悄悄溜进代码:任何未被察觉的渲染结构变化都会立即让测试失败,起到变更哨兵的作用。

快照测试的缺点

课程原文档用两个关键词概括了快照的局限:

  • 误报(false positives):快照通过并不能证明组件"正确"——它只证明"渲染结果和上次一致"。一个真实存在的 bug 可能因为被"冻结"在快照里而长期逃过检测。过度依赖快照会让开发者对代码产生超出实际的安全感。
  • 假阴性(false negatives):快照对任何细微改动都过于敏感——修正一个标点符号会失败,把一个 HTML 标签换成更具语义的标签也会失败。长此以往,开发者可能对整个测试套件失去信任。

结论是:快照本身并不坏,它自有用途;关键在于理解何时该快照、何时不该快照。合理的做法是把快照用于"防止意外回归"的稳定区域,而把对业务正确性的验证交给显式的行为断言。

从测试组织到进阶方向

保持每个测试独立、可读

课程原文档提醒:在组件有大量测试时,beforeEach可以避免重复的渲染代码。第二部分 archive/javascript/react_js/react_testing_part_two.md 进一步补充了组织建议:尽量把测试的全部准备步骤放在同一个it块内(除非测试文件很长、准备代码多达几十行),这消除了为了理解某个测试而在整个文件里搜索上下文的负担,也让后续审查变更更容易,同时降低测试之间状态"泄漏"的概率。此外,应在渲染组件之前调用userEvent.setup(),并避免在beforeEach中调用渲染或 userEvent 函数。

学习清单

课程原文档在 Assignment 一节给出了值得动手完成的练习:

  1. 通读 React Testing Library 的查询方法速查表,掌握"一个查询场景对应一个最合适的查询方法";当内置方法都无法满足时,再考虑data-testid
  2. 阅读 userEvent 的 API 文档,熟悉用户模拟的完整能力。
  3. 深入阅读关于 Jest 快照测试优缺点、以及快照测试在编程中一般性利弊的讨论文章。

后续的进阶内容(回调函数与子组件的 Mock、actAPI、Arrange-Act-Assert 模式)则可在 archive/javascript/react_js/react_testing_part_two.md 中继续学习,仓库也提供了对应的新版课程 react/react_testing/mocking_callbacks_and_components.md。

知识自检

  • 测试 React 应用需要安装哪些包?分别承担什么职责?(回顾上文"测试一个 React 应用需要哪些包"一节)
  • user-event包的意义是什么?为什么它优于fireEvent
  • render方法做了什么?返回什么?
  • 查询元素时最受推荐的方法是什么?
  • 如何用userEvent测试一次点击事件?
  • 快照测试的优点是什么?缺点又是什么?

总结

围绕课程原文档 archive/javascript/react_js/react_testing_part_one.md 的核心脉络,我们完整走通了 React UI 测试的入门路径:识别必需的依赖包(@testing-library/react@testing-library/jest-dom@testing-library/user-event)、用render+screen完成第一次查询、以ByRole作为高优先级查询策略、用异步userEvent模拟真实点击,以及辩证地看待快照测试的价值与风险。这些能力与仓库新版课程 react/react_testing/introduction_to_react_testing.md 中的 Vitest 方案一一对应——无论你选择 Jest 还是 Vitest,核心的测试哲学(查询用户可见的 UI、模拟真实交互、对变更保持警觉)始终一致。把它应用到你的下一个组件上,你写的每一行测试都会让应用更可维护、更值得信赖。

【免费下载链接】curriculumThe open curriculum for learning web development项目地址: https://gitcode.com/GitHub_Trending/cu/curriculum

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询