OpenHands Agent Canvas 前端 React Router 测试实践:createRoutesStub 与 MemoryRouter 的选型、模式与反模式
React 组件若依赖路由上下文(useParams、useLoaderData、<Link> 等),测试时就必须为其提供路由环境。OpenHands Agent Canvas(@openhands/agent-canvas)前端采用 React Router v7(react-router@7.18.2)+ Vitest + Testing Library 技术栈,官方在 tests/router.md 中沉淀了一套路由测试规范:按组件对路由的依赖程度,在 createRoutesStub 与 MemoryRouter 两种方案中做选择,并明确列出需要规避的反模式。读完后你可以掌握:如何为依赖路由参数、loader 数据或嵌套路由的组件搭建真实路由测试环境,如何用 initialEntries 验证导航行为,以及如何避免 BrowserRouter 与直接 mock 路由钩子这两类常见陷阱。
测试环境背景:为什么路由组件不能裸渲染
React Router 的组件和钩子依赖路由上下文才能工作。在 jsdom 测试环境中(本项目 devDependencies 使用 jsdom@30.0.1、vitest@4.1.10、@testing-library/react@16.3.2,见 package.json),直接渲染一个调用 useParams 或 <Link> 的组件会因缺少路由上下文而报错。规范给出的原则是:在测试中提供路由上下文的同时,保留对路由状态的完全控制,并按组件实际需求选择方案:
createRoutesStub(推荐)—— 基于真实路由配置创建完整路由结构,支持路由参数、loader 数据与嵌套路由;MemoryRouter—— 提供最小化路由上下文,仅满足“钩子能工作”的最低要求。
选择依据只有一条:看你的组件究竟需要从路由器拿到什么。
方案一:createRoutesStub(推荐)
适用场景
当被测组件满足以下任一条件时,应使用 createRoutesStub:
- 依赖路由参数(
useParams); - 使用 loader 数据(
useLoaderData或clientLoader); - 存在嵌套路由或使用
<Outlet />; - 需要测试路由之间的导航行为。
需要特别注意的是文档中的边界说明:createRoutesStub 的定位是单元测试可复用组件(依赖路由上下文的那一类)。如果要测的是完整的 route/page 组件,OpenHands 前端建议交给 E2E 测试(Playwright)覆盖——本项目 package.json 中配置了 test:e2e、test:e2e:mock-llm 等多套 Playwright 脚本(对应 playwright.config.ts、playwright.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,并叠加 QueryClientProvider 与 ActiveBackendProvider 后再渲染(见 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 never(settings.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> 包裹 ChatInterface;renderWithQueryClient 则把 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.tsx 用 vi.mock 屏蔽了 useConfig、useTelemetryIdentity、Sidebar、i18n 等业务依赖,但路由本身仍通过 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/afterEach 中 queryClient.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.tsx 与 tests/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 + QueryClientProvider(retry: false) |
tests/routes/settings.test.tsx |
| 完整页面/路由组件的全链路行为 | 交给 Playwright E2E | playwright.config.ts |
最后需要说明适用前提:本文基于当前仓库的 tests/router.md 及 react-router@7.18.2 版本编写,createRoutesStub 与 MemoryRouter 的具体 API 以该版本 React Router 官方文档为准;升级路由版本时应重新核对 stub 相关 API 的兼容性。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0622
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00