首页
/ OpenHands Agent Canvas 的 MSW API 模拟实战:从 dev:mock 前端开发到 Vitest 全局 Mock Server 生命周期管理

OpenHands Agent Canvas 的 MSW API 模拟实战:从 dev:mock 前端开发到 Vitest 全局 Mock Server 生命周期管理

2026-09-04 16:35:35作者:廉彬冶Miranda

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 fetchaxios 实例)不同,MSW 在网络层拦截出站请求:

  • 浏览器环境:通过 Service Worker 拦截,应用代码完全无感知;
  • Node.js 环境(如 Vitest + jsdom):通过直接请求拦截实现同等能力。

这种透明性带来的核心收益是:同一份 mock handler 可以在开发和测试中复用一次编写、处处生效。该仓库将 MSW 用于两个场景:

  1. 测试:编写可靠的单元/集成测试,无需真实网络调用;
  2. 开发:在后端不可用或后端 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 数据,供测试直接复用;
  • resetTestHandlersMockSettingsresetAutomationMockDataresetMockWorkspacesresetCanvasExtensionsMockData:各领域的 mock 状态重置函数;
  • AGENT_PROFILES_HANDLERSresetMockAgentProfilesseedMockAgentProfiles:Agent Profiles 的手动播种与重置。

这说明项目里不少 handler 是有状态的(在内存中维护一份可变数据),因此需要配套的重置/播种函数来保证测试间隔离——这是"Export mock data from handler files"这一最佳实践的落地体现。

2.2 两个入口文件极其克制

browser.tsnode.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:mockcross-env VITE_MOCK_API=true react-router build),可用于产出带 mock 的静态构建。

3.2 环境变量如何变成 Service Worker 启动

VITE_MOCK_API=true 本身不做任何事,它只通过下面这条链路生效:

  1. 开关判断——should-start-mock-worker.ts 只有 8 行,判断条件为 hasWindow && mockApi === "true"。注意两个要点:
    • 比较的是字符串 "true" 而非布尔值,这与 Vite 注入 import.meta.env 的字符串化行为一致;
    • 额外检查 typeof window !== "undefined",保证 SSR/构建阶段(无 window)绝不会误启动 worker。
  2. 入口启动——entry.client.tsxprepareApp() 中完成 i18n 加载后,若开关为真,则动态 import("./mocks/browser") 并执行 worker.start({ onUnhandledRequest: "bypass" })。动态导入保证了非 mock 模式下 MSW 相关代码不进入主 bundle;onUnhandledRequest: "bypass" 则让未被 mock 的请求透传到真实后端,因此 mock 模式并非"全量假数据",而是已写 handler 的端点走 mock、其余走真实 API
  3. 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 数据尽量锚定真实默认值与类型(WebClientConfigSettings),从根上避免 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

七、最佳实践清单(结合仓库落地)

文档给出的四条实践,在仓库中都能找到对应证据:

  1. 保持 mock 与真实 API 契约一致——mock 随后端变更同步更新。仓库佐证:handlers.ts 再导出的 reset* 系列函数与领域 handler 一一对应,说明 mock 状态是被当作"需要维护的资产"对待的。
  2. 大多数测试使用服务层 mock——更简单、更显式。仓库中 __tests__/ 下绝大多数测试(如 llm-settings.test.tsx)走的就是 vi.spyOn 路径。
  3. 网络层 mock 留给集成测试——WebSocket、重试逻辑等。对应设施即 vitest.setup.ts 的全局 server + msw-websocket-setup.ts
  4. 从 handler 文件导出 mock 数据供测试复用——MOCK_DEFAULT_USER_SETTINGS 是典型样例:它在 settings-handlers.ts 定义、经 handlers.ts 再导出、在测试中经 #/mocks/handlers 路径导入复用。

八、适用前提与边界

  • 本文描述的行为均基于当前仓库状态(@openhands/agent-canvasmsw@2.15.0vitest@4.1.10,要求 Node >= 22.12.0);
  • dev:mock 模式下 onUnhandledRequestbypass,因此未写 handler 的端点仍会尝试访问真实后端,mock 模式不是完全离线的假数据环境;
  • 测试中请始终使用 vitest.setup.ts 管理的全局 server(来自 src/mocks/node.ts),避免自建 setupServer() 造成生命周期失控;
  • 领域 handler 若维护内存状态,记得利用对应的 reset* 函数或依赖 afterEachserver.resetHandlers() 保证测试隔离。

按上述体系工作,你可以只写一次 handler 就同时获得"后端缺席时的完整前端开发体验"与"无网络依赖的确定性测试",这正是 MSW 网络层拦截在该仓库中的核心价值。

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

项目优选

收起
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
980
502
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
540
384