← [ BACK_TO_NOTES ]

React Testing Library 测试模型

从用户可观察行为出发理解 React Testing Library:render 建立 DOM,screen/queries 定位界面,user-event 执行用户交互,jest-dom/Vitest assertions 验证结果。

#synthesis #growing #test #react #typescript

[!info] related notes

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 的分工

两者经常一起出现,但职责不同:

工具主要职责
VitestTest 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:渲染根容器
  • baseElement
  • rerender():用新 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: "确认记录" });

这里同时验证:

  1. DOM 中有一个可访问的 button
  2. 它的 accessible name 是“确认记录”

这比单纯:

screen.getByText("确认记录")

提供了更多真实语义。

常见 role:

  • button
  • textbox
  • checkbox
  • radio
  • link
  • heading
  • dialog
  • list
  • listitem
  • status
  • alert

普通 <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(),
  },
}));

有三个价值:

  1. 不让真实通知系统参与这条测试
  2. 测试更确定、更安静
  3. 真需要验证通知时仍可断言 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 验证用户可观察结果。

以及:

测试行为,不测试实现。

创建于 2026/8/31 更新于 2026/8/31
Digital Biome
Access

键盘导航

j / k / ↑ / ↓
选择对象
Enter / o
打开选择
/ 或 Ctrl+K
全站搜索
i / f / 1–4
筛选当前列表
g → h / l / p / t / a
首页 / 笔记 / 项目 / 工具 / About
r → 1–9
打开所选对象的关联
u
返回上一页
Esc
关闭浮层 / 取消命令;输入框内清空文本