Mock Service Worker: Frontend Testing Without a Real Backend

How Mock Service Worker intercepts network requests at the service worker level — and why this makes it fundamentally different from mocking fetch directly.

March 22, 20264 min read1 / 3

The typical approach to mocking API calls in frontend tests is to replace fetch or replace the module that makes the call. This is fast and simple, and it has a fundamental problem: you're not testing the code that makes the request.

When you mock fetch directly, your test skips the entire layer that constructs the request — the headers it adds, the error handling around the response, the retry logic. Those paths are untested, and they're often where bugs live.

Mock Service Worker (MSW) takes a different approach. It registers an actual service worker in the browser that intercepts network requests at the network level, before they leave the browser. Your code calls fetch normally, the service worker intercepts the request, and MSW returns the configured response. Your application code never knows the difference.

In Node (for unit tests), MSW intercepts using a polyfill that hooks into the HTTP stack rather than a service worker — same API, same handlers.

Setting Up MSW

Bash
pnpm add -D msw

Define your handlers — the interceptors for specific routes:

TypeScript
// src/mocks/handlers.ts import { http, HttpResponse } from 'msw'; export const handlers = [ http.get('/api/user', () => { return HttpResponse.json({ id: 'user-1', name: 'Ada Lovelace', role: 'admin', }); }), http.post('/api/user', async ({ request }) => { const body = await request.json(); return HttpResponse.json({ id: 'user-2', ...body }, { status: 201 }); }), // Simulate an error http.get('/api/orders', () => { return HttpResponse.json( { error: 'Service temporarily unavailable' }, { status: 503 } ); }), ];

For browser use, initialize the service worker:

TypeScript
// src/mocks/browser.ts import { setupWorker } from 'msw/browser'; import { handlers } from './handlers'; export const worker = setupWorker(...handlers);

Start it in development:

TypeScript
// src/main.tsx if (import.meta.env.DEV) { const { worker } = await import('./mocks/browser'); await worker.start({ onUnhandledRequest: 'bypass' }); }

For tests (Node):

TypeScript
// src/mocks/server.ts import { setupServer } from 'msw/node'; import { handlers } from './handlers'; export const server = setupServer(...handlers);

In your test setup file:

TypeScript
// vitest.setup.ts import { server } from './mocks/server'; beforeAll(() => server.listen({ onUnhandledRequest: 'warn' })); afterEach(() => server.resetHandlers()); afterAll(() => server.close());

resetHandlers() after each test is important — it removes any test-specific overrides so they don't leak into other tests.

Testing Happy and Unhappy Paths

The default handlers cover the happy path. For unhappy path tests, override the handler for a specific test:

TypeScript
import { server } from '../mocks/server'; import { http, HttpResponse } from 'msw'; import { render, screen, waitFor } from '@testing-library/react'; import { UserProfile } from './UserProfile'; it('shows an error when the user request fails', async () => { server.use( http.get('/api/user', () => { return HttpResponse.json( { error: 'Unauthorized' }, { status: 401 } ); }) ); render(<UserProfile userId="user-1" />); await waitFor(() => { expect(screen.getByText(/unauthorized/i)).toBeInTheDocument(); }); });

The override applies only to this test and is reset by afterEach(() => server.resetHandlers()).

This is what makes MSW genuinely better than fetch mocking: you can test the full path from "component initiates request" through "component renders the error state" without any shortcuts. The error handling in your fetch wrapper, the mapping from HTTP status codes to error messages, the UI transitions — all of it runs.

MSW in a Microfrontend Context

In a module federation setup, each remote has its own API calls. Testing remotes in isolation is where MSW becomes essential — you can test a remote module against realistic API responses without running a backend or connecting to the host application.

The service worker registration needs some coordination in development: if the host and remotes are on different ports, the service worker registered for the host won't intercept requests from the remote. The practical solution is to register MSW for each remote independently in development mode, not just at the host level.

For testing, this isn't an issue — each test file sets up its own server instance.

The Boundary Question

MSW doesn't mock your entire frontend. It mocks the network boundary — responses from your API. This means:

  • Your request construction code runs (tested)
  • Your response parsing code runs (tested)
  • Your error handling runs (tested)
  • Your UI rendering from the response runs (tested)

The only thing not tested is the actual backend. Which is fine — that's what backend tests are for. The goal is a seam at the right layer: real enough to be meaningful, controlled enough to be reliable.

This is also what makes MSW useful in Storybook: you can story a component against different API states (loading, success, empty, error, partial data) by configuring MSW handlers per story. The component doesn't know it's in Storybook; it's just making real-looking network requests.

Enjoyed this? Get more like it.

Deep dives on system design, React, web development, and personal finance — straight to your inbox. Free, always.