React 基础体系 · 第 50/70 篇。示例以 React 19、现代 TypeScript 和主流框架能力为基础;客户端与服务端边界会明确说明。
React 组件集成测试:用户行为、异步 UI、Provider 和边界
组件集成测试关注的不是某个函数是否被调用,而是多个真实组件、状态和依赖组合后,用户能否完成一条可观察的交互路径。
例如,一个“提交订单”流程至少涉及:
- 用户填写表单;
- 表单读取 Provider 提供的当前用户;
- 点击按钮触发异步请求;
- UI 进入提交中状态;
- 请求成功后显示结果;
- 请求失败后显示可恢复的错误;
- 未登录或 Provider 配置错误时阻止提交。
如果只测试 submitOrder() 函数,无法证明表单是否正确读取输入、按钮是否真的绑定事件、加载状态是否出现,也无法证明错误消息是否能被用户看到。组件集成测试应围绕这条用户可见的因果链建立断言。
一、什么是 React 组件集成测试
1. 从实现调用转向用户可观察行为
一个测试可以抽象成:
其中:
- 初始 UI:组件首次渲染时用户能看到的内容;
- 用户行为:点击、输入、提交、键盘操作等;
- 外部结果:网络响应、Provider 状态、路由变化或定时器完成;
- 最终 UI:成功、失败、加载或空状态等结果。
测试的主要断言应落在最终 UI 或可访问性状态上,而不是内部状态变量。
例如,不应优先写:
expect(component.state.isSubmitting).toBe(true);
expect(mockSubmit).toHaveBeenCalled();
更有价值的是:
expect(
screen.getByRole("button", { name: "提交订单" }),
).toBeDisabled();
expect(await screen.findByRole("status")).toHaveTextContent(
"订单已提交",
);
前一种写法依赖实现细节;后一种写法描述用户能观察到的契约。
2. 与单元测试和端到端测试的边界
三类测试解决的问题不同:
| 类型 | 主要范围 | 常见运行环境 | 关注点 |
|---|---|---|---|
| 单元测试 | 函数、纯逻辑、单个 Hook | Node | 输入和输出是否正确 |
| 组件集成测试 | 多个 React 组件、Provider、异步 UI | jsdom 或浏览器 | 用户行为是否产生正确 UI |
| 端到端测试 | 前端、真实服务、路由、浏览器 | Chromium 等真实浏览器 | 整个系统是否可用 |
组件集成测试通常不需要启动真实后端,但也不应把所有依赖替换成无行为的 Mock。比如,把 useSession()、表单组件和提交逻辑全部 Mock 后,测试只剩下一个函数调用,就失去了集成价值。
一个实用边界是:
保留会影响用户行为的 React 组件、Provider 和状态流;替换不可控的外部系统,例如真实支付服务、生产数据库和第三方分析平台。
二、测试工具和运行环境
下面使用 Vitest、React Testing Library、@testing-library/user-event 和 @testing-library/jest-dom。这些工具不是 React 本身的 API,而是围绕 DOM 和用户交互建立测试。
安装依赖:
npm install -D \
vitest \
jsdom \
@testing-library/react \
@testing-library/user-event \
@testing-library/jest-dom \
@testing-library/jest-dom
第二个 @testing-library/jest-dom 不需要重复安装,实际命令可写为:
npm install -D vitest jsdom @testing-library/react \
@testing-library/user-event @testing-library/jest-dom
vitest.config.ts:
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
test: {
environment: "jsdom",
setupFiles: "./src/test/setup.ts",
globals: true,
},
});
src/test/setup.ts:
import "@testing-library/jest-dom/vitest";
还需要安装并配置 @vitejs/plugin-react:
npm install -D @vitejs/plugin-react
jsdom 提供 DOM API,但它不是完整浏览器。它通常没有真实布局、绘制、导航和完整网络栈。因此:
- 可以测试按钮、表单、ARIA 状态和 DOM 更新;
- 不应依赖真实像素位置、布局尺寸或浏览器导航行为;
- 需要测试真实焦点、滚动、文件上传或浏览器 API 时,应考虑浏览器模式或端到端测试。
三、查询 DOM:测试用户能找到什么
Testing Library 推荐优先按照用户或辅助技术访问页面的方式查询元素。
常见优先级如下:
getByRole;getByLabelText;getByPlaceholderText;getByText;getByTestId。
1. getBy、queryBy 和 findBy
三种查询的失败和等待语义不同:
| API | 元素存在时 | 元素不存在时 | 是否等待 |
|---|---|---|---|
getBy... |
返回元素 | 立即抛错 | 否 |
queryBy... |
返回元素 | 返回 null |
否 |
findBy... |
返回 Promise | 超时后拒绝 | 是 |
因此:
expect(screen.getByRole("button", { name: "提交订单" }))
.toBeInTheDocument();
用于断言当前已经存在的元素。
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
用于断言当前不存在的元素。
expect(await screen.findByRole("status"))
.toHaveTextContent("订单已提交");
用于等待异步更新后的元素。
2. 可访问名称是测试契约的一部分
下面的按钮可以被 getByRole 找到:
<button type="submit">提交订单</button>
下面的输入可以被 getByRole 或 getByLabelText 找到:
<label htmlFor="product-id">商品编号</label>
<input id="product-id" name="productId" />
测试:
screen.getByRole("textbox", { name: "商品编号" });
这种查询同时验证了语义和可访问名称。如果组件把按钮改成没有语义的 <div onClick={...}>,测试会失败,而这通常确实意味着用户体验退化。
四、用用户行为驱动测试
userEvent 模拟更接近真实用户的交互序列。它与直接调用 fireEvent 不同。
例如,用户输入文字并不只是触发一个 input 事件,还可能涉及焦点、键盘事件和多次输入更新:
const user = userEvent.setup();
await user.type(
screen.getByRole("textbox", { name: "商品编号" }),
"book-001",
);
await user.click(
screen.getByRole("button", { name: "提交订单" }),
);
所有 userEvent 操作都应 await。否则测试可能在 React 状态更新完成前就执行断言,产生竞态。
fireEvent 仍然有用途,例如测试底层事件边界或构造某些浏览器事件,但它不应成为一般用户流程的默认工具:
fireEvent.change(input, {
target: { value: "book-001" },
});
这直接修改事件对象,未必经过真实用户输入会经历的事件链。
五、一个完整的异步表单示例
下面构造一个包含 Provider、用户输入、异步请求、加载状态和错误状态的组件。
1. Session Provider
src/session.tsx:
import {
createContext,
useContext,
type ReactNode,
} from "react";
export type Session = {
userId: string;
displayName: string;
};
const SessionContext = createContext<Session | null>(null);
export function SessionProvider({
value,
children,
}: {
value: Session | null;
children: ReactNode;
}) {
return (
<SessionContext.Provider value={value}>
{children}
</SessionContext.Provider>
);
}
export function useSession(): Session {
const session = useContext(SessionContext);
if (!session) {
throw new Error("SessionProvider is missing or has no active session");
}
return session;
}
这里有两个不同的状态:
- Provider 存在但值为
null:代表未登录; - 组件树没有 Provider:代表开发配置错误。
这两个状态是否应该使用相同 UI,需要由应用契约决定。下面的示例将它们区分开来:未登录显示用户可理解的错误,缺少 Provider 则直接抛错,交给错误边界处理。
2. 订单表单
src/OrderForm.tsx:
import { FormEvent, useState } from "react";
import { useSession } from "./session";
type Status = "idle" | "pending" | "success" | "error";
export function OrderForm() {
const session = useSession();
const [productId, setProductId] = useState("");
const [status, setStatus] = useState<Status>("idle");
const [message, setMessage] = useState("");
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!productId.trim()) {
setStatus("error");
setMessage("请输入商品编号");
return;
}
setStatus("pending");
setMessage("");
try {
const response = await fetch("/api/orders", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
productId: productId.trim(),
userId: session.userId,
}),
});
if (!response.ok) {
throw new Error("order request failed");
}
setStatus("success");
setMessage("订单已提交");
} catch {
setStatus("error");
setMessage("提交失败,请重试");
}
}
const isPending = status === "pending";
return (
<form onSubmit={handleSubmit}>
<label htmlFor="product-id">商品编号</label>
<input
id="product-id"
name="productId"
value={productId}
onChange={(event) => setProductId(event.target.value)}
disabled={isPending}
/>
<button type="submit" disabled={isPending}>
{isPending ? "提交中…" : "提交订单"}
</button>
{status === "pending" && (
<p role="status" aria-live="polite">
正在提交
</p>
)}
{status === "success" && (
<p role="status" aria-live="polite">
{message}
</p>
)}
{status === "error" && (
<p role="alert">
{message}
</p>
)}
</form>
);
}
这里的状态转换是:
stateDiagram-v2
[*] --> idle
idle --> error: 空商品编号
idle --> pending: 提交有效表单
pending --> success: fetch 响应 ok
pending --> error: 网络错误或响应非 2xx
error --> pending: 用户修正后重试
success --> pending: 再次提交
关键因果关系是:
- 提交事件先检查输入;
- 输入有效时立即进入
pending; pending控制按钮禁用和加载消息;fetch成功时进入success;fetch抛错或响应非成功状态时进入error。
测试必须覆盖这些可观察的中间状态,而不能只断言最终成功。
六、测试 Provider、用户行为和异步 UI
src/OrderForm.test.tsx:
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, afterEach, describe, expect, it, vi } from "vitest";
import { OrderForm } from "./OrderForm";
import { SessionProvider } from "./session";
function renderWithSession() {
return render(
<SessionProvider
value={{
userId: "user-42",
displayName: "Ada",
}}
>
<OrderForm />
</SessionProvider>,
);
}
describe("OrderForm", () => {
beforeEach(() => {
vi.stubGlobal("fetch", vi.fn());
});
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
it("让已登录用户提交订单,并显示成功状态", async () => {
const user = userEvent.setup();
vi.mocked(fetch).mockResolvedValue(
new Response(JSON.stringify({ orderId: "order-1" }), {
status: 201,
headers: {
"Content-Type": "application/json",
},
}),
);
renderWithSession();
await user.type(
screen.getByRole("textbox", { name: "商品编号" }),
"book-001",
);
await user.click(
screen.getByRole("button", { name: "提交订单" }),
);
expect(await screen.findByRole("status"))
.toHaveTextContent("订单已提交");
expect(fetch).toHaveBeenCalledWith(
"/api/orders",
expect.objectContaining({
method: "POST",
body: JSON.stringify({
productId: "book-001",
userId: "user-42",
}),
}),
);
});
it("在请求进行时禁用按钮,并在失败后显示可重试错误", async () => {
const user = userEvent.setup();
let resolveRequest!: (response: Response) => void;
vi.mocked(fetch).mockReturnValue(
new Promise<Response>((resolve) => {
resolveRequest = resolve;
}),
);
renderWithSession();
await user.type(
screen.getByRole("textbox", { name: "商品编号" }),
"book-002",
);
const submitButton = screen.getByRole("button", {
name: "提交订单",
});
await user.click(submitButton);
expect(submitButton).toBeDisabled();
expect(screen.getByRole("status"))
.toHaveTextContent("正在提交");
resolveRequest(
new Response(null, {
status: 500,
}),
);
expect(await screen.findByRole("alert"))
.toHaveTextContent("提交失败,请重试");
expect(
screen.getByRole("button", { name: "提交订单" }),
).not.toBeDisabled();
});
it("在商品编号为空时不发送请求", async () => {
const user = userEvent.setup();
renderWithSession();
await user.click(
screen.getByRole("button", { name: "提交订单" }),
);
expect(await screen.findByRole("alert"))
.toHaveTextContent("请输入商品编号");
expect(fetch).not.toHaveBeenCalled();
});
});
这个测试保留了三类真实关系:
SessionProvider向OrderForm提供用户身份;userEvent修改输入并提交表单;fetch的返回结果驱动异步 UI 状态。
其中 fetch 被替换,是因为真实网络服务不应成为这个测试的必要前置条件;但请求的 URL、方法和序列化后的数据仍然被验证。
为什么使用 Response
fetch 的 Promise 成功,并不代表 HTTP 请求成功。以下两种情况不同:
fetch("/api/orders")
.then(() => {
// 这里只代表网络层返回了 Response
});
HTTP 500 通常也会得到一个成功 resolve 的 Response。因此组件必须显式检查:
if (!response.ok) {
throw new Error("order request failed");
}
测试用 status: 500 验证了这条故障路径。如果只写:
vi.mocked(fetch).mockRejectedValue(new Error("network failed"));
只能覆盖网络层拒绝,不能覆盖服务端返回 4xx 或 5xx 的情况。
七、异步断言的机制和常见错误
1. React 状态更新为什么需要等待
调用 setStatus("success") 后,DOM 不会在 JavaScript 同步表达式的任意位置都立即可见。React 需要完成更新、提交 DOM,并让测试环境观察到结果。
Testing Library 通常通过 act 协调 React 更新。使用 userEvent、findBy 和 waitFor,可以避免手动包裹大多数操作。
以下写法存在竞态:
await user.click(button);
expect(screen.getByText("订单已提交")).toBeInTheDocument();
如果“订单已提交”依赖异步请求,getByText 会在元素尚未出现时立即抛错。应改为:
expect(await screen.findByText("订单已提交"))
.toBeInTheDocument();
2. waitFor 的正确条件
waitFor 会重复执行回调,直到回调不抛异常或超时。因此应让失败状态通过断言抛出:
await waitFor(() => {
expect(screen.getByRole("status"))
.toHaveTextContent("订单已提交");
});
不要写成:
await waitFor(() => {
return screen.getByRole("status")
.textContent === "订单已提交";
});
返回 false 并不会自动告诉 waitFor 这次尝试失败。回调没有抛错时,waitFor 可能提前结束。
3. 等待元素消失
如果加载指示器最终应被移除:
await waitForElementToBeRemoved(() =>
screen.queryByRole("status", { name: "正在提交" }),
);
这里需要 queryBy,因为元素一旦消失,查询必须返回 null,而不是立即抛错。
4. 不要用固定睡眠模拟异步
下面的写法既慢又不稳定:
await new Promise((resolve) => setTimeout(resolve, 100));
expect(...);
100 毫秒不是 UI 完成的条件:本机可能已经完成,CI 机器可能还没有;请求也可能永远不会完成。等待应绑定于状态条件,例如成功消息、错误消息或按钮重新启用。
八、网络 Mock:函数替换与 MSW 的边界
简单组件可以直接 Mock fetch:
vi.mocked(fetch).mockResolvedValue(
new Response(JSON.stringify({ orderId: "order-1" }), {
status: 201,
}),
);
这种方式适合验证:
- 是否调用了正确 URL;
- 是否使用了正确方法;
- 请求体是否正确;
- 不同响应如何映射为 UI 状态。
但当多个组件都使用网络请求时,直接 Mock 每个模块会让测试与实现绑定。此时可以使用 Mock Service Worker(MSW)在网络边界拦截请求。测试仍然调用真实的 fetch,而响应由请求处理器决定。
典型结构:
import { http, HttpResponse } from "msw";
import { setupServer } from "msw/node";
const server = setupServer(
http.post("/api/orders", async ({ request }) => {
const body = await request.json();
return HttpResponse.json(
{ orderId: "order-1", received: body },
{ status: 201 },
);
}),
);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
测试中可以针对单个场景覆盖处理器:
server.use(
http.post("/api/orders", () => {
return new HttpResponse(null, { status: 503 });
}),
);
MSW 的价值不是“更真实”这一抽象口号,而是它保留了从组件到 fetch 的调用链,同时把不可控的服务器替换在 HTTP 边界。这样可以发现请求方法、路径和序列化错误,而不需要依赖真实后端。
无论使用哪种 Mock,都应测试至少三类结果:
- 网络拒绝,例如连接失败;
- HTTP 失败,例如 401、422、500;
- HTTP 成功但数据无效,例如缺少必需字段。
第三类尤其容易被忽略。response.ok === true 只说明 HTTP 状态成功,不保证 JSON 满足应用协议。
九、Provider:为什么应在集成测试中保留
Provider 是通过 React Context 向子树传递依赖的组件。常见依赖包括:
- 当前用户和权限;
- 主题;
- 国际化;
- 路由;
- 查询缓存;
- 表单配置;
- 实验开关。
如果组件通过 useContext 获取这些值,Provider 就是组件行为的一部分。
1. 用真实 Provider 构造测试环境
最直接的方式是显式包裹:
function renderWithSession(
ui: React.ReactElement,
session = {
userId: "user-42",
displayName: "Ada",
},
) {
return render(
<SessionProvider value={session}>
{ui}
</SessionProvider>,
);
}
更通用的测试工具可以支持可选配置:
function renderApp(
ui: React.ReactElement,
{
session = {
userId: "user-42",
displayName: "Ada",
},
}: {
session?: {
userId: string;
displayName: string;
} | null;
} = {},
) {
return render(
<SessionProvider value={session}>
{ui}
</SessionProvider>,
);
}
测试未登录路径:
it("未登录时显示错误", async () => {
renderApp(<OrderForm />, { session: null });
// 由于 useSession 在渲染阶段抛错,
// 这里需要由错误边界或测试框架捕获。
});
这个例子说明了一个重要边界:Provider 的“空值语义”必须明确。如果 useSession 对 null 直接抛错,那么它不会产生“未登录 UI”;若产品需要未登录页面,就应让 Hook 返回 Session | null,由组件渲染登录提示,而不是在 Hook 中抛错。
2. 不要把所有 Provider 都变成假实现
假设组件依赖查询缓存 Provider。如果测试把 useQuery 直接 Mock 成固定数据,那么以下问题都无法发现:
- Query Provider 是否缺失;
- 查询 key 是否错误;
- loading 和 error 状态是否正确;
- 缓存配置是否影响重试和刷新。
应根据测试目标决定替换层级:
- 测试纯展示:可以直接传入静态数据;
- 测试 Provider 与组件的交互:保留真实 Provider;
- 测试网络错误:在 HTTP 层 Mock;
- 测试第三方服务本身:通常不属于该组件集成测试。
3. Provider 的生命周期也会影响测试
Provider 可能在挂载时执行:
- 读取本地存储;
- 注册事件监听;
- 创建缓存;
- 请求当前用户;
- 初始化订阅。
测试反复运行时,如果 Provider 没有清理副作用,会出现跨测试污染。Provider 的 useEffect 应返回清理函数,测试则应在每个用例后卸载或使用 Testing Library 的自动清理机制。
如果一个测试依赖前一个测试留下的 Context、Mock 响应或缓存,测试顺序一变化就失败,这不是异步问题,而是生命周期隔离问题。
十、错误边界:组件故障和异步故障不是同一类
错误边界(Error Boundary)是捕获子树渲染错误并显示备用 UI 的 React 组件。React 当前仍然通过类组件 API 实现错误边界,核心方法是:
static getDerivedStateFromError:根据错误更新备用 UI 状态;componentDidCatch:记录错误或执行副作用。
一个最小实现:
import {
Component,
type ErrorInfo,
type ReactNode,
} from "react";
type ErrorBoundaryProps = {
children: ReactNode;
fallback: ReactNode;
};
type ErrorBoundaryState = {
hasError: boolean;
};
export class ErrorBoundary extends Component<
ErrorBoundaryProps,
ErrorBoundaryState
> {
state: ErrorBoundaryState = {
hasError: false,
};
static getDerivedStateFromError(): ErrorBoundaryState {
return {
hasError: true,
};
}
componentDidCatch(error: Error, info: ErrorInfo) {
console.error("UI render error", error, info.componentStack);
}
render() {
if (this.state.hasError) {
return this.props.fallback;
}
return this.props.children;
}
}
测试渲染错误:
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { ErrorBoundary } from "./ErrorBoundary";
function BrokenWidget() {
throw new Error("render failed");
}
it("渲染错误时显示备用 UI", () => {
const errorSpy = vi
.spyOn(console, "error")
.mockImplementation(() => {});
render(
<ErrorBoundary fallback={<p role="alert">页面暂时不可用</p>}>
<BrokenWidget />
</ErrorBoundary>,
);
expect(screen.getByRole("alert"))
.toHaveTextContent("页面暂时不可用");
errorSpy.mockRestore();
});
测试中屏蔽 console.error 是为了避免预期错误污染日志,但必须在测试结束后恢复,否则后续测试的真实错误会被隐藏。
错误边界能捕获什么
错误边界主要覆盖子组件的:
- 渲染过程;
- 生命周期方法;
- 类组件相关的更新错误。
它不能自动捕获:
- 事件处理器中的异常;
- 异步回调中的异常;
setTimeout回调中的异常;- 服务端渲染阶段发生的异常。
例如:
function BadButton() {
return (
<button
onClick={() => {
throw new Error("event failed");
}}
>
点击
</button>
);
}
点击按钮抛出的错误不会被普通错误边界接管。事件处理器应自行处理错误并更新 UI:
function SafeButton() {
const [failed, setFailed] = useState(false);
function handleClick() {
try {
// 可能失败的同步操作
} catch {
setFailed(true);
}
}
return (
<>
<button onClick={handleClick}>执行</button>
{failed && <p role="alert">操作失败</p>}
</>
);
}
网络请求失败同样应在 try/catch 中转化为状态,而不是期待错误边界捕获 Promise rejection。错误边界只能捕获后续渲染阶段抛出的错误,例如请求成功后组件因数据结构错误而渲染失败。
十一、act、批处理和 React 19
act 的作用是让测试等待一次 React 更新周期完成,再观察结果。React 组件测试中,以下工具通常已经在内部处理了 act:
render;userEvent的交互方法;- Testing Library 的异步查询;
waitFor。
因此一般不需要手动写:
await act(async () => {
await user.click(button);
});
如果仍然出现类似以下警告:
An update to Component inside a test was not wrapped in act(...)
通常应排查:
- 是否忘记
await user.click(...); - 是否有请求完成后还未等待的状态更新;
- 是否在测试结束后仍有定时器或 Promise 更新组件;
- 是否直接调用了会触发状态更新的外部函数;
- 是否使用了不兼容 React 19 的旧测试工具。
React 19 延续了通过 act 协调测试更新的原则,但测试库的具体兼容版本应与项目版本匹配。不能因为某个更新“看起来同步”就假设 DOM 已经完成提交。
十二、定时器、轮询和重试
异步 UI 如果包含防抖、轮询或重试,会引入第二种等待来源:时间。
例如:
useEffect(() => {
const timer = window.setTimeout(() => {
setStatus("ready");
}, 500);
return () => window.clearTimeout(timer);
}, []);
测试真实等待 500 毫秒通常没有必要,可以使用 Vitest 假定时器:
import { act } from "react";
import { render, screen } from "@testing-library/react";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it("定时器完成后显示 ready", async () => {
render(<DelayedComponent />);
expect(screen.getByRole("status"))
.toHaveTextContent("加载中");
await act(async () => {
vi.advanceTimersByTime(500);
});
expect(screen.getByRole("status"))
.toHaveTextContent("已准备");
});
使用假定时器时,必须同时考虑 Promise 微任务和 React 更新。对于 userEvent,应把定时器推进函数传入:
const user = userEvent.setup({
advanceTimers: vi.advanceTimersByTime,
});
否则 userEvent 自身的延迟与假定时器可能互相等待。
假定时器的风险是测试可能与真实时间模型不一致,尤其是同时存在 setTimeout、Promise、requestAnimationFrame 和第三方调度器时。若测试只是等待网络结果,不应为了“更快”而启用假定时器;只有在时间本身是被测行为时才使用。
十三、加载、成功、失败和空数据应分别验证
异步组件通常不是二态模型。至少要区分:
如果接口返回列表,还可能有:
这些状态对应不同的用户决策:
idle:用户还没有发起操作;pending:禁止重复提交或显示进度;success:告知操作完成;error:说明失败,并提供重试或修正入口;empty:请求成功,但没有数据。
只测试成功路径会遗漏最容易影响用户的状态转换。一个完整的请求测试通常应验证:
// 1. 初始状态
expect(screen.getByRole("button", { name: "提交订单" }))
.not.toBeDisabled();
// 2. 用户行为
await user.click(...);
// 3. 中间状态
expect(screen.getByRole("status"))
.toHaveTextContent("正在提交");
// 4. 外部结果
resolveRequest(okResponse);
// 5. 最终状态
expect(await screen.findByRole("status"))
.toHaveTextContent("订单已提交");
中间状态不是装饰。它决定了用户是否会重复点击、是否知道请求仍在进行,以及屏幕阅读器是否能获得进度信息。
十四、组件边界与测试边界
“边界”不仅指错误边界,也包括测试中不同责任的分界。
1. 展示组件与数据组件
可以将数据获取和展示拆开:
function OrderPage() {
const session = useSession();
const result = useOrders(session.userId);
if (result.isLoading) {
return <p role="status">加载中</p>;
}
if (result.isError) {
return <p role="alert">订单加载失败</p>;
}
return <OrderList orders={result.data} />;
}
OrderList 可以有大量纯展示单元测试;OrderPage 则适合做集成测试,验证 Provider、查询状态和页面状态之间的关系。
如果把整个页面都 Mock 成静态数据,无法验证 isLoading、isError 和 data 的映射。反过来,如果每个文本节点都只通过页面集成测试覆盖,测试会变慢且故障定位困难。
合理划分的依据不是组件文件数量,而是责任边界:
- 纯函数和纯展示逻辑:小范围测试;
- 状态、Provider、用户行为和网络交互:组件集成测试;
- 跨页面和真实浏览器行为:端到端测试。
2. 测试不应穿透不必要的内部边界
以下断言通常过度依赖实现:
expect(screen.getByTestId("order-form-state"))
.toHaveAttribute("data-status", "success");
如果这个属性不是用户或辅助技术需要的接口,组件重构时测试会无意义地失败。更好的断言是:
expect(screen.getByRole("status"))
.toHaveTextContent("订单已提交");
data-testid 并非禁止使用。当元素没有合理的可访问语义,例如复杂 SVG、虚拟化容器或第三方画布时,它可以作为最后的定位手段,但不应替代本来可以使用的角色、标签或文本。
十五、客户端与服务端边界
React 19 应用可能同时包含客户端组件、服务端组件、服务端渲染和服务端动作。组件集成测试必须先确定代码运行在哪个边界。
1. jsdom 测试的是客户端运行时
@testing-library/react 在 jsdom 中渲染客户端 React 树。它适合测试:
useState、useEffect和事件;- 浏览器 DOM;
- 客户端 Provider;
- 客户端路由适配器;
- 用户可见的加载和错误状态。
它不是服务端渲染器,也不能自动模拟框架对服务端组件、服务器模块和请求上下文的处理。
以下依赖通常不应直接带入 jsdom 客户端测试:
- 仅服务端可用的数据库客户端;
- 读取服务端环境变量的模块;
- 依赖 Node 专有 API 的服务端模块;
- 框架内部的服务器请求上下文。
2. 服务端组件应测试其服务端契约
服务端组件不能使用客户端 Hook,例如 useState 或浏览器事件处理器。它可能异步读取数据并返回 React 元素,但其测试需要框架提供的服务端渲染或 RSC 测试能力。
因此应区分两个契约:
服务端数据函数
→ 服务端组件生成的输出
→ 框架序列化/流式传输
→ 客户端组件接收 props 并响应用户行为
客户端组件集成测试不应假装覆盖前两段。可以:
- 单独测试服务端数据函数的输入、权限和错误;
- 使用框架提供的服务端组件测试工具验证服务端输出;
- 在客户端集成测试中从客户端组件边界开始,传入已解析的 props;
- 对真实导航、服务器动作和序列化问题补充端到端测试。
3. 服务端动作或请求的测试边界
如果按钮调用的是服务端动作,而不是直接调用 fetch,客户端测试仍然应验证:
- 用户是否能找到并操作按钮;
- 提交中状态是否出现;
- 服务端动作成功时是否显示成功 UI;
- 服务端动作失败时是否显示错误 UI。
但动作本身的权限、输入校验、数据库事务必须在服务端测试中验证。把服务端动作 Mock 成“永远成功”,不能证明服务端安全性;把数据库引入 jsdom,也会让测试环境和生产边界混乱。
十六、Suspense 和异步资源的特殊边界
Suspense 的 fallback 是 React 在子树暂时无法完成时显示的备用 UI。它和手动的 isLoading 状态不同:
<Suspense fallback={<p role="status">加载中</p>}>
<OrderDetails />
</Suspense>
测试 fallback 时,应验证用户看到的状态:
expect(screen.getByRole("status"))
.toHaveTextContent("加载中");
然后等待内容出现:
expect(await screen.findByRole("heading", {
name: "订单详情",
})).toBeInTheDocument();
Suspense 的具体触发方式可能来自框架、数据层或 React 的异步资源机制。测试不能仅凭“组件用了 async”就推断它一定会触发 Suspense;必须由实际数据读取机制抛出 React 能识别的 Promise,或者由框架完成对应集成。
同样,错误 fallback 和 Suspense fallback 是两条不同路径:
等待异步资源 → Suspense fallback
异步资源失败并被边界处理 → Error Boundary fallback
测试两者时,应使用能分别触发“仍在等待”和“明确失败”的输入或 Mock 响应。
十七、常见失败表现与诊断路径
1. 测试偶发失败
典型表现:
Unable to find an accessible element with the role "status"
可能原因:
- 忘记
await user.click; - 使用
getBy检查异步元素; - 请求 Mock 没有 resolve 或 reject;
- 测试结束时仍有未清理的定时器;
- 前一个测试遗留了 Mock 实现或缓存。
诊断顺序应是:
- 确认用户行为 Promise 已等待;
- 确认外部 Promise 一定会结束;
- 用
findBy等待用户可见结果; - 检查 Mock 是否在
afterEach中恢复; - 检查 Provider 和组件是否在测试结束后仍有副作用。
2. 找不到按钮或输入框
如果:
screen.getByRole("button", { name: "提交订单" });
失败,不应立即改成 getByTestId。先检查:
- 元素是否真的是
<button>; - 可见文本是否构成可访问名称;
- 是否被禁用或根本没有渲染;
- 是否处于尚未完成的异步状态;
- 是否在错误边界 fallback 中。
查询失败经常揭示组件语义错误,而不仅是测试写错。
3. 测试卡在 await user.click
常见原因是启用了假定时器,但没有给 userEvent 配置 advanceTimers:
const user = userEvent.setup({
advanceTimers: vi.advanceTimersByTime,
});
另一个原因是点击处理器等待一个永远不会完成的 Promise。此时应为测试明确提供 resolve/reject 控制,而不是增加超时时间。
4. 预期错误没有进入错误边界
如果错误发生在事件处理器或 fetch Promise 中,错误边界不会自动显示 fallback。检查错误发生位置:
render 阶段抛错 → Error Boundary
事件处理器抛错 → 事件处理器自行处理
Promise rejection → try/catch 或数据层错误状态
定时器回调抛错 → 定时器回调自行处理
这一区分能避免把错误边界当作全局异常捕获器。
5. Strict Mode 导致调用次数变化
开发环境下启用 StrictMode 可能让某些生命周期和 Effect 的执行表现不同,用于帮助发现不安全的副作用。测试中如果直接断言:
expect(fetch).toHaveBeenCalledTimes(1);
可能因为开发模式重挂载、Effect 依赖或组件实现而不稳定。
如果“只发送一次请求”是产品或组件契约,应从实现上保证幂等或正确控制 Effect,并针对该契约测试;如果测试只是要验证请求内容,优先断言是否存在匹配调用:
expect(fetch).toHaveBeenCalledWith(
"/api/orders",
expect.anything(),
);
这不是忽略重复请求,而是避免把与目标无关的调用次数绑定到测试。
十八、测试真实故障路径,而不是只模拟理想世界
一个异步 UI 的故障矩阵可以这样建立:
| 故障位置 | Mock 方式 | 期望 UI |
|---|---|---|
| 输入校验 | 空输入 | 本地错误,不发送请求 |
| 网络层 | mockRejectedValue |
提交失败 |
| HTTP 层 | Response 状态 500 |
提交失败 |
| 权限层 | 状态 401 | 登录过期或无权限 |
| 业务校验 | 状态 422 + 错误字段 | 字段级提示 |
| 数据协议 | 200 但 JSON 缺字段 | 数据错误或边界 fallback |
| 渲染层 | 子组件直接抛错 | Error Boundary fallback |
例如,422 不应总是显示通用错误。如果服务端返回字段错误:
{
"field": "productId",
"message": "商品不存在"
}
组件可能需要把错误绑定到输入框:
<input
aria-invalid={hasProductError}
aria-describedby={hasProductError ? "product-error" : undefined}
/>
对应测试不只应检查文字,还应检查关联关系:
expect(
screen.getByRole("textbox", { name: "商品编号" }),
).toHaveAttribute("aria-invalid", "true");
expect(screen.getByText("商品不存在"))
.toHaveAttribute("id", "product-error");
这样验证的是用户和辅助技术能否理解错误,而不是某个内部错误对象是否存在。
十九、集成测试的取舍
组件集成测试不是越接近整个应用越好。范围过大时,失败定位会变慢;范围过小时,Provider、状态流和异步边界又没有被验证。
可以用以下原则决定测试范围:
- 如果行为由多个 React 组件共同决定,应保留这些组件;
- 如果依赖只影响数据来源,应在网络或服务适配层替换;
- 如果 Provider 改变了用户能看到的行为,应保留真实 Provider;
- 如果依赖属于服务端权限、数据库或第三方系统,应在其运行边界测试;
- 如果问题涉及真实浏览器能力,应补充浏览器端端到端测试。
一个好的集成测试通常具有以下结构:
准备真实组件树和必要 Provider
↓
使用可访问查询定位 UI
↓
使用 userEvent 执行完整用户行为
↓
在外部边界注入可控成功或失败
↓
等待并断言加载、成功、失败或边界 UI
测试的核心不是“调用了哪个函数”,而是状态、数据流和故障路径是否最终形成正确的用户界面。对于 React 组件,这通常意味着同时验证 DOM 语义、用户事件、Provider 配置、异步调度和错误边界,而不是把它们分别 Mock 成互不相干的片段。
系列导航与关联阅读
- 系列入口:React 完整学习路线:从渲染与 Hooks 到服务端组件和生产架构
- 上一篇:React 单元测试:纯函数、Hook、时间、网络和稳定断言
- 下一篇:React 端到端测试:Playwright、登录态、网络、并行和失败证据
官方资料
本文依据 React 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。

评论
0 条讨论