C.W.K.
Stream
Lesson 04 of 04 · published

Network Mocking with MSW

~19 min · vitest-mocking, msw, network, integration

Level 0Test Curious
0 XP0/32 lessons0/13 achievements
0/100 XP to next level100 XP to go0% complete
"Don't mock fetch. Mock the network."

The Problem with Mocking fetch

The naive approach to network testing is to mock fetch directly: vi.spyOn(global, 'fetch').mockResolvedValue(...). It works for trivial cases. It falls apart when your code uses a wrapper (axios, ky, native Fetch + custom error handling), when one test calls the same endpoint twice with different responses, or when an integration test spans multiple network calls.

MSW (Mock Service Worker) solves this at a different layer: it intercepts the actual outbound HTTP requests, regardless of which library made them. You write request handlers — "when something calls GET /api/users/:id, return this body" — and MSW responds. Your application code runs unchanged.

The Big Win — Same Handlers in Vitest AND Playwright

Because MSW operates at the request layer, the exact same handler file works in two contexts:

  • Vitest tests: setupServer(...handlers) intercepts Node-side fetch calls.
  • Playwright tests: setupWorker(...handlers) (in the browser) or route-based MSW intercepts browser-side fetch calls.

One set of fixtures, two test runners, identical contract. That's a level of leverage vi.mock can't reach.

Per-Project Setup

The standard MSW setup for Vitest:

  1. Install: npm install -D msw.
  2. Author handlers in a shared file (src/mocks/handlers.ts).
  3. Create a server instance (src/mocks/server.ts) that registers the handlers.
  4. Wire server.listen() in vitest.setup.ts with onUnhandledRequest: 'error'.

The onUnhandledRequest: 'error' bit matters. By default MSW passes unhandled requests through to the real network — fine in production, dangerous in tests. Setting it to 'error' fails any test that calls an unmocked endpoint, which is exactly the noise you want.

Handlers are the contract. A well-named handler file (src/mocks/handlers.ts) reads like API documentation. New developers can scan it to understand what endpoints exist and what they return — and the same file IS the source of truth your tests rely on. Don't scatter handler logic across test files; centralize it and override per-test only when needed.

Per-Test Overrides

Most tests share the baseline handlers. When one test needs a different response — an error, a slow response, an edge-case body — use server.use(...) at the start of that test. MSW prepends the override to the handler list for the test's duration, then server.resetHandlers() in afterEach wipes overrides back to baseline.

Code

MSW handlers — declarative API contract·typescript
// src/mocks/handlers.ts — the canonical handler set
import { http, HttpResponse } from 'msw';

export const handlers = [
  http.get('/api/users/:id', ({ params }) => {
    const { id } = params;
    if (id === '1') {
      return HttpResponse.json({
        id: 1,
        firstName: 'Pippa',
        lastName: 'Choi',
        email: 'pippa@example.com',
      });
    }
    return new HttpResponse(null, { status: 404 });
  }),

  http.post('/api/users', async ({ request }) => {
    const body = await request.json() as { name: string };
    return HttpResponse.json({ id: 42, name: body.name }, { status: 201 });
  }),
];
Vitest setup — MSW lifecycle hooks·typescript
// src/mocks/server.ts — register handlers for Node (Vitest)
import { setupServer } from 'msw/node';
import { handlers } from './handlers';

export const server = setupServer(...handlers);

// vitest.setup.ts — wire it into the test lifecycle
import { afterAll, afterEach, beforeAll } from 'vitest';
import { server } from './src/mocks/server';

beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => server.resetHandlers());     // strip per-test overrides
afterAll(() => server.close());
Test using baseline + per-test override·typescript
// src/services/user-service.test.ts
import { describe, it, expect } from 'vitest';
import { http, HttpResponse } from 'msw';
import { server } from '../mocks/server';
import { getDisplayName } from './user-service';

describe('getDisplayName', () => {
  it('uses the baseline handler', async () => {
    // No override — uses the handler defined in src/mocks/handlers.ts
    await expect(getDisplayName(1)).resolves.toBe('Pippa Choi');
  });

  it('handles 404 for unknown users', async () => {
    await expect(getDisplayName(999)).rejects.toThrow();
  });

  it('handles a 500 server error (per-test override)', async () => {
    server.use(
      http.get('/api/users/:id', () =>
        new HttpResponse('Internal Server Error', { status: 500 })
      )
    );

    await expect(getDisplayName(1)).rejects.toThrow();
    // afterEach resets handlers, so other tests still see the baseline.
  });
});
Don't forget setupFiles — MSW lifecycle lives there·typescript
// Common error: forgetting setupFiles
// Symptom: tests pass when run alone but fail in CI with real network errors.
//
// Check vitest.config.ts:
import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    environment: 'happy-dom',
    setupFiles: ['./vitest.setup.ts'],   // ← MSW lives here
    restoreMocks: true,
  },
});

External links

Exercise

Install MSW. Author a handler for GET /api/posts/:id that returns a post for id 1 and 404 for everything else. Write three tests against a getPostTitle(id) function: (1) happy path returns the title from the handler, (2) unknown id rejects with a meaningful error, (3) one test uses server.use(...) to simulate a 503 and asserts the function surfaces it. Confirm afterEach(() => server.resetHandlers()) keeps the override isolated.
Hint
If the test for (3) bleeds into (1) — i.e., test (1) now sees the 503 — your resetHandlers isn't running. Check it's in afterEach, not afterAll.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.