React 基础体系 · 第 50/70 篇。示例以 React 19、现代 TypeScript 和主流框架能力为基础;客户端与服务端边界会明确说明。

React 组件集成测试:用户行为、异步 UI、Provider 和边界

组件集成测试关注的不是某个函数是否被调用,而是多个真实组件、状态和依赖组合后,用户能否完成一条可观察的交互路径。

例如,一个“提交订单”流程至少涉及:

  1. 用户填写表单;
  2. 表单读取 Provider 提供的当前用户;
  3. 点击按钮触发异步请求;
  4. UI 进入提交中状态;
  5. 请求成功后显示结果;
  6. 请求失败后显示可恢复的错误;
  7. 未登录或 Provider 配置错误时阻止提交。

如果只测试 submitOrder() 函数,无法证明表单是否正确读取输入、按钮是否真的绑定事件、加载状态是否出现,也无法证明错误消息是否能被用户看到。组件集成测试应围绕这条用户可见的因果链建立断言。


一、什么是 React 组件集成测试

1. 从实现调用转向用户可观察行为

一个测试可以抽象成:

初始 UI+用户行为+外部结果最终 UI\text{初始 UI} + \text{用户行为} + \text{外部结果} \rightarrow \text{最终 UI}

其中:

  • 初始 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 推荐优先按照用户或辅助技术访问页面的方式查询元素。

常见优先级如下:

  1. getByRole
  2. getByLabelText
  3. getByPlaceholderText
  4. getByText
  5. getByTestId

1. getByqueryByfindBy

三种查询的失败和等待语义不同:

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>

下面的输入可以被 getByRolegetByLabelText 找到:

<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: 再次提交

关键因果关系是:

  1. 提交事件先检查输入;
  2. 输入有效时立即进入 pending
  3. pending 控制按钮禁用和加载消息;
  4. fetch 成功时进入 success
  5. 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();
  });
});

这个测试保留了三类真实关系:

  • SessionProviderOrderForm 提供用户身份;
  • 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 更新。使用 userEventfindBywaitFor,可以避免手动包裹大多数操作。

以下写法存在竞态:

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,都应测试至少三类结果:

  1. 网络拒绝,例如连接失败;
  2. HTTP 失败,例如 401、422、500;
  3. 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 的“空值语义”必须明确。如果 useSessionnull 直接抛错,那么它不会产生“未登录 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(...)

通常应排查:

  1. 是否忘记 await user.click(...)
  2. 是否有请求完成后还未等待的状态更新;
  3. 是否在测试结束后仍有定时器或 Promise 更新组件;
  4. 是否直接调用了会触发状态更新的外部函数;
  5. 是否使用了不兼容 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 和第三方调度器时。若测试只是等待网络结果,不应为了“更快”而启用假定时器;只有在时间本身是被测行为时才使用。


十三、加载、成功、失败和空数据应分别验证

异步组件通常不是二态模型。至少要区分:

S={idle,pending,success,error}S = \{\text{idle}, \text{pending}, \text{success}, \text{error}\}

如果接口返回列表,还可能有:

success={non-empty,empty}\text{success} = \{\text{non-empty}, \text{empty}\}

这些状态对应不同的用户决策:

  • 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 成静态数据,无法验证 isLoadingisErrordata 的映射。反过来,如果每个文本节点都只通过页面集成测试覆盖,测试会变慢且故障定位困难。

合理划分的依据不是组件文件数量,而是责任边界:

  • 纯函数和纯展示逻辑:小范围测试;
  • 状态、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 树。它适合测试:

  • useStateuseEffect 和事件;
  • 浏览器 DOM;
  • 客户端 Provider;
  • 客户端路由适配器;
  • 用户可见的加载和错误状态。

它不是服务端渲染器,也不能自动模拟框架对服务端组件、服务器模块和请求上下文的处理。

以下依赖通常不应直接带入 jsdom 客户端测试:

  • 仅服务端可用的数据库客户端;
  • 读取服务端环境变量的模块;
  • 依赖 Node 专有 API 的服务端模块;
  • 框架内部的服务器请求上下文。

2. 服务端组件应测试其服务端契约

服务端组件不能使用客户端 Hook,例如 useState 或浏览器事件处理器。它可能异步读取数据并返回 React 元素,但其测试需要框架提供的服务端渲染或 RSC 测试能力。

因此应区分两个契约:

服务端数据函数
    → 服务端组件生成的输出
    → 框架序列化/流式传输
    → 客户端组件接收 props 并响应用户行为

客户端组件集成测试不应假装覆盖前两段。可以:

  • 单独测试服务端数据函数的输入、权限和错误;
  • 使用框架提供的服务端组件测试工具验证服务端输出;
  • 在客户端集成测试中从客户端组件边界开始,传入已解析的 props;
  • 对真实导航、服务器动作和序列化问题补充端到端测试。

3. 服务端动作或请求的测试边界

如果按钮调用的是服务端动作,而不是直接调用 fetch,客户端测试仍然应验证:

  1. 用户是否能找到并操作按钮;
  2. 提交中状态是否出现;
  3. 服务端动作成功时是否显示成功 UI;
  4. 服务端动作失败时是否显示错误 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 实现或缓存。

诊断顺序应是:

  1. 确认用户行为 Promise 已等待;
  2. 确认外部 Promise 一定会结束;
  3. findBy 等待用户可见结果;
  4. 检查 Mock 是否在 afterEach 中恢复;
  5. 检查 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 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。