首页
/ OpenHands Agent Canvas 前端 React Router 测试实践:createRoutesStub 与 MemoryRouter 的选型、模式与反模式

OpenHands Agent Canvas 前端 React Router 测试实践:createRoutesStub 与 MemoryRouter 的选型、模式与反模式

2026-09-04 21:05:46作者:贡沫苏Truman

React 组件若依赖路由上下文(useParamsuseLoaderData<Link> 等),测试时就必须为其提供路由环境。OpenHands Agent Canvas(@openhands/agent-canvas)前端采用 React Router v7(react-router@7.18.2)+ Vitest + Testing Library 技术栈,官方在 tests/router.md 中沉淀了一套路由测试规范:按组件对路由的依赖程度,在 createRoutesStubMemoryRouter 两种方案中做选择,并明确列出需要规避的反模式。读完后你可以掌握:如何为依赖路由参数、loader 数据或嵌套路由的组件搭建真实路由测试环境,如何用 initialEntries 验证导航行为,以及如何避免 BrowserRouter 与直接 mock 路由钩子这两类常见陷阱。

测试环境背景:为什么路由组件不能裸渲染

React Router 的组件和钩子依赖路由上下文才能工作。在 jsdom 测试环境中(本项目 devDependencies 使用 jsdom@30.0.1vitest@4.1.10@testing-library/react@16.3.2,见 package.json),直接渲染一个调用 useParams<Link> 的组件会因缺少路由上下文而报错。规范给出的原则是:在测试中提供路由上下文的同时,保留对路由状态的完全控制,并按组件实际需求选择方案:

  1. createRoutesStub(推荐)—— 基于真实路由配置创建完整路由结构,支持路由参数、loader 数据与嵌套路由;
  2. MemoryRouter —— 提供最小化路由上下文,仅满足“钩子能工作”的最低要求。

选择依据只有一条:看你的组件究竟需要从路由器拿到什么。

方案一:createRoutesStub(推荐)

适用场景

当被测组件满足以下任一条件时,应使用 createRoutesStub

  • 依赖路由参数(useParams);
  • 使用 loader 数据(useLoaderDataclientLoader);
  • 存在嵌套路由或使用 <Outlet />
  • 需要测试路由之间的导航行为。

需要特别注意的是文档中的边界说明:createRoutesStub 的定位是单元测试可复用组件(依赖路由上下文的那一类)。如果要测的是完整的 route/page 组件,OpenHands 前端建议交给 E2E 测试(Playwright)覆盖——本项目 package.json 中配置了 test:e2etest:e2e:mock-llm 等多套 Playwright 脚本(对应 playwright.config.tsplaywright.mock-llm.config.ts),单元测试与 E2E 职责分离。

基础用法:路由参数

import { createRoutesStub } from "react-router";
import { render } from "@testing-library/react";

const RouterStub = createRoutesStub([
  {
    Component: MyRouteComponent,
    path: "/conversations/:conversationId",
  },
]);

render(<RouterStub initialEntries={["/conversations/123"]} />);

initialEntries 指定初始 URL,路由匹配器据此解析出 conversationId=123,组件内 useParams() 拿到的是真实路由匹配结果,而非手工注入的假数据——这正是相比 mock 钩子的核心优势。

嵌套路由 + loader

const RouterStub = createRoutesStub([
  {
    Component: SettingsScreen,
    clientLoader,
    path: "/settings",
    children: [
      {
        Component: () => <div data-testid="llm-settings" />,
        path: "/settings",
      },
      {
        Component: () => <div data-testid="mcp-settings" />,
        path: "/settings/mcp",
      },
    ],
  },
]);

render(<RouterStub initialEntries={["/settings/mcp"]} />);

父路由挂载 clientLoader、子路由通过 path 匹配,initialEntries 直接指向子路由 /settings/mcp,即可完整验证“loader 在导航前执行 → <Outlet /> 渲染对应子路由”这条链路。

仓库中 tests/routes/settings.test.tsx 就是这一模式的完整范例:它导入真实页面组件与 loader(import SettingsScreen, { clientLoader } from "#/routes/settings",对应 src/routes/settings.tsx),构造 /settings + 子路由的 stub,并叠加 QueryClientProviderActiveBackendProvider 后再渲染(见 settings.test.tsx)。该测试还验证了 clientLoader 的 302 重定向逻辑(隐藏页面被重定向到 /settings/agents),说明 stub 环境足以覆盖 loader 返回 Response 重定向这类框架级行为。

clientLoader 的类型错位处理

从 Route 模块复用 clientLoader 时,测试代码与应用代码之间的 loader 类型往往对不齐,规范建议用 @ts-expect-error 显式跳过:

import { clientLoader } from "@/routes/settings";

const RouterStub = createRoutesStub([
  {
    path: "/settings",
    Component: SettingsScreen,
    // @ts-expect-error: loader types won't align between test and app code
    loader: clientLoader,
  },
]);

仓库实际代码中有两种等价写法:loader: clientLoader as neversettings.test.tsx)与 } as never 包裹整个 loader 参数(settings.test.tsx)。无论哪种,关键是保持 loader 为真实实现,仅绕过类型检查,从而仍然能断言真实的重定向/数据行为。

方案二:MemoryRouter

适用场景

MemoryRouter 适合“只需要能渲染”的组件:

  • 组件只需要基础路由上下文即可渲染;
  • 组件使用了 <Link>,但测试不关心导航是否发生;
  • 不依赖特定路由参数或 loader。

基础用法

import { MemoryRouter } from "react-router";
import { render } from "@testing-library/react";

render(
  <MemoryRouter>
    <MyComponent />
  </MemoryRouter>
);

带初始路由

render(
  <MemoryRouter initialEntries={["/some/path"]}>
    <MyComponent />
  </MemoryRouter>
);

仓库范例见 tests/components/chat/chat-interface.test.tsx:它封装了两个渲染辅助函数——renderChatInterfaceWithRouter 用裸 <MemoryRouter> 包裹 ChatInterfacerenderWithQueryClient 则把 QueryClientProvider<MemoryRouter initialEntries={[route]}> 组合,并在内部用 <Routes>/<Route path="/:conversationId"> 声明路由,使 useParams 能解析出 conversationId。这体现了一个实用技巧:即使用 MemoryRouter,也可以配合 <Routes>/<Route> 手动声明路由树,在“最小上下文”与“参数可用”之间取得平衡。

反模式:必须规避的两类写法

反模式一:在测试中使用 BrowserRouter

BrowserRouter 会操作真实的浏览器 history API,在测试环境中容易引起状态污染与不可预期的行为:

// ❌ Avoid
render(
  <BrowserRouter>
    <MyComponent />
  </BrowserRouter>
);

// ✅ Use MemoryRouter instead
render(
  <MemoryRouter>
    <MyComponent />
  </MemoryRouter>
);

反模式二:createRoutesStub 可用时直接 mock 路由钩子

直接 mock useParams 之类的钩子既脆弱,又没有真正测试路由行为(URL 与参数匹配、loader 执行顺序都不再被覆盖):

// ❌ Avoid when possible
vi.mock("react-router", async () => {
  const actual = await vi.importActual("react-router");
  return {
    ...actual,
    useParams: () => ({ conversationId: "123" }),
  };
});

// ✅ Prefer createRoutesStub - tests real routing behavior
const RouterStub = createRoutesStub([
  {
    Component: MyComponent,
    path: "/conversations/:conversationId",
  },
]);

render(<RouterStub initialEntries={["/conversations/123"]} />);

需要注意的是这条规则的边界:它反对的是 mock 路由钩子。仓库实践中对非路由依赖(业务 hooks、services、i18n)仍大量使用 vi.mock,两者并不冲突。例如 tests/routes/root-layout.test.tsxvi.mock 屏蔽了 useConfiguseTelemetryIdentitySidebari18n 等业务依赖,但路由本身仍通过 createRoutesStub 挂载真实页面组件 MainApp 及其多条子路由(//automations/:id/conversations 等),用 initialEntries 驱动导航——即“mock 业务噪音,保留路由真实”。

常见模式:组合上下文与导航断言

QueryClientProvider 组合

多数组件同时需要路由上下文和 TanStack Query 上下文(本项目使用 @tanstack/react-query@5.101.4):

import { createRoutesStub } from "react-router";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";

const queryClient = new QueryClient({
  defaultOptions: {
    queries: { retry: false },
  },
});

const RouterStub = createRoutesStub([
  {
    Component: MyComponent,
    path: "/",
  },
]);

render(<RouterStub />, {
  wrapper: ({ children }) => (
    <QueryClientProvider client={queryClient}>
      {children}
    </QueryClientProvider>
  ),
});

queries: { retry: false } 是关键配置:避免失败请求在测试中重试造成 flaky 行为。仓库里还有更工程化的做法——直接复用应用自身的 src/query-client-config.ts 中导出的共享 queryClient,并在 beforeEach/afterEachqueryClient.clear() 隔离状态,tests/routes/settings.test.tsx 即采用该模式。

测试导航行为

验证用户交互触发了预期的路由跳转:

import { createRoutesStub } from "react-router";
import { screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";

const RouterStub = createRoutesStub([
  {
    Component: HomeScreen,
    path: "/",
  },
  {
    Component: () => <div data-testid="settings-screen" />,
    path: "/settings",
  },
]);

render(<RouterStub initialEntries={["/"]} />);

const user = userEvent.setup();
await user.click(screen.getByRole("link", { name: /settings/i }));

expect(screen.getByTestId("settings-screen")).toBeInTheDocument();

在 stub 中声明“首页 + 目标页”两条路由,点击链接后断言目标页组件出现,即可完成端到端式的导航验证,而无需启动完整应用。仓库中 tests/routes/root-layout.test.tsxtests/routes/root-layout-refetch.test.tsx 均以此方式驱动 initialEntries 在多条路由间切换,验证根布局组件在不同路由下的渲染与刷新行为。

选型速查与落地对照

需求 推荐方案 仓库可参考实现
依赖 useParams / loader / 嵌套路由 / <Outlet /> createRoutesStub tests/routes/settings.test.tsx
仅需基础路由上下文即可渲染 MemoryRouter tests/components/chat/chat-interface.test.tsx
验证点击/链接触发的导航 createRoutesStub + initialEntries tests/routes/root-layout.test.tsx
路由 + TanStack Query 双上下文 createRoutesStub/MemoryRouter + QueryClientProviderretry: false tests/routes/settings.test.tsx
完整页面/路由组件的全链路行为 交给 Playwright E2E playwright.config.ts

最后需要说明适用前提:本文基于当前仓库的 tests/router.mdreact-router@7.18.2 版本编写,createRoutesStubMemoryRouter 的具体 API 以该版本 React Router 官方文档为准;升级路由版本时应重新核对 stub 相关 API 的兼容性。

登录后查看全文
热门项目推荐
相关项目推荐

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.12 K
2.72 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
527
590
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
904
1.82 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
854
1.34 K
docsdocs
暂无描述
Markdown
889
5.78 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.52 K
1.01 K
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.33 K
1.45 K
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
981
502
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
540
384