Root cause #
MSW layers handlers in two tiers: the base handlers passed to setupServer()/setupWorker(), and runtime handlers added mid-test with server.use(). The runtime tier is the trap. server.use() pushes an override onto a stack that persists until you explicitly reset it. If test A calls server.use(http.get('/cart', errorResolver)) and you never reset, test B inherits that error resolver. Test B may not even hit /cart, so it passes — until a refactor makes it touch the endpoint, and now it fails for a reason that has nothing to do with the change under test.
In the async world this is worse than it looks. A service worker (setupWorker) intercepts at the network boundary of the page, so a leaked handler outlives not just the test but potentially the whole page session. Without close(), the worker keeps intercepting between files, and a request you expected to reach a real backend (or a Playwright route) gets quietly stubbed instead. The fix is a strict, always-run lifecycle.
Step-by-step fix #
1. Start the server once, before all tests #
// tests/setup.js
const { setupServer } = require('msw/node');
const { handlers } = require('./handlers');
const server = setupServer(...handlers);
// onUnhandledRequest: 'error' surfaces escaped requests instead of silently passing them.
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
module.exports = { server };
2. Reset runtime handlers after every test #
This is the line that prevents bleed. It drops everything added by server.use() and restores the base set.
// tests/setup.js (continued)
afterEach(() => {
// Drops runtime overrides from server.use() so test B never inherits test A's mocks.
server.resetHandlers();
});
3. Close the server when the file finishes #
afterAll(() => {
// Stops the interceptor entirely; without this the worker can leak across files in watch mode.
server.close();
});
4. Tear down the browser worker the same way #
For setupWorker() running in the page, stop it explicitly so it does not survive the page session.
// in the app/test boot for browser MSW
const worker = setupWorker(...handlers);
await worker.start({ onUnhandledRequest: 'error' });
// On teardown — stops interception so a later test reaches the real route, not a stale mock.
afterEach(() => worker.resetHandlers());
afterAll(() => worker.stop());
When you seed per-environment fixtures into these handlers, keep the data deterministic — see Seeding Deterministic Mock Data Across Environments.
Pitfalls #
- Calling
server.use()without a matchingresetHandlers()— overrides stack up. Mitigation: putresetHandlers()in a globalafterEach, not per file. - Skipping
close()in watch mode — the worker survives reloads. Mitigation: always pairlisten()withafterAll(close). - Using
onUnhandledRequest: 'bypass'— escaped requests pass silently and hide gaps. Mitigation: use'error'in tests so unmocked calls fail loudly. - Re-creating
setupServerper test — slow and loses the base handlers. Mitigation: create once at module scope, reset between tests. - Forgetting the browser worker has its own lifecycle. Mitigation: mirror
resetHandlers/stopforsetupWorkerexactly as for the node server.
Reliability targets #
| Target | Goal |
|---|---|
| Cross-test mock bleed incidents | 0 per suite run |
| Unhandled-request escapes | 0 (enforced via onUnhandledRequest: 'error') |
| Handler reset coverage | 100% of tests |
| Order-independent pass rate | ≥ 99.5% |
Frequently Asked Questions #
Q: Do I need both resetHandlers() and close()?
A: Yes. resetHandlers() runs between tests to drop runtime overrides; close() runs once at the end to stop the interceptor entirely. Skipping either leaves a different category of leak.
Q: Where should the lifecycle hooks live?
A: In a shared global setup file referenced by your test runner config, so every spec inherits the same listen/resetHandlers/close cycle without copy-pasting.
Q: Why use onUnhandledRequest: 'error'?
A: It turns any request your handlers do not cover into an immediate failure, exposing missing mocks instead of letting them hit a real backend and produce nondeterministic results.