Resetting MSW handlers between Vitest tests
This page is part of the Mock Boundaries section, which covers how to design and control the interceptors that sit between a component and the services it calls. If you have not yet stood up a request-mocking layer, start with setting up mock APIs for frontend component tests; this page assumes MSW is already installed and focuses on one precise failure: an override added in one test leaking into another.
The specific problem: you add a server.use() override inside a test to simulate a 500 response, the test passes, and then a completely unrelated test three files later starts failing because it is still receiving that 500. The override was never torn down, so it bled forward into every test that ran after it on the same worker.
Problem statement
MSW’s setupServer creates a single server instance that intercepts requests for the entire test file (and, if defined in a shared setup, every file on that worker). Base handlers passed to setupServer(...) are the standing contract. Inside an individual test you often need to deviate from that contract — return an error, an empty list, or a slow response — and the idiomatic way to do that is server.use(...), which prepends a runtime handler that takes priority over the base handlers.
The catch is that runtime handlers are sticky. server.use() mutates the shared server; it does not scope the handler to the current test. Unless something explicitly removes it, that override stays active for every subsequent request on the same server instance. The result is a test that “poisons” the ones after it: an error-path test leaves an error handler installed, and the next test that hits the same endpoint gets the error instead of the happy path it expected.
The symptom is order-dependent failure. The victim test passes when run alone (vitest run victim.test.tsx) and fails when run after the override test, which makes it look like the victim is broken when the real fault is a missing reset.
Root cause
The root cause is a missing lifecycle hook: server.use() adds a runtime handler, and only server.resetHandlers() removes it. Without a resetHandlers() call in afterEach, the override survives the test that added it. Because MSW is a shared mock boundary rather than per-test state, forgetting to reset it is exactly the kind of cross-test leak that strict isolation is meant to prevent.
The same missing lifecycle explains a related symptom. If server.listen() is not configured with onUnhandledRequest: 'error', a request that no handler matches slips through to the real network instead of failing, so the missing mock is invisible until it flakes in CI.
Minimal reproduction
A base handler returns a list of orders. One test overrides it to a 500 to exercise the error state, and forgets nothing except the reset — because there is no reset anywhere.
// src/test/mocks/handlers.ts
import { http, HttpResponse } from 'msw';
export const handlers = [
http.get('/api/orders', () =>
HttpResponse.json([{ id: 'ord_1', total: 4200 }])
),
];
// src/test/mocks/server.ts
import { setupServer } from 'msw/node';
import { handlers } from './handlers';
export const server = setupServer(...handlers);
// OrderList.test.tsx — NO resetHandlers anywhere
import { beforeAll, afterAll, test, expect } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import { http, HttpResponse } from 'msw';
import { server } from '@/test/mocks/server';
import { OrderList } from './OrderList';
beforeAll(() => server.listen());
afterAll(() => server.close());
test('shows an error banner when the API fails', async () => {
server.use(
http.get('/api/orders', () => new HttpResponse(null, { status: 500 })) // sticky override
);
render(<OrderList />);
await waitFor(() => expect(screen.getByRole('alert')).toBeInTheDocument());
});
test('renders the order list', async () => {
render(<OrderList />);
// still receiving the 500 from the previous test → this fails
await waitFor(() => expect(screen.getByText('ord_1')).toBeInTheDocument());
});
The second test fails with a timeout: Unable to find an element with the text: ord_1, because the 500 override from the first test is still installed on the shared server.
Step-by-step fix
1. Own the server lifecycle in a shared setup file
Put the three lifecycle calls in one setup file that every test file loads. resetHandlers() in afterEach is the line that fixes the bleed; onUnhandledRequest: 'error' makes any uncovered request fail loudly.
// src/test/setupTests.ts
import { afterAll, afterEach, beforeAll } from 'vitest';
import { server } from './mocks/server';
// Start interception once; fail on any request without a handler.
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
// Discard per-test overrides so nothing bleeds into the next test.
afterEach(() => server.resetHandlers());
// Stop interception and release the worker.
afterAll(() => server.close());
// vitest.config.ts
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'jsdom',
setupFiles: ['./src/test/setupTests.ts'], // applies the lifecycle everywhere
},
});
What this does: resetHandlers() runs after every single test, restoring exactly the handlers passed to setupServer(...). Any server.use() override is discarded the instant its test finishes, so the next test starts from the base contract. Because this lives in setupFiles, no individual test has to remember it.
2. Scope one-off overrides inside the test that needs them
With the reset in place, server.use() becomes safe: add the override at the top of the test, and trust afterEach to remove it. Do not mutate the base handlers array or re-setupServer.
// OrderList.test.tsx — override is now local in effect
import { test, expect } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import { http, HttpResponse } from 'msw';
import { server } from '@/test/mocks/server';
import { OrderList } from './OrderList';
test('shows an error banner when the API fails', async () => {
server.use(
http.get('/api/orders', () => new HttpResponse(null, { status: 500 }))
);
render(<OrderList />);
await waitFor(() => expect(screen.getByRole('alert')).toBeInTheDocument());
});
test('renders the order list', async () => {
render(<OrderList />);
await waitFor(() => expect(screen.getByText('ord_1')).toBeInTheDocument());
});
What this does: the 500 handler only exists for the duration of the error test. afterEach calls resetHandlers() before the second test runs, so GET /api/orders falls back to the base 200 handler and the list renders.
3. Assert the boundary is covered, not bypassed
onUnhandledRequest: 'error' turns a missing handler into an immediate, named failure. Use it to catch endpoints you forgot to mock rather than letting them hit the network.
// A test whose component calls an unmocked endpoint fails clearly:
test('loads the customer profile', async () => {
render(<OrderList showCustomer />); // fires GET /api/customer/42
await waitFor(() => screen.getByText('ord_1'));
});
// Add the missing handler to the base set, or scope it per test:
server.use(
http.get('/api/customer/:id', ({ params }) =>
HttpResponse.json({ id: params.id, name: 'Grace Hopper' })
)
);
What this does: instead of a silent real request or a hung timeout, MSW throws and prints the exact method and URL that has no handler, so you know precisely which part of the boundary is uncovered.
4. Replace the base set only when a whole file needs a different contract
If an entire test file should run against a different baseline (for example, an “authenticated admin” fixture), pass handlers to resetHandlers(...) in a file-scoped beforeEach — this replaces the base set for that file without touching the shared default.
import { beforeEach } from 'vitest';
import { http, HttpResponse } from 'msw';
import { server } from '@/test/mocks/server';
beforeEach(() => {
server.resetHandlers(
http.get('/api/orders', () =>
HttpResponse.json([{ id: 'ord_9', total: 99900, tier: 'admin' }])
)
);
});
What this does: resetHandlers(...handlers) swaps the active handler set to the ones you pass, for the rest of that file. The global afterEach reset in setupTests.ts still runs, but because this beforeEach re-establishes the admin baseline before each test, every test in the file gets the admin contract without leaking it to other files.
Verification
Run the previously failing pair. Both tests should pass, and order should no longer matter.
npx vitest run OrderList.test.tsx --sequence.shuffle
Expected output once the reset is in place:
✓ OrderList > shows an error banner when the API fails (23ms)
✓ OrderList > renders the order list (12ms)
Test Files 1 passed (1)
Tests 2 passed (2)
If a component calls an endpoint with no handler, onUnhandledRequest: 'error' names it instead of hanging:
Error: [MSW] Cannot bypass a request without a matching request handler:
• GET /api/customer/42
To prove no override bleeds across files, shuffle the whole suite repeatedly; a leak would show as an intermittent failure in a file that never touched the override:
for i in $(seq 1 15); do npx vitest run --sequence.shuffle || break; done
Edge cases and caveats
afterEachorder matters when you also unmount. MSW’sresetHandlers()and Testing Library’scleanup()are independent; register both. If a component fires a request during unmount, runcleanup()beforeresetHandlers()so the in-flight request still matches its handler rather than trippingonUnhandledRequest: 'error'.server.use()handlers are one-time only if you mark them so. By default a runtime handler matches every request until reset. Pass{ once: true }on the response resolver when you want an override to fire for a single request and then fall through to the base handler within the same test.- A shared setup file plus per-file
beforeEachoverrides can double-reset. That is harmless — the globalafterEachrestores thesetupServerbase, and the file’sbeforeEachre-applies its own baseline before the next test. Do not also callresetHandlers()manually inside a test unless you intend to drop an override mid-test.
FAQ
Why does an MSW override from one test affect a later test?
server.use() prepends a runtime handler to the same server instance that every test shares. If you never call server.resetHandlers(), that override stays active for all subsequent tests, so an unrelated test receives the override’s response instead of the base handler’s. Calling resetHandlers() in afterEach restores the base handlers before the next test.
Where should server.listen, resetHandlers, and close go?
Put server.listen({ onUnhandledRequest: 'error' }) in beforeAll, server.resetHandlers() in afterEach, and server.close() in afterAll. Placing these in a shared setup file applies them to every test file, so no individual test has to remember the lifecycle.
Does resetHandlers() delete my base handlers too?
No. resetHandlers() with no arguments restores exactly the handlers passed to setupServer(), discarding only the runtime handlers added by server.use(). Pass handlers to resetHandlers(...) only when you want to replace the base set for the rest of the file.
Related
- Mock Boundaries — the parent section on designing contract-based interceptors between a component and external services.
- Setting Up Mock APIs for Frontend Component Tests — the companion page on standing up MSW handlers and the request lifecycle this page builds on.
- How to Isolate React Components for Unit Testing — where the MSW server fits into a fully isolated render wrapper.
- Preventing React Context Leaks Across Parallel Vitest Workers — the same class of cross-test leak, at the module-singleton level rather than the mock layer.
- Component Testing Fundamentals — the top-level guide to the entire testing workflow this page sits within.