React Testing Library 测试模型
从用户可观察行为出发理解 React Testing Library:render 建立 DOM,screen/queries 定位界面,user-event 执行用户交互,jest-dom/Vitest assertions 验证结果。
[!info] related notes
- 所属 MOC: testing-moc、react-moc
- 相关概念: testing-library-queries、react-component-test-harness、vitest-tool、frontend-testing
- 易混淆概念:
- 相关资源:
React Testing Library 测试模型
核心结论
React Testing Library(RTL)的核心目标不是验证“React 内部怎么实现”,而是验证:
用户能看到什么、能操作什么,以及操作之后界面和外部行为发生了什么。
最小测试闭环可以记成:
render
↓
screen / queries
↓
userEvent
↓
expect
这与 AAA 可以直接对应:
Arrange → render + provider + mock boundary
Act → userEvent
Assert → screen query + expect
为什么不测试 React 内部实现
假设需求是:点击“确认记录”后,这条候选事实被确认。
脆弱的测试可能去看:
组件内部 state 是否变成 confirmed
某个私有 handler 是否被调用
某个 className 是否出现
组件树第 3 层的 div 是否改变
这些都可能在正常重构时改变,而用户体验完全没有改变。
更稳的测试关注:
用户能否看到“确认记录”按钮
用户点击后是否触发正确 command/API boundary
确认后的 UI 是否呈现正确状态
这就是 Testing Library 的 guiding principle:测试越接近软件实际被使用的方式,通常越能给出可信信号。
RTL 与 Vitest 的分工
两者经常一起出现,但职责不同:
| 工具 | 主要职责 |
|---|---|
| Vitest | Test runner、测试分组、断言、mock、生命周期 |
| React Testing Library | 把 React 组件渲染到测试 DOM,并按用户视角查询界面 |
@testing-library/user-event | 模拟更接近真实用户的点击、输入、键盘等交互 |
@testing-library/jest-dom | 增加面向 DOM 的可读断言,如 toBeInTheDocument() |
| happy-dom / jsdom | 在 Node 测试环境中提供浏览器 DOM 模型 |
所以:
import { describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
不是三个重复的测试库,而是三种互补职责。
render():把组件放进测试 DOM
render(<BodyStateWorkbench snapshot={snapshot} />);
render 会把 React element 挂到测试环境中的 DOM。
它返回一组常用工具,例如:
container:渲染根容器baseElementrerender():用新 props 重新渲染同一组件unmount():卸载组件asFragment():取得当前 DOM fragment
但多数用户行为测试不需要直接操作 container;优先通过 screen 查询用户可见界面。
什么时候用 rerender
当你真正想验证“props 变化后组件如何更新”时:
const { rerender } = render(<Status value="pending" />);
expect(screen.getByText("等待中")).toBeInTheDocument();
rerender(<Status value="done" />);
expect(screen.getByText("已完成")).toBeInTheDocument();
不要把 rerender 当成普通用户行为的替代品;用户能点击完成的流程优先用 userEvent。
screen:从用户看到的页面查询元素
screen 提供绑定到当前 document 的查询 API:
screen.getByRole(...)
screen.getByText(...)
screen.queryByRole(...)
screen.findByText(...)
相比:
container.querySelector(".save-btn")
screen.getByRole("button", { name: "保存" }) 更接近用户和辅助技术真正识别界面的方式。
具体查询语义见 Testing Library 查询 API。
getByRole() 为什么通常优先
screen.getByRole("button", { name: "确认记录" });
这里同时验证:
- DOM 中有一个可访问的
button - 它的 accessible name 是“确认记录”
这比单纯:
screen.getByText("确认记录")
提供了更多真实语义。
常见 role:
buttontextboxcheckboxradiolinkheadingdialoglistlistitemstatusalert
普通 <button>、<input> 等本身就具有隐式 role,通常不要为了测试额外手写 role="button"。
userEvent:描述用户行为,而不是手工发 DOM event
推荐:
const user = userEvent.setup();
await user.click(button);
await user.type(input, "右肩今天更轻松");
user-event 会模拟一个用户动作通常伴随的一系列 DOM 行为,例如 focus、pointer、keyboard、input 等,并进行可交互性检查。
而低层的:
fireEvent.click(button);
更接近“直接派发一个 click event”。
因此一般规则:
能用 userEvent 描述的真实用户动作
→ 优先 userEvent
userEvent 尚未覆盖的底层事件或特殊场景
→ 再考虑 fireEvent
常见 userEvent API:
await user.click(element);
await user.dblClick(element);
await user.type(input, "text");
await user.clear(input);
await user.keyboard("{Enter}");
await user.tab();
await user.selectOptions(select, "option-a");
await user.upload(fileInput, file);
现代 user-event 的交互大多是异步 API,习惯上使用 await。
expect 与 DOM matcher
Vitest 提供 expect,@testing-library/jest-dom 扩展面向 DOM 的 matcher。
高频示例:
expect(element).toBeInTheDocument();
expect(element).toBeVisible();
expect(button).toBeDisabled();
expect(input).toHaveValue("右肩疼痛");
expect(element).toHaveTextContent("待确认");
expect(button).toHaveAccessibleName("确认记录");
否定断言:
expect(
screen.queryByRole("button", { name: "后来改善" }),
).not.toBeInTheDocument();
Component Test 与 Component Integration Test
React 组件测试不一定只能测一个“纯组件”。
例如 BodySense 当前测试真实覆盖:
BodyStateWorkbench
↓
useBodyStateCommand
↓
useMutation
↓
useWorkspaceInvalidation
↓
QueryClient
↓
workspaceApi(这里 mock / spy)
这已经接近一个小范围 Component Integration Test:
- React 组件是真的
- Hook 是真的
- React Query 是真的
- Provider 是真的
- 只在网络/API boundary 做替换
这种测试通常比“把每个 Hook 都 mock 掉”更有价值,因为更多真实协作逻辑会被执行。
Mock 到什么边界
推荐优先 mock:
- HTTP/API boundary
- 浏览器不可用能力
- 外部第三方服务
- 非本测试重点的全局 UI 副作用
例如:
const review = vi
.spyOn(workspaceApi, "reviewFact")
.mockResolvedValue({ fact: {} as never });
这样保留:
真实组件
真实 Hook
真实 mutation flow
只替换:
真实网络请求
不推荐默认把:
组件内部 hook
子组件
React Query
所有函数
全部 mock 掉,否则测试可能只证明“mock 会按设定返回”。
Toast 为什么可以 mock
BodyStateWorkbench 中:
toast.success(success);
toast.error(...);
Toast 是一次 command 完成后的 UI 副作用,但当前测试重点通常是 BodyState command 是否正确执行,而不是 Sonner 自己会不会渲染通知。
因此:
vi.mock("sonner", () => ({
toast: {
success: vi.fn(),
error: vi.fn(),
},
}));
有三个价值:
- 不让真实通知系统参与这条测试
- 测试更确定、更安静
- 真需要验证通知时仍可断言 spy
例如:
expect(toast.success).toHaveBeenCalledWith("记录已确认");
但如果产品 contract 并不关心 toast 文案,就不要为了覆盖率强行断言它。
BodyState 的 Positive + Negative 边界测试
Negative:Lifestyle 不显示症状型趋势按钮
it("does not show symptom trend actions for lifestyle facts", () => {
renderWorkbench(createSnapshot({
facts: [
createFact({
kind: "lifestyle.activity",
value: "日常活动以久坐为主",
}),
],
}));
expect(screen.getByText("日常活动以久坐为主")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "后来改善" }))
.not.toBeInTheDocument();
});
Positive:Discomfort 仍保留症状趋势按钮
it("keeps symptom trend actions for discomfort facts", () => {
renderWorkbench(createSnapshot({
facts: [
createFact({
kind: "discomfort",
value: "久坐后右臀疼痛",
}),
],
}));
expect(screen.getByRole("button", { name: "后来改善" }))
.toBeInTheDocument();
});
这两个测试共同锁定业务分类边界,防止开发者为了让 negative test 通过而直接删掉所有 trend controls。
不要过早把未确定文案写死
如果产品只确定:
Lifestyle 不能使用“改善 / 加重 / 恢复”这种 symptom 语义。
但还没有决定 Lifestyle 新按钮最终叫:
- 更新现状
- 状态变化
- 不再如此
那么测试先锁:
queryByRole("button", { name: "后来改善" })
而不是提前断言一个尚未确定的新文案。
测试应该保护已经确定的 contract,而不是替产品设计制造 contract。
Test Harness 与 Provider
真实应用中的组件经常依赖:
- QueryClientProvider
- Router Provider
- Theme Provider
- Auth Context
- i18n Provider
单独 render() 组件时,应用顶层 Provider 已经不存在,所以测试需要构造一个最小 Test Harness 来还原组件真正依赖的运行环境。
详见 React 组件测试中的 Test Harness 与 Provider。
常见反模式
直接查 CSS class
container.querySelector(".trend-button")
DOM 结构重构时非常脆弱。
断言内部 state
用户不关心 state 变量叫什么,只关心 UI 和行为结果。
所有依赖全部 mock
这会让测试与真实运行路径脱节。
用 getBy* 检查“不存在”
getBy* 找不到会在进入 expect 之前直接 throw。检查 absence 应用 queryBy*。
把所有行为塞进 E2E
组件级即可稳定验证的业务规则,不需要每次启动完整浏览器和后端链路。
BodySense 当前测试环境
当前 Web 测试配置使用:
Vitest 4
happy-dom
src/**/*.test.{ts,tsx}
setupFiles: src/test-setup.ts
restoreMocks: true
这意味着常规 React component tests 在 Node 进程中的 happy-dom 环境运行;真正的完整浏览器行为仍由 Playwright E2E 承担。
最短记忆方式
render 建场景,screen 找用户看见的东西,userEvent 做用户会做的事,expect 验证用户可观察结果。
以及:
测试行为,不测试实现。