MSW 在网络层拦截而非客户端 Patch,同一套 handler 可跨 Node 浏览器测试。关键原则:浏览器应调用自己的后端接口(/api/chat),而非直接请求 OpenAI,避免密钥泄露。
MSW 在网络层进行拦截,而不是通过修补客户端来实现,因此同一套 handlers 可以服务于 Node 中的 Vitest 跑、真实浏览器中的 Playwright 跑,以及你的开发服务器。第一个要做的决定不是怎么写一个 handler,而是你要为哪个请求写它。
大多数指南会从在浏览器测试中拦截 https://api.openai.com/v1/chat/completions 开始。在照搬之前,先检查你的应用是否真的从浏览器发出那个请求,因为如果真的发了,你要解决的问题就不只是测试了:任何可以从页面 JavaScript 触及的 provider key,都可以被任何打开 dev tools 的人读取,而且费用也由他们来付。
在一个正确构建的应用中,浏览器调用的是你自己的端点——POST /api/chat——而你的服务器持有 key 并调用 provider。你的浏览器测试应该 mock 的边界就是这一个。这也是一个更有用的测试边界:这是你的契约,你拥有它的形状,对它的修改是你自己做出的,而不是 vendor 做出的。
provider URL 仍有两种真实场景值得拦截。一种是本地或自托管的 OpenAI 兼容服务器运行在 localhost 上,这种情况下没有密钥泄露的风险。另一种是在 Node 端测试你的服务器路由,使用 msw/node 的 setupServer,此时请求确实会发到 provider。handler 语法是一样的,只是 URL 和 setup 函数不同。
MSW v2 用 Fetch API 替换了 v1 的 rest 命名空间和 (req, res, ctx) 解析器签名:handlers 来自 http,responses 来自 HttpResponse,解析器接收一个包含标准 Request 的对象。你找到的任何使用 rest.post 和 res(ctx.json(...)) 的代码片段都是 v1 版本,将无法运行。MSW 的 response resolver 文档是当前的参考文档。
// tests/handlers.ts
import { http, HttpResponse } from "msw";
export const handlers = [
http.post("/api/chat", async ({ request }) => {
const body = await request.json();
// The assertion lives here: the component sent what it should.
if (!Array.isArray(body.messages) || body.messages.length === 0) {
return new HttpResponse("no messages", { status: 400 });
}
return HttpResponse.json({
role: "assistant",
content: "The order shipped on Tuesday.",
finishReason: "stop",
});
}),
];
// tests/setup.ts (browser)
import { setupWorker } from "msw/browser";
export const worker = setupWorker(...handlers);
// tests/setup.ts (node)
import { setupServer } from "msw/node";
export const server = setupServer(...handlers);
从 handler 内部返回一个 400 是一个有用的模式。它把"组件发送了一个格式错误的请求"变成了你正在测试的 UI 中的一个可见错误状态,而不是一个静默的通过,同时也能锻炼你的错误渲染。
流式响应是浏览器测试发挥价值的地方,因为你想要检查的是一个渲染行为:文本是否逐步出现,stop 按钮在流式传输中途是否有效,被中止的请求是否让输入框保持禁用状态。MSW 以 ReadableStream 作为响应体返回,这样你可以控制每个 chunk 何时到达。
import { http, HttpResponse, delay } from "msw";
http.post("/api/chat", () => {
const encoder = new TextEncoder();
const stream = new ReadableStream({
async start(controller) {
for (const piece of ["The order ", "shipped on ", "Tuesday."]) {
controller.enqueue(encoder.encode(`data: ${JSON.stringify({ delta: piece })}\n\n`));
await delay(50);
}
controller.enqueue(encoder.encode("data: [DONE]\n\n"));
controller.close();
},
});
return new HttpResponse(stream, {
headers: { "content-type": "text/event-stream" },
});
});
chunk 之间的 await 是使这成为增量渲染测试而非单次绘制测试的关键。没有 delay,整个流会在一个微任务中到达,任何断言部分文本可见的测试都会通过,无论你的组件是否真的在流式传输。
在浏览器中,MSW 是一个真正的 Service Worker,从一个你用 MSW 的 init 命令生成到 public 目录的文件注册。注册是异步的,而 service worker 无法拦截一个在它激活之前发出的请求。因此一个在挂载 effect 中调用 API 的组件可能抢在 worker 之前,请求逃逸到真实网络,测试失败时出现的错误看起来一点都不像 mock 问题。
在任何东西渲染之前 await worker 的 start()。在组件测试中,这意味着一个 async setup hook,而不是在模块作用域中的一个 fire-and-forget 调用。
在 Playwright 中,从应用入口点在测试标记下启动 worker,并让应用在这个 promise 上等待再进行首次渲染。从测试文件中启动就太晚了:页面已经加载了。
确认生成的 worker 脚本正被托管在应用期望的路径上。对它的 404 会产生与竞争完全相同的症状,很容易混淆;浏览器控制台可以告诉你具体是哪个。
在测试之间重置 handlers,并在最后停止 worker,这样每个测试的覆盖就不会泄漏到下一个文件。
默认情况下,一个未处理的请求会带着警告穿透到真实网络。在测试运行中这是一个错误的默认设置:千行日志中的一个警告是不可见的,而请求很可能是一个付费请求。用 onUnhandledRequest 设置为 "error" 开始,这样任何你忘记 stub 的东西都会让测试失败并报出 URL。
这一个设置就把 MSW 从一个便利工具变成了一个保证,它是 nock 页面描述的 nock.disableNetConnect() 的浏览器对应物。如果你的应用合法地加载字体或分析工具,为那些添加显式的 passthrough handlers,而不是全局放松这个设置——例外应该是一个你可以阅读的列表。
还有一个设置值得在需要之前了解。Handlers 可以被每个测试覆盖,所以常见模式是在 setup 中放一个默认的 happy-path handler,然后在一个需要 429、500 或中途停止的流的测试内部放一个狭窄的覆盖。每个测试后重置,这样覆盖不会存活到下一个文件。这给了大多数测试套件最终想要的形状:一个地方描述 API 通常做什么,而在每个关于出错的测试附近有一个可见的、本地的异常。
而且因为同一套 handler 数组在 Node 中为 setupServer 供源,你在浏览器测试中断言的契约就是你的服务端测试所运行击中的同一对象。这比听起来更有价值:一个 mocked 前端和一个真实后端不一致的常见原因是有人从文档写了 mock,而从 ticket 写了 endpoint。保持一个 handler 文件意味着响应形状的修改同时破坏两端,在同一个 commit 中,这是任何人可能发现问题的最早时间。
MSW 在 v1 和 v2 之间对其 handler 和 response API 做了一个破坏性更改,当你升级时必须重新生成 worker 脚本。在信任任何示例之前检查你 lockfile 中的版本,包括这一个。
Recording and Replaying OpenAI API Calls With nock
Recording Streaming Responses as Test Fixtures
Stubbing an LLM Client Without a Mocking Library