Prerequisites #
| Requirement | Version / setting | Why it matters |
|---|---|---|
msw |
2.x | The http/HttpResponse API differs substantially from 1.x |
| Node | 18+ | Native fetch interception in the Node runtime |
| Service worker file | Generated into the public directory | The browser runtime needs mockServiceWorker.js served from the app origin |
| Test setup files | Per runner | setupServer for Jest/Vitest, setupWorker for browser runs |
onUnhandledRequest policy |
'error' in CI |
Otherwise unmocked calls escape to the real network |
Where a suite already mocks at the framework layer, MSW does not have to replace it wholesale — pairing it with the recorded-payload approach in Record & Replay HTTP Traffic is common, with recordings supplying realistic bodies and MSW supplying the routing.
Step-by-step implementation #
1. Describe the API once #
Handlers are plain functions over a request, returning a response. Keep them in a shared module that both runtimes import.
// mocks/handlers.js
// Trade-off: a shared handler set is the whole point and it becomes a shared
// dependency — a careless edit affects every test level at once.
import { http, HttpResponse } from 'msw';
export const handlers = [
http.get('/api/invoices', ({ request }) => {
const status = new URL(request.url).searchParams.get('status');
return HttpResponse.json({
invoices: status === 'open' ? [{ id: 'INV-1', amount: 1250 }] : [],
});
}),
http.post('/api/invoices/:id/pay', async ({ params }) => {
if (params.id === 'INV-DECLINED') {
return HttpResponse.json({ error: 'card_declined' }, { status: 402 });
}
return HttpResponse.json({ id: params.id, status: 'paid' });
}),
];
2. Start the Node runtime for unit and integration tests #
setupServer intercepts requests made by the process, which covers component tests in a DOM environment as well as server-side code.
// vitest.setup.js
// Trade-off: onUnhandledRequest 'error' is strict and will fail on requests you
// forgot — which is exactly the feedback that keeps the handler set complete.
import { setupServer } from 'msw/node';
import { handlers } from './mocks/handlers.js';
export const server = setupServer(...handlers);
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => server.resetHandlers()); // undo per-test overrides
afterAll(() => server.close());
resetHandlers() in afterEach is not optional. Without it a handler added inside one test keeps answering in the next, which is precisely the shared-state problem described in Test Isolation & State Leakage.
3. Override per test, narrowly #
Scenario-specific behaviour belongs in the test that needs it, layered on top of the shared defaults rather than edited into them.
// Trade-off: overrides keep the shared handlers clean and are invisible to a
// reader who only looks at handlers.js — keep them short and local.
test('shows a retry affordance when the list fails', async () => {
server.use(
http.get('/api/invoices', () => HttpResponse.json({ error: 'boom' }, { status: 500 }))
);
render(<InvoiceList />);
expect(await screen.findByRole('alert')).toHaveTextContent(/could not load/i);
});
4. Serve the same handlers in the browser #
For Cypress and Playwright the handlers are served by a service worker registered inside the application. The worker file must be generated into the served directory and the worker must be started before the app makes its first request.
// mocks/browser.js
// Trade-off: a service worker is closer to real network behaviour than route
// interception and adds a registration lifecycle you must wait for.
import { setupWorker } from 'msw/browser';
import { handlers } from './handlers.js';
export const worker = setupWorker(...handlers);
// src/main.jsx — start it only in test/dev builds
if (import.meta.env.MODE !== 'production') {
const { worker } = await import('../mocks/browser.js');
await worker.start({ onUnhandledRequest: 'error' }); // await before rendering
}
Awaiting start() before the application renders is what prevents the first request from escaping the worker — the classic symptom of a “sometimes the mock does not apply” flake, and the same lifecycle problem that makes teardown subtle in Tearing Down MSW Service Workers Between Tests.
5. Fail loudly on anything unmocked #
onUnhandledRequest: 'error' is the setting that turns a handler set into a guarantee. Without it, a request nobody wrote a handler for goes to the real origin — passing locally, failing whenever that origin is unavailable.
// Trade-off: 'error' will fail on genuinely irrelevant traffic such as source
// maps or telemetry; allowlist those explicitly rather than relaxing the policy.
server.listen({
onUnhandledRequest(request, print) {
if (request.url.includes('/telemetry')) return; // deliberately ignored
print.error();
},
});
6. Model state carefully, or not at all #
Handlers are functions, so it is tempting to give them an in-memory store and let tests exercise create-read-update flows against it. That works, and it introduces the one thing mocking was supposed to remove: state shared between tests. A store declared at module scope is worker-wide, so a test that creates an invoice changes what every later test sees, and the failures move around when the order changes.
Two arrangements avoid it. The first is to keep handlers stateless and express variation through per-test overrides — the read returns whatever that test’s scenario needs, and no write is ever recorded. This is right for the large majority of component and page tests, which assert on rendering rather than on persistence.
The second is a store whose lifetime is a single test, created in a factory and installed through server.use(). The cost is a few more lines per test; the benefit is that the store cannot outlive the test that made it.
// Trade-off: a per-test store is more setup than a module-level one and it is
// the only version that cannot leak — the same argument as any other fixture.
function invoiceHandlers(initial = []) {
const store = [...initial]; // lives as long as the test
return [
http.get('/api/invoices', () => HttpResponse.json({ invoices: store })),
http.post('/api/invoices', async ({ request }) => {
const created = { id: `INV-${store.length + 1}`, ...(await request.json()) };
store.push(created);
return HttpResponse.json(created, { status: 201 });
}),
];
}
test('creating an invoice adds it to the list', async () => {
server.use(...invoiceHandlers()); // fresh store per test
// …
});
7. Know what MSW is not the right tool for #
MSW describes what an API returns. It is a poor fit for describing how the network behaves, and reaching for it there produces awkward tests.
Connection failures, aborted requests and hangs are expressed far more clearly with framework interception, where route.abort() and an unresolved handler say exactly what is happening — see Testing Offline and Connection Loss States. Artificial latency has the same problem: a delay inside a handler slows the suite and models a distribution badly, while a runner-level throttle models it properly, as in Simulating Slow 3G Conditions in Playwright.
Large realistic payloads are the third case. A handler returning a two-hundred-line inline object is unreadable and drifts from the real API; a recorded archive keeps the fidelity and the review signal. The healthiest arrangement in a mature suite is usually all three tools with clear boundaries: MSW for the API contract, recordings for realistic bodies, framework interception for network behaviour and per-spec edge cases.
Configuration reference #
| Option | Where | Accepted values | Default | Effect on reliability |
|---|---|---|---|---|
onUnhandledRequest |
listen / start |
'warn' | 'error' | 'bypass' | function |
'warn' |
'error' prevents silent live calls; 'bypass' guarantees them |
server.resetHandlers() |
afterEach |
— | not called | Removes per-test overrides; omitting it leaks scenarios between tests |
server.use() |
inside a test | handlers | — | Prepends handlers, so the most recent matching one wins |
worker.start() |
app bootstrap | awaited | not | not awaited | An unawaited start lets the first request escape |
quiet |
start |
true | false |
false |
Silences the console banner in CI logs |
waitUntilReady |
start |
true | false |
true |
Defers requests until the worker is active |
| Handler order | handlers.js |
array order | — | First match wins; a broad handler placed early shadows specific ones |
Data-driven analysis #
- Unhandled-request count. With
'error'this is zero by construction; while migrating, the count is a direct measure of how incomplete the handler set is, and it should trend to zero rather than being silenced. - Handler reuse ratio. The share of handlers used by more than one test level. A high ratio is the return on adopting MSW at all; a ratio near zero means you have three descriptions again, just in one file.
- Override density. Overrides per test file. A few are healthy — they express scenarios. Many suggest the defaults do not represent a realistic baseline, and the shared set needs rework.
- First-request escapes. Requests that occurred before the worker became active. Any non-zero value points at the bootstrap ordering rather than at the handlers, and it is the most common cause of intermittent browser-side mock failures.
- Handler-to-endpoint coverage. Endpoints the application calls versus endpoints the handler set describes. The gap is the set of paths where tests are silently exercising nothing.
Adopting it in an existing suite #
Introducing a shared mocking layer into a suite that already mocks three different ways is a migration, and treating it as one avoids the usual outcome — a fourth mocking approach sitting alongside the other three.
Start where the duplication hurts most, which is almost always a single API surface described in several places: the endpoint a component test stubs with a module mock, a Cypress spec stubs with cy.intercept, and a Playwright spec stubs with a route handler. Write handlers for that surface, wire the Node runtime into the unit tests, and delete the module mocks. That first slice proves the setup and produces an immediate, visible reduction in duplicated fixtures.
Take the browser runtime second, and expect the bootstrap ordering to be the awkward part rather than the handlers. Until worker.start() is awaited before the application renders, a small percentage of browser tests will see unmocked first requests, and the resulting flakiness will be blamed on MSW rather than on the ordering.
Leave the strictness switch for last. Turning on onUnhandledRequest: 'error' before the handler set is reasonably complete produces a wall of failures that stalls the migration; turning it on afterwards converts a mostly-complete set into a guaranteed one. Counting escapes first, as described in Handling Unhandled Requests and Passthrough in MSW, makes that final step a short, ordered task rather than a cliff.
What not to migrate is as important. Specs that exercise network behaviour — aborts, hangs, latency, retry sequences — should keep using framework interception, and specs that depend on large realistic payloads are better served by recorded archives. A migration that tries to move everything into handlers ends with a handler file nobody wants to read.
Common pitfalls & mitigation strategies #
- Leaving
onUnhandledRequestat the default. Unmocked requests reach the network and the suite depends on a real origin. Mitigation:'error'in CI, with an explicit allowlist. - Not resetting handlers between tests. An override from one test answers the next. Mitigation:
server.resetHandlers()inafterEach. - Not awaiting
worker.start(). The first request escapes before the worker is active. Mitigation: await the start before rendering the application. - A broad handler placed before specific ones.
http.get('/api/*')shadows every narrower route. Mitigation: order specific handlers first, and keep wildcards last. - Stateful handlers at module scope. A counter or an in-memory store persists across tests. Mitigation: keep scenario state inside the test and reset it explicitly.
- A stale service-worker file. An outdated
mockServiceWorker.jsfails silently against a newer library version. Mitigation: regenerate it as part of the install script. - Using MSW for delays and aborts it is awkward at. Timeout and connection-failure scenarios read better as framework routes. Mitigation: use interception for those, as in Testing Offline and Connection Loss States.
Frequently Asked Questions #
Q: Is MSW a replacement for cy.intercept and page.route?
A: For the baseline, yes; for edge cases, no. Shared handlers give every level the same realistic API, which is what stops unit and browser tests from disagreeing. Aborts, artificial delays, and per-attempt response sequences are clearer expressed inline in the spec with framework interception, where a reader can see them next to the assertion.
Q: Why do some requests bypass the worker in browser tests?
A: Either the worker had not activated when the request was made — the fix is awaiting start() before the app renders — or the request came from a context the worker does not control, such as a different origin or a request issued by the test runner itself rather than the page. The Node runtime has an analogous case: requests made by the test process rather than by the code under test.
Q: How do I model a sequence, such as failing twice then succeeding? A: Keep a counter inside the test and close over it in an override, so the state’s lifetime is the test’s lifetime. Putting the counter at module scope makes it survive into the next test, which produces the classic “passes alone, fails in the suite” signature.
Q: Can handlers assert on the requests they receive? Yes, and it is underused. A handler has the request in hand, so validating the outgoing body against the provider’s schema — or simply asserting that a required header is present — turns the mock from something that always agrees into a check on the client. That catches a whole category of drift that response-focused testing never sees.
Q: How much of the API should the shared handler set describe? A: Every endpoint the application calls in the flows you test, and nothing more. A handler for an endpoint no test exercises is dead weight that still has to be maintained and reviewed; an endpoint the application calls with no handler is a gap that strict mode will surface immediately. The set should track real usage, which is why the endpoint-coverage metric is worth watching as the application grows.
Q: Does MSW work with server-side rendering? A: The Node runtime intercepts requests made by the rendering process, so a server-rendered page can be tested against the same handlers as the client. The subtlety is that a server-rendered application makes requests during render and then the hydrated client makes more, so both runtimes may be active in one test — and both must be pointed at the same handler set, or the two phases will render from different data. That mismatch produces hydration warnings which look like the timing problems described in Waiting for React Hydration Before Assertions but have a completely different cause.
Q: How do handlers interact with a client-side cache? Poorly, unless the cache is reset between tests. A cache-first policy can skip the network entirely, so the handler is never called and a test waiting on it hangs; a normalised cache can merge a previous test’s entity into this test’s render. Reset the client’s store in the same teardown that resets handlers, and keep one spec that exercises caching deliberately so the behaviour is not merely disabled everywhere.
Q: Do the same handlers work for GraphQL? A: Yes — MSW has a GraphQL namespace that matches on operation name rather than URL, which is the right level for an API where every request hits the same path. The framework-specific alternatives are covered in GraphQL & WebSocket Mocking and Stubbing GraphQL Queries in Cypress.