Mocking the OpenAI API With MSW in a Browser Test
10 min read · updated August 11, 2026
MSW intercepts at the network layer rather than by patching a client, so the same handlers serve a Vitest run in Node, a Playwright run in a real browser, and your development server. The first decision is not how to write a handler. It is which request you should be writing one for.
Which URL are you mocking?
Most guides to this open by intercepting https://api.openai.com/v1/chat/completions from a browser test. Before copying that, check whether your application actually makes that request from a browser, because if it does you have a bigger problem than testing: any provider key reachable from page JavaScript is readable by anyone who opens dev tools, and it is billable by them too.
In a correctly built application the browser calls your own endpoint — POST /api/chat — and your server holds the key and calls the provider. The boundary your browser test should mock is that one. It is also the more useful boundary to test: it is your contract, you own its shape, and a change to it is a change you made rather than one a vendor made.
The provider URL is still worth intercepting in two real cases. One is a local or self-hosted OpenAI-compatible server on localhost, where there is no secret to leak. The other is a Node-side test of your server route using setupServer from msw/node, where the request genuinely does go to the provider. The handler syntax is identical; only the URL and the setup function differ.
The handler, in MSW v2 syntax
MSW v2 replaced the v1 rest namespace and the (req, res, ctx) resolver signature with the Fetch API: handlers come from http, responses from HttpResponse, and the resolver receives an object containing a standard Request. Any snippet you find using rest.post and res(ctx.json(...)) is v1 and will not run. The MSW response resolver documentation is the current reference.
// 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);Returning a 400 from inside the handler is a useful pattern. It turns “the component sent a malformed request” into a visible error state in the UI you are testing, rather than a silent pass, and it exercises your error rendering at the same time.
Streaming a response back
Streaming is where a browser test earns its keep, because the thing you want to check is a rendering behaviour: does text appear incrementally, does the stop button work mid-stream, does an aborted request leave the input disabled. MSW returns a ReadableStream as the response body, so you control when each chunk arrives.
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" },
});
});The await between chunks is what makes this a test of incremental rendering rather than of a single paint. Without a delay the whole stream lands in one microtask and an assertion that partial text was visible will pass whether or not your component streams at all.
The worker startup race
In the browser, MSW is a real Service Worker, registered from a file you generate into your public directory with MSW’s init command. Registration is asynchronous, and a service worker cannot intercept a request that was fired before it activated. So a component that calls the API in its mount effect can beat the worker, the request escapes to the real network, and the test fails with an error that looks nothing like a mocking problem.
- Await the worker’s
start()before you render anything. In a component test that means an async setup hook, not a fire-and-forget call at module scope. - In Playwright, start the worker from the application entry point under a test flag, and make the app wait on that promise before its first render. Starting it from the test file is too late: the page has already loaded.
- Confirm the generated worker script is being served at the path the app expects. A 404 on it produces exactly the same symptom as a race and is easy to mistake for one; the browser console names it.
- Reset handlers between tests and stop the worker at the end, so a per-test override does not leak into the next file.
Make an unhandled request fail
By default an unhandled request passes through to the real network with a warning. In a test run that is the wrong default: a warning in a thousand-line log is invisible, and the request may well be a billed one. Start with onUnhandledRequest set to "error" so anything you forgot to stub fails the test and names the URL.
That single setting is what turns MSW from a convenience into a guarantee, and it is the browser counterpart of nock.disableNetConnect() described on the nock page. If your application legitimately loads fonts or analytics, add explicit passthrough handlers for those rather than relaxing the setting globally — the exceptions should be a list you can read.
One more setting is worth knowing before you need it. Handlers can be overridden per test, so the common pattern is a default happy-path handler in setup and a narrow override inside the one test that needs a 429, a 500 or a stream that stops half way. Reset after each test so the override does not survive into the next file. That gives you the shape most suites end up wanting: one place describing what the API normally does, and a visible, local exception wherever a test is about something going wrong.
And because the same handler array feeds setupServer in Node, the contract you assert in a browser test is the same object your server-side tests run against. That is worth more than it sounds: the usual reason a mocked frontend and a real backend disagree is that somebody wrote the mock from the documentation and the endpoint from the ticket. Keeping one handler file means a change to the response shape breaks both sides at once, in the same commit, which is the earliest anyone could reasonably find out.