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.

MSW handler bleed without resetHandlers versus with resetHandlers Left column: test one calls server.use to return a 500, test two hits the same endpoint and still receives the 500 because handlers were never reset. Right column: afterEach calls resetHandlers, so test two receives the base 200 response. No resetHandlers — bleed test 1: server.use(500) test 2: GET /orders server 500 still active test 2 gets 500 → fails resetHandlers in afterEach test 1: server.use(500) afterEach → resetHandlers() test 2: GET /orders server base 200 test 2 gets 200 → passes

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

  • afterEach order matters when you also unmount. MSW’s resetHandlers() and Testing Library’s cleanup() are independent; register both. If a component fires a request during unmount, run cleanup() before resetHandlers() so the in-flight request still matches its handler rather than tripping onUnhandledRequest: '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 beforeEach overrides can double-reset. That is harmless — the global afterEach restores the setupServer base, and the file’s beforeEach re-applies its own baseline before the next test. Do not also call resetHandlers() 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.