OpenHands Agent Canvas 的 MSW API 模拟实战:从 dev:mock 前端开发到 Vitest 全局 Mock Server 生命周期管理
OpenHands(Agent Canvas)前端仓库基于 MSW(Mock Service Worker)构建了一套"开发期与测试期共用"的 API 模拟体系:浏览器侧通过 Service Worker 拦截请求支撑 dev:mock 前端独立开发,Node 侧通过 setupServer 为 Vitest 提供全局网络层 mock,同一套 handler 在两个环境复用。读完本文,你将掌握该项目的 MSW 分层模拟策略(服务层 vi.spyOn 为主、网络层 server.use() 为辅)、全局 server 的生命周期配置细节,以及如何按项目规范为新 API 端点补齐 mock。
一、为什么选择 MSW:网络层拦截的透明性
与传统"打补丁"式的 mock(直接 patch fetch 或 axios 实例)不同,MSW 在网络层拦截出站请求:
- 浏览器环境:通过 Service Worker 拦截,应用代码完全无感知;
- Node.js 环境(如 Vitest + jsdom):通过直接请求拦截实现同等能力。
这种透明性带来的核心收益是:同一份 mock handler 可以在开发和测试中复用一次编写、处处生效。该仓库将 MSW 用于两个场景:
- 测试:编写可靠的单元/集成测试,无需真实网络调用;
- 开发:在后端不可用或后端 API 尚未就绪时,用模拟 API 独立跑通前端。
仓库中 MSW 以 devDependencies 形式引入,版本固定为 msw@2.15.0(见 package.json),并配套依赖 @mswjs/socket.io-binding@0.2.0 用于 Socket.IO 场景的模拟。
二、Mock 体系的文件地图
__tests__/MSW.md 明确了体系中的关键文件,结合源码可以还原完整的调用结构:
| 文件 | 职责 |
|---|---|
| src/mocks/handlers.ts | 主 handler 注册表,聚合全部领域 handler |
| src/mocks/*-handlers.ts | 领域级 handler 文件(auth、conversation、settings 等) |
| src/mocks/browser.ts | 浏览器端 setupWorker 入口(开发模式用) |
| src/mocks/node.ts | Node 端 setupServer 入口(测试用) |
| vitest.setup.ts | 全局测试环境,管理 MSW server 生命周期 |
| src/mocks/should-start-mock-worker.ts | 是否启动浏览器 mock worker 的开关判断 |
| __tests__/helpers/msw-websocket-setup.ts | WebSocket 测试工具函数 |
2.1 主注册表:handlers.ts
handlers.ts 将 13 组领域 handler 聚合为一个 handlers 数组,覆盖该前端的主要后端域:文件服务、密钥、Agent Profiles、Git 仓库、设置、会话、认证、反馈、分析(analytics)、自动化(automation)、MCP、工作区、Canvas 扩展。
值得注意的是,它除了导出 handlers,还统一再导出了若干测试专用能力:
MOCK_DEFAULT_USER_SETTINGS:默认用户设置 mock 数据,供测试直接复用;resetTestHandlersMockSettings、resetAutomationMockData、resetMockWorkspaces、resetCanvasExtensionsMockData:各领域的 mock 状态重置函数;AGENT_PROFILES_HANDLERS、resetMockAgentProfiles、seedMockAgentProfiles:Agent Profiles 的手动播种与重置。
这说明项目里不少 handler 是有状态的(在内存中维护一份可变数据),因此需要配套的重置/播种函数来保证测试间隔离——这是"Export mock data from handler files"这一最佳实践的落地体现。
2.2 两个入口文件极其克制
browser.ts 和 node.ts 各自只有三行,分别是一行 setupWorker(...handlers) / setupServer(...handlers)。这种设计确保了:handler 逻辑只存在于 handlers.ts 及其领域文件中,环境差异完全收敛在两个入口,测试代码统一从 node.ts 导入同一个 server 实例。
三、开发工作流:dev:mock 背后的完整链路
3.1 启动命令
# Run with API mocking enabled
npm run dev:mock
对照 package.json 中的脚本定义,实际执行的是:
npm run make-i18n && cross-env VITE_MOCK_API=true react-router dev
即:先生成 i18n 翻译,再通过 cross-env 注入 VITE_MOCK_API=true 环境变量启动 Vite 开发服务器。此外还有一个对应的构建脚本 build:mock(cross-env VITE_MOCK_API=true react-router build),可用于产出带 mock 的静态构建。
3.2 环境变量如何变成 Service Worker 启动
VITE_MOCK_API=true 本身不做任何事,它只通过下面这条链路生效:
- 开关判断——should-start-mock-worker.ts 只有 8 行,判断条件为
hasWindow && mockApi === "true"。注意两个要点:- 比较的是字符串
"true"而非布尔值,这与 Vite 注入import.meta.env的字符串化行为一致; - 额外检查
typeof window !== "undefined",保证 SSR/构建阶段(无 window)绝不会误启动 worker。
- 比较的是字符串
- 入口启动——entry.client.tsx 在
prepareApp()中完成 i18n 加载后,若开关为真,则动态import("./mocks/browser")并执行worker.start({ onUnhandledRequest: "bypass" })。动态导入保证了非 mock 模式下 MSW 相关代码不进入主 bundle;onUnhandledRequest: "bypass"则让未被 mock 的请求透传到真实后端,因此 mock 模式并非"全量假数据",而是已写 handler 的端点走 mock、其余走真实 API。 - Worker 文件——
package.json中的msw.workerDirectory: ["public"]指定了 Service Worker 文件目录,对应的public/mockServiceWorker.js正是浏览器实际注册的文件。
从源码结构看,这条链路意味着:只要把 VITE_MOCK_API 设为字符串 "true",且运行在浏览器环境,mock 即自动接管,应用其他代码零改动。
四、测试侧:vitest.setup.ts 中的全局 Server 生命周期
文档强调的一条重要规则是:复用全局 server 实例,不要在单个测试里新建 setupServer()。其实现位于 vitest.setup.ts,核心生命周期为:
import { server } from "#/mocks/node";
beforeAll(() => {
server.listen({ onUnhandledRequest: "bypass" });
});
afterEach(async () => {
server.resetHandlers(); // 每个测试后清掉 server.use() 注入的临时 handler
// ...cleanup、微任务排空
});
afterAll(async () => {
server.resetHandlers();
// 排空 MSW 挂起的 respondWith 回调后
server.close();
});
几个值得注意的工程细节:
onUnhandledRequest: "bypass":与开发模式保持一致,测试中未被 mock 的 XHR 不会报错而是直接放行(在 jsdom 中最终失败),避免 mock 成为隐式的全局断言;resetHandlers()放在afterEach:这保证了下一节讲的server.use()临时 handler 具有测试级作用域,不会跨测试泄漏;afterAll中的"drain"逻辑:文件里有大段注释解释了一个真实踩过的坑——MSW 拦截 XHR 后是异步解析响应的,若某个迟到的respondWith回调(例如 PostHog analytics 的上报)在 jsdom 环境 teardown 之后才执行,会因ProgressEvent全局已被 Vitest 删除而抛出ReferenceError: ProgressEvent is not defined,被 Vitest 记为 unhandled rejection 导致整个 run 失败。修复方式是在afterAll(此时 jsdom 仍存活)先恢复真实定时器、重置 handler,然后跑 30 个 0ms 的 macrotask 让挂起回调全部落地,再server.close()。这是一个"网络层 mock 与测试环境 teardown 竞态"的典型治理案例,对使用 MSW 2.x + Vitest 的项目很有参考价值。
五、两种 mock 策略:服务层为主,网络层为辅
5.1 服务层 mock(推荐,绝大多数测试适用)
文档给出的推荐做法是:对 service 静态方法用 vi.spyOn,显式、作用域清晰、意图直白:
import { vi } from "vitest";
import SettingsService from "#/api/settings-service/settings-service.api";
const getSettingsSpy = vi.spyOn(SettingsService, "getSettings");
getSettingsSpy.mockResolvedValue({
llm_model: "openai/gpt-4o",
llm_api_key_set: true,
// ... other settings
});
成功场景用 mockResolvedValue,错误场景用 mockRejectedValue:
getSettingsSpy.mockRejectedValue(new Error("Failed to fetch settings"));
文档指向的真实示例是 __tests__/routes/llm-settings.test.tsx。该测试的写法很有代表性:它从 handlers.ts 导入 MOCK_DEFAULT_USER_SETTINGS,用 buildSettings(overrides) 帮助函数在默认 mock 数据之上做局部覆写,再传给 SettingsService 的 spy——mock handler 文件导出的数据与测试数据源共用一份,保证了"开发期看到的假数据"与"测试断言的假数据"同源,这正是"Keep mocks close to real API contracts"的实操形态。
5.2 网络层 mock(进阶:需要真实网络行为时)
当测试需要真实网络层语义时——WebSocket、重试逻辑、错误状态码处理——才使用 server.use() 做测试级 handler 覆写:
import { http, HttpResponse } from "msw";
import { server } from "#/mocks/node";
it("should handle server errors", async () => {
server.use(
http.get("/api/my-endpoint", () => {
return new HttpResponse(null, { status: 500 });
}),
);
// ... test code
});
配合 vitest.setup.ts 中的 afterEach 重置,server.use() 注入的 handler 只在该测试内生效,之后自动回退到 handlers.ts 的全局默认集。
5.3 WebSocket 测试工具
msw-websocket-setup.ts 封装了 MSW 的 ws 命名空间:
createWebSocketLink(url):基于ws.link()创建 WebSocket 链接,默认ws://localhost/events/socket;createWebSocketMockServer(wsLink):创建一个只处理connection事件并立即server.connect()的最小 server;conversationWebSocketTestSetup():针对会话 WebSocket 的 V1 URL 模式ws://localhost:3000/sockets/events/{conversationId},使用通配符ws://localhost:3000/sockets/events/*匹配任意会话 ID,可直接用于会话事件处理器的测试。
六、为新 API 端点添加 Mock:双落点规范
文档规定新增端点时要在两个地方补齐 mock,以与后端保持 1:1 对齐:
6.1 第一步:在 src/mocks/ 中新增领域 handler(开发用)
// src/mocks/my-feature-handlers.ts
import { http, HttpResponse } from "msw";
export const MY_FEATURE_HANDLERS = [
http.get("/api/my-feature", () => {
return HttpResponse.json({
data: "mock response",
});
}),
];
然后注册进 handlers.ts:
import { MY_FEATURE_HANDLERS } from "./my-feature-handlers";
export const handlers = [
// ... existing handlers
...MY_FEATURE_HANDLERS,
];
一个可参考的成熟 handler 是 settings-handlers.ts:它直接从 #/services/settings 导入 DEFAULT_SETTINGS 作为默认模型值来源(而不是硬编码模型名),并提供 createMockWebClientConfig(overrides) 之类的工厂函数支持测试覆写——handler 数据尽量锚定真实默认值与类型(WebClientConfig、Settings),从根上避免 mock 与后端契约漂移。
6.2 第二步:在测试中按场景控制响应
import { vi } from "vitest";
import MyFeatureService from "#/api/my-feature-service.api";
const spy = vi.spyOn(MyFeatureService, "getData");
spy.mockResolvedValue({ data: "test-specific response" });
开发侧 handler 提供"全局默认假数据",测试侧 spy 提供"用例级精确控制",两者职责分离。文档同时提示:service API 本身的创建规范参见 src/api/README.md。
七、最佳实践清单(结合仓库落地)
文档给出的四条实践,在仓库中都能找到对应证据:
- 保持 mock 与真实 API 契约一致——mock 随后端变更同步更新。仓库佐证:handlers.ts 再导出的
reset*系列函数与领域 handler 一一对应,说明 mock 状态是被当作"需要维护的资产"对待的。 - 大多数测试使用服务层 mock——更简单、更显式。仓库中
__tests__/下绝大多数测试(如 llm-settings.test.tsx)走的就是vi.spyOn路径。 - 网络层 mock 留给集成测试——WebSocket、重试逻辑等。对应设施即 vitest.setup.ts 的全局 server + msw-websocket-setup.ts。
- 从 handler 文件导出 mock 数据供测试复用——
MOCK_DEFAULT_USER_SETTINGS是典型样例:它在 settings-handlers.ts 定义、经 handlers.ts 再导出、在测试中经#/mocks/handlers路径导入复用。
八、适用前提与边界
- 本文描述的行为均基于当前仓库状态(
@openhands/agent-canvas,msw@2.15.0、vitest@4.1.10,要求 Node >= 22.12.0); dev:mock模式下onUnhandledRequest为bypass,因此未写 handler 的端点仍会尝试访问真实后端,mock 模式不是完全离线的假数据环境;- 测试中请始终使用 vitest.setup.ts 管理的全局
server(来自 src/mocks/node.ts),避免自建setupServer()造成生命周期失控; - 领域 handler 若维护内存状态,记得利用对应的
reset*函数或依赖afterEach的server.resetHandlers()保证测试隔离。
按上述体系工作,你可以只写一次 handler 就同时获得"后端缺席时的完整前端开发体验"与"无网络依赖的确定性测试",这正是 MSW 网络层拦截在该仓库中的核心价值。
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