3.6 测试 本节摘要:测试把「我测过没问题」变成「可验证的断言」。本节从单元、集成、E2E、快照四类测试讲起,用 Jest 与 React Testing Library 写真实的组件测试——渲染断言、交互触发、异步等待、mock 依赖、快照比对——并给出「测试该覆盖什么、不该覆盖什么」的工程判断。 本节地图 阅读完本节,你应当能够: 区分单元、集成、E2E、快照四类测试的职责 用 Jest 与 React Testing Library 渲染组件并断言 用 fireEvent 模拟用户交互,验证组件行为 用 jest.mock 隔离外部依赖,用 waitFor 等待异步 判断哪些逻辑值得测试,哪些测试是负担 一、问题与直觉:为什么测试不是可选项 没有测试的 React 项目是什么状态?
本节摘要:测试把「我测过没问题」变成「可验证的断言」。本节从单元、集成、E2E、快照四类测试讲起,用 Jest 与 React Testing Library 写真实的组件测试——渲染断言、交互触发、异步等待、mock 依赖、快照比对——并给出「测试该覆盖什么、不该覆盖什么」的工程判断。
阅读完本节,你应当能够:
没有测试的 React 项目是什么状态?改一个按钮的样式,担心破坏别的页面;重构一个组件,得手动点一遍所有用到它的地方;同事改了你的代码,你们互相不知道对方坏了什么。
测试解决的是「回归风险」——修改不再引起隐蔽的破坏。测试把「代码行为」变成「可验证的契约」:组件在什么 props 下渲染什么、点击后状态怎么变,写进断言,以后任何人改动,跑一遍测试就知道有没有破坏契约。
SOURCE 原文给测试定位了四个价值:组件行为符合预期、发现边界问题、保障代码可维护、提升团队协作效率。其中「可维护」最关键——有测试兜底,才敢重构,代码才不会越改越乱。
单元测试:测单个组件或函数,隔离外部依赖。跑得最快,数量最多。
集成测试:测多个组件或模块之间的交互。比如父组件与子组件联动、表单提交全流程。
E2E 测试:模拟真实用户行为,在浏览器里点完整流程。最接近真实,但慢、脆。
快照测试:把组件渲染结果存成快照,改动时比对差异。检测意外 UI 变化,但容易误报。
| 类型 | 测什么 | 速度 | 稳定性 |
|---|---|---|---|
| 单元 | 单个组件/函数 | 快 | 稳 |
| 集成 | 组件间交互 | 中 | 中 |
| E2E | 用户完整流程 | 慢 | 脆 |
| 快照 | UI 意外变化 | 中 | 易误报 |

一个实用的分层:单元 + 集成打底,E2E 只测关键用户路径(登录、下单、核心流程),快照谨慎用。别试图让四类测试覆盖率一样高——性价比完全不同。
npm install --save-dev jest @testing-library/react @testing-library/jest-dom
假设要测第 1.4 节的 Counter 组件:
// Counter.jsx import { useState } from 'react'; function Counter() { const [count, setCount] = useState(0); return ( <div> <p>Count: {count}</p> <button onClick={() => setCount(count + 1)}>Increment</button> </div> ); } export default Counter;
测试文件:
import { render, screen, fireEvent } from '@testing-library/react'; import '@testing-library/jest-dom'; import Counter from './Counter'; describe('Counter 组件', () => { test('初始计数为 0', () => { render(<Counter />); const countElement = screen.getByText(/Count:/i); expect(countElement).toHaveTextContent('Count: 0'); }); test('点击 Increment 后计数加一', () => { render(<Counter />); const incrementButton = screen.getByText('Increment'); fireEvent.click(incrementButton); const countElement = screen.getByText(/Count:/i); expect(countElement).toHaveTextContent('Count: 1'); }); });
拆解几个 API:
React Testing Library 的设计哲学是「像用户一样测试」:用户看到的是文本、按钮、表单标签,不是组件的内部状态或方法。所以:
这个哲学的价值在重构时体现:测试绑定的是「用户看到的行为」,重构内部实现不会破坏测试——这正是测试该有的样子。
组件有异步逻辑(请求数据)时,用 findBy 或 waitFor 等待:
import { render, screen, waitFor } from '@testing-library/react'; test('异步加载后显示用户', async () => { render(<UserList />); await waitFor(() => { expect(screen.getByText('John Doe')).toBeInTheDocument(); expect(screen.getByText('Jane Doe')).toBeInTheDocument(); }); });
waitFor 反复轮询直到断言通过或超时。比固定 setTimeout 靠谱得多——它不依赖「恰好等够时长」。
测组件时不想真的发网络请求。用 jest.mock 替换依赖:
// UserList 依赖 fetchUsers 函数 jest.mock('./UserList', () => ({ ...jest.requireActual('./UserList'), __esModule: true, fetchUsers: jest.fn(() => Promise.resolve([ { id: 1, name: 'John Doe' }, { id: 2, name: 'Jane Doe' }, ])), }));
jest.requireActual 保留原始模块的其他部分,只替换 fetchUsers。mock 的核心原则是「测组件逻辑,不测网络」——网络层的问题由请求库和 E2E 测试负责,单元测试只关心组件在「拿到数据」时渲染对不对。
Jest 默认在 jsdom 环境运行——一个用 JavaScript 模拟的 DOM。它没有真实的布局、没有真实的网络,但够 React 组件「渲染、交互、断言」用。这带来两个实践含义:
第一,依赖「真实布局」的测试(测量元素宽度、滚动位置)在 jsdom 里拿不到正确值,这类要放 E2E 或做 mock。第二,jsdom 里 fetch、matchMedia 等浏览器 API 可能缺失,需要 polyfill 或 mock。遇到「测试环境里报 XX is not defined」,先想到是 jsdom 缺 API,再去补。
E2E 测试(如 Cypress)跑在真实浏览器里,用真实布局、真实网络。它弥补 jsdom 的所有短板,但成本也高——第 5.2 节项目实战里会给一套「单元 + 集成为主,E2E 只守关键路径」的配置示例。
import { render } from '@testing-library/react'; import Button from './Button'; test('按钮快照', () => { const { container } = render(<Button>点击</Button>); expect(container).toMatchSnapshot(); });
第一次运行生成快照文件,之后每次运行比对。UI 变了 → 快照不匹配 → 测试失败。检查后确认是有意改动,用更新命令更新快照。
快照的问题在于噪音:样式微调、类名变化都会触发快照失败,而这类改动往往无关紧要。团队里如果天天「更新快照」,快照就失去了保护作用。建议:快照只用于「你明确要锁定的结构」,别全组件都拍。
💡 关键直觉:快照是「结构契约」,不是「行为契约」。测行为用断言(文本、存在性、样式),测结构才用快照。把快照当行为测试用,你会被误报烦死。
单元测试隔离了外部依赖,集成测试则把组件拼起来看它们是否协作。典型场景:父组件用子组件,子组件的变化要反映到父组件。
假设 Parent 组件用了 Counter,Counter 计数变化时更新 Parent 里的消息:
function Parent() { const [message, setMessage] = useState(''); return ( <div> <Counter onChange={(count) => setMessage(`计数:${count}`)} /> <p>{message}</p> </div> ); }
集成测试模拟用户点按钮,断言父子联动是否正确:
test('点击按钮后父组件消息更新', () => { render(<Parent />); const incrementButton = screen.getByText('Increment'); fireEvent.click(incrementButton); const messageElement = screen.getByText(/计数:/i); expect(messageElement).toHaveTextContent('计数:1'); });
集成测试的价值在于验证「接口契约」:子组件的 onChange 有没有正确触发、父组件有没有正确响应。单元测试各自证明「自己是对的」,集成测试证明「合在一起也对」。
组件依赖 Router、Provider、状态库时,测试里要包上对应的包装。React Testing Library 提供了 render 的包装能力:
import { render } from '@testing-library/react'; import { BrowserRouter } from 'react-router-dom'; import { ThemeProvider } from './ThemeContext'; function renderWithProviders(ui) { return render( <BrowserRouter> <ThemeProvider>{ui}</ThemeProvider> </BrowserRouter> ); }
把「每个测试都要套的包装」抽成一个 helper,测试文件保持干净。依赖 Provider 的组件测试时,记得给 Context 提供明确的值,否则测的是「缺省行为」而不是「真实行为」。
测试不是写得越多越好。判断标准是「回归价值」:
| 该测 | 不必测 |
|---|---|
| 有状态逻辑的组件(表单、计数器) | 纯展示组件(无逻辑) |
| 条件渲染的分支 | 每行 JSX |
| 用户交互的行为 | 内部实现细节 |
| 公共函数/工具 | 一次性代码 |
| 关键业务路径 | 所有边缘情况 |
一个实用的原则:测「会坏的东西」——有状态、有交互、有分支逻辑的代码才会坏;纯展示组件、简单的 map 渲染不容易坏,别为它们刷覆盖率。
测试领域有个经典模型叫测试金字塔:底层是大量廉价的单元测试,中层是中等数量的集成测试,顶层是少数昂贵的 E2E 测试。它的含义很直白:便宜的测试多写,昂贵的测试少写。
成本不只是「执行时间」,还有「维护成本」。E2E 测试每次改 UI 都可能要跟着改,脆、慢、维护贵。所以 E2E 只覆盖「坏了代价最高的流程」——登录、支付、核心业务链路,其他都用单元和集成顶住。
金字塔还提醒一个常见误区:别把单元测试堆成「金字塔的塔尖」。如果项目里全是慢而脆的 E2E、没有快速兜底的单元测试,测试套件会变成 CI 的拖累和团队的火气来源。从底层单元测试开始建,再逐步加集成和 E2E,比反过来轻松得多。
最容易测、最值得测的是纯函数——比如第 3.4 节表单里抽出来的校验规则:
import { validators } from './validators'; test('必填校验', () => { expect(validators.required('')).toBe('此项必填'); expect(validators.required('有值')).toBe(''); }); test('邮箱校验', () => { expect(validators.email('abc@example.com')).toBe(''); expect(validators.email('not-an-email')).toBe('邮箱格式不正确'); });
纯函数不依赖 React、没有渲染开销,一个文件几秒钟跑完,覆盖率极高。先测纯函数,再测组件,最后 E2E——这个顺序性价比最高。
要不要先写测试再写代码(TDD)?在 React 组件上,完整 TDD 的成本偏高——组件渲染的预期不好提前精确描述。更实际的折中是「先写代码,再为关键行为补测试」:组件能跑了,把「会坏的地方」补上测试。核心业务逻辑(状态流转、校验规则)可以尝试严格 TDD,界面层用「补测」更务实。别被方法论绑架,目标是「该坏的能拦住」,不是「先写哪一行」。
⚠️ 常见坑:测试断言绑定了实现细节(比如查组件的内部 state)。这种测试在重构时第一个碎,碎了还误导人——你以为功能坏了,其实只是实现换了。永远用「用户视角」的查询与断言。
「测试覆盖率达到多少才合格?」 别被覆盖率数字绑架。覆盖率衡量的是「哪些代码行被执行过」,不是「行为有没有被验证」——一个断言了三个分支的测试,覆盖率可能只加了十几行,但它比「跑过 100 行但什么都没断言」的测试有用得多。判断标准是「回归价值」:这个测试能拦住哪种破坏?拦得住关键破坏的测试,覆盖率低也合格;反之,覆盖率再高也是刷数字。团队可以约定「新增代码要测关键行为」,但别设一个「覆盖率必须 80%」的 KPI——那是南辕北辙。
「测试里能不能用真实网络请求?」 不能,单元测试和集成测试阶段都不要碰真实网络。真实请求让测试变慢、变脆、依赖外部环境(服务挂了测试就红了)。mock 的正确姿势不是「随便 mock」,而是「mock 掉边界,保留被测逻辑」——被测组件依赖的 fetch 或请求函数被替换成可控的桩,组件自己的状态流转、渲染分支、交互行为保持真实。真正的网络层问题,交给请求库自己的测试和 E2E 测试去覆盖。记住测试金字塔:越接近真实环境,越贵,越少用。
「React 测试有必要每个组件都写吗?」 没必要。测试是「为会坏的东西买的保险」,纯展示组件(一个 div 包一段文案)、静态配置、一次性代码,测试价值极低。真正值得写的是:有状态逻辑的组件(表单、计数器、分页)、有分支的渲染(加载/错误/内容三态)、有外部交互的组件(点击、输入、提交)、以及抽出来的纯函数(校验规则、格式化函数)。一个项目 70% 的组件可能只需要「冒烟测试」——渲染不崩即可,剩下 30% 的关键组件才需要完整断言。这个配比,比「每个组件三个测试」理性得多。
「测试文件和组件放在一起还是分开?」 两种都行,团队约定统一即可。就近放(同目录、同名前缀)的好处是导入路径短、删组件时测试一起删;集中放(tests 目录)的好处是测试树清爽、不看测试的同事不被打扰。就 React 社区的主流实践看,就近放更常见——因为组件和它的测试通常一起被修改,「修改时相邻」的收益高于「目录整洁」。关键是别混着来:一个项目要么全就近,要么全集中,混用会让新人找不到测试。
下一节看 SSR 与 Next.js——前端测试保证「代码对不对」,服务端渲染解决「首屏快不快、能不能被搜索引擎抓」。这是 React 应用从「能用」走向「能被更多人用」的关键一步,也是 SEO 敏感的站点绕不开的技术选型。