OpenHands 前端 WebSocket 测试实践:基于 __tests__/helpers 的可复用测试组件与 MSW 模拟搭建
OpenHands 前端(React + Vite)中有大量围绕 WebSocket 的交互逻辑——事件流接收、消息乐观更新、连接状态展示、错误提示——这些逻辑很难通过普通的服务层 mock 验证。本仓库在 tests/helpers 目录中沉淀了一套可复用的 WebSocket 测试工具:一组把 Zustand store 与 Context 状态“渲染出来”的测试组件,以及一套基于 MSW(Mock Service Worker)的 WebSocket 模拟搭建函数。读完本文,你能够掌握:如何用“显示型测试组件”把难以断言的 store 状态转成 DOM 断言,以及如何用 MSW 2.x 的 ws.link API 在 Vitest 中搭建可控的 WebSocket 服务器来验证事件处理链路。
目录结构:一个只服务测试的 helpers 目录
__tests__/helpers/ 目录下只有两个核心文件,均与前端测试套件直接相关:
- websocket-test-components.tsx —— 用于读取并显示 WebSocket 相关 store 值的 React 测试组件;
- msw-websocket-setup.ts —— 基于 MSW 的 WebSocket 测试模拟工具函数。
这两个文件共同覆盖了 WebSocket 测试的两端:一端是“被测代码”(WebSocket 客户端与 store 更新逻辑),另一端是“被测结果的读取”(通过渲染组件 + data-testid 断言 DOM)。
显示型测试组件:把 store 状态变成可断言的 DOM
websocket-test-components.tsx 导出了 4 个组件,每个组件订阅一个真实的 Context 或 Zustand store,并把关键状态渲染为带 data-testid 的 DOM 节点。测试侧可以用 Testing Library 直接 getByTestId 读取数值,从而把“内存中的 store 状态”转成“可断言的 DOM 文本”。这种模式的价值在于:它复用了生产代码里真正的 Context 与 store,而不是重新实现一份假的。
ConnectionStatusComponent —— 连接状态
export function ConnectionStatusComponent() {
const context = useConversationWebSocket();
return (
<div>
<div data-testid="connection-state">
{context?.connectionState || "NOT_AVAILABLE"}
</div>
</div>
);
}
该组件通过 src/contexts/conversation-websocket-context.tsx 导出的 useConversationWebSocket Hook 读取当前 WebSocket 连接状态。注意其兜底逻辑:Context 不可用时渲染 "NOT_AVAILABLE",这样在 Provider 缺失的测试场景下也能得到确定性输出,而不是空白。
EventStoreComponent —— 事件 store 快照
export function EventStoreComponent() {
const { events, uiEvents } = useEventStore();
return (
<div>
<div data-testid="events-count">{events.length}</div>
<div data-testid="ui-events-count">{uiEvents.length}</div>
<div data-testid="latest-event-id">
{isAgentServerEvent(events[events.length - 1])
? (events[events.length - 1] as OpenHandsEvent).id
: "none"}
</div>
</div>
);
}
它一次性暴露三个断言点:
events-count:事件 store 中事件的总数;ui-events-count:UI 事件的数量;latest-event-id:最后一条事件若是 Agent Server 事件(通过src/types/agent-server/type-guards中的isAgentServerEvent类型守卫判断),则显示其id,否则显示"none"。
其中“最新事件 ID”这一项对 WebSocket 测试特别有用:当模拟服务器推入一条带 id 的事件后,测试只需断言 latest-event-id 的值,就能确认事件被完整消费并写入了 store,无需深入遍历整个数组。
OptimisticUserMessageStoreComponent —— 乐观消息队列
export function OptimisticUserMessageStoreComponent() {
const { pendingMessages } = useOptimisticUserMessageStore();
return (
<div>
<div data-testid="optimistic-user-message">
{pendingMessages[0]?.text || "none"}
</div>
<div data-testid="optimistic-user-message-count">
{pendingMessages.length}
</div>
<div data-testid="optimistic-user-message-statuses">
{pendingMessages.map((m) => m.status).join(",") || "none"}
</div>
</div>
);
}
OpenHands 在用户发送消息后、服务端回显之前,会先在本地维护一个“乐观消息”队列。这个组件把队列的头部文本、长度和全部状态(逗号拼接)都渲染出来,因此测试可以覆盖三类典型场景:发送后消息进入 pending、服务端回显后队列清空、以及状态流转(如从 pending 变为 sent)。
ErrorMessageStoreComponent —— 错误信息
export function ErrorMessageStoreComponent() {
const { errorMessage } = useErrorMessageStore();
return (
<div>
<div data-testid="error-message">{errorMessage || "none"}</div>
</div>
);
}
用于断言连接失败或事件处理失败时,错误信息 store 被正确写入(或保持为 "none")。
四个组件的 data-testid 汇总如下,可直接用于测试断言:
| 组件 | data-testid | 含义 |
|---|---|---|
| ConnectionStatusComponent | connection-state |
WebSocket 连接状态,缺省显示 NOT_AVAILABLE |
| EventStoreComponent | events-count / ui-events-count / latest-event-id |
事件总数 / UI 事件数 / 最新 Agent Server 事件 ID |
| OptimisticUserMessageStoreComponent | optimistic-user-message / optimistic-user-message-count / optimistic-user-message-statuses |
首条待回显消息文本 / 队列长度 / 各消息状态 |
| ErrorMessageStoreComponent | error-message |
当前错误信息,无错误时显示 none |
MSW WebSocket 模拟工具:msw-websocket-setup.ts
msw-websocket-setup.ts 基于 MSW 2.x(本仓库锁定版本为 2.15.0,见 package.json)的 ws.link API 封装了 4 个工具函数,形成“link → server → setup → 场景预设”的分层结构。
第一层:createWebSocketLink
export const createWebSocketLink = (url = "ws://localhost/events/socket") =>
ws.link(url);
ws.link(url) 创建一个指向目标 URL 的 MSW WebSocket 链接,测试中可以向该 link 注册事件监听,模拟服务器向客户端推送数据。默认 URL 为 ws://localhost/events/socket,可传参覆盖。
第二层:createWebSocketMockServer
export const createWebSocketMockServer = (wsLink: ReturnType<typeof ws.link>) =>
setupServer(
wsLink.addEventListener("connection", ({ server }) => {
server.connect();
}),
);
这里用 setupServer 创建一个仅服务于 WebSocket 的 MSW 服务器,并注册了一个 connection 监听器:每当客户端连接到这个 link,就调用 MSW 提供的 server.connect(),让服务器侧也完成握手。这一步是 WebSocket 模拟能跑通的关键——HTTP mock 是“请求-响应”模型,而 WebSocket 是长连接,必须显式完成双方连接。
第三层:createWebSocketTestSetup
export const createWebSocketTestSetup = (
url = "ws://localhost/events/socket",
) => {
const wsLink = createWebSocketLink(url);
const server = createWebSocketMockServer(wsLink);
return { wsLink, server };
};
把前两层组合成一个完整搭建,返回 { wsLink, server },测试文件中通常用它替代手写的 ws.link + setupServer 样板代码。
场景预设:conversationWebSocketTestSetup
export const conversationWebSocketTestSetup = () =>
createWebSocketTestSetup("ws://localhost:3000/sockets/events/*");
这是专为“会话级 WebSocket handler 测试”准备的标准入口。源码注释明确说明:它采用 V1 版本的 URL 模式 /sockets/events/{conversationId},并使用通配符 * 匹配任意会话 ID。这意味着无论测试中切换到哪个 conversation,只要 URL 前缀一致,mock 就能命中,无需为每个会话 ID 单独创建 link。
与全局 MSW 服务器的关系
需要注意的是,本仓库的 Vitest 全局配置 vitest.setup.ts 已经基于 src/mocks/node.ts 导出的 server 管理了 HTTP mock 的生命周期(listen / resetHandlers / close)。MSW 指南 中也明确建议:HTTP 请求应优先用 server.use() 复用全局服务器,而“需要真实网络层行为(WebSocket、重试逻辑等)的测试”才走网络层模拟,并指引开发者到 __tests__/helpers/msw-websocket-setup.ts 查找工具函数。也就是说,这套 helper 定位就是 MSW 体系中“WebSocket 网络层测试”的专用入口,与全局 HTTP mock 服务器并存、各司其职。
在测试中使用:从 helpers 到断言
README 中给出的标准用法如下(导入路径按仓库内实际位置书写):
import {
ConnectionStatusComponent,
EventStoreComponent,
} from "./__tests__/helpers/websocket-test-components";
import { conversationWebSocketTestSetup } from "./__tests__/helpers/msw-websocket-setup";
// Set up MSW server
const { wsLink, server } = conversationWebSocketTestSetup();
// Render components with WebSocket context (helper function defined in test file)
renderWithWebSocketContext(<ConnectionStatusComponent />);
典型的使用模式分四步:
- 在
beforeEach中调用conversationWebSocketTestSetup()拿到wsLink与server,并server.listen(); - 渲染被测的 WebSocket Provider,同时渲染 websocket-test-components.tsx 中的显示组件;
- 通过
wsLink模拟服务器推送事件(向客户端发送 JSON 消息),或等待客户端发送消息; - 用
getByTestId("events-count")等断言 DOM 中的 store 快照值,最后server.close()。
仓库中的 conversation-websocket-context.test.tsx 展示了当前测试套件的另一种风格:直接 vi.mock 掉 useWebSocket 传输层,手动触发 onMessage 回调来驱动真实 Provider 与事件 store。从源码结构看,随着 WebSocket 传输实现(V1 URL 模式、重连参数等)的演进,仓库对 WebSocket 的测试策略在“mock 传输层”与“网络层 MSW 模拟”之间并存选择——helper 目录提供的是后者所需的基础设施。use-websocket.test.ts 中也能看到直接使用 ws.link("ws://acme.com/ws") 加 setupServer 的网络层测试写法,与 helper 封装的形态一致。
为什么抽出这套 helpers
README 总结了四点收益,结合仓库现状逐条可以印证:
- 可复用(Reusability):显示组件与 MSW 搭建函数被设计为跨测试文件共享。显示组件不依赖任何特定测试数据,任何需要观察这四个 store 的测试都可以直接复用;
- 可维护(Maintainability):WebSocket mock 的搭建逻辑(默认 URL、
connection握手)集中在一处。例如 V1 URL 模式从旧路径切换到/sockets/events/{conversationId}通配模式时,只需修改 msw-websocket-setup.ts 中conversationWebSocketTestSetup的一行,而不必逐个测试文件改 URL; - 一致性(Consistency):不同 WebSocket 相关测试使用同一套默认 URL 与服务器配置,避免了各测试文件手写
setupServer时的细微差异导致的行为不一致; - 可读性(Readability):测试文件从搭建样板中解放出来,聚焦于“推入什么事件、断言什么状态”这一核心测试逻辑。
参考文件
| 文件 | 说明 |
|---|---|
| tests/helpers/README.md | helpers 目录说明(本文依据的原始文档) |
| tests/helpers/websocket-test-components.tsx | 4 个显示型测试组件实现 |
| tests/helpers/msw-websocket-setup.ts | MSW WebSocket 模拟工具函数 |
| tests/MSW.md | 项目 MSW 使用指南,含服务层 vs 网络层 mock 的选型建议 |
| vitest.setup.ts | Vitest 全局 MSW 生命周期配置 |
| tests/contexts/conversation-websocket-context.test.tsx | WebSocket 上下文测试的实际用例 |
| tests/hooks/use-websocket.test.ts | 使用 ws.link 网络层模拟的 Hook 测试 |
适用前提与限制:本文所述 API 基于 MSW 2.15.0 的 ws.link / setupServer 接口与 Vitest 环境,WebSocket mock 依赖 Node 端的请求拦截能力,仅在单元测试(vitest)场景可用;浏览器开发模式的 mock 走的是 src/mocks/browser.ts 的 Service Worker 路径,与本文讨论的测试工具不直接相关。
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