Skip to content

MSW for API Mocking

intermediate18 min read

The Problem with Mocking Fetch

You've seen this pattern a hundred times:

vi.mock('../api/users', () => ({
  getUser: vi.fn().mockResolvedValue({ id: 1, name: 'Alice' }),
}))

It works. Your test passes. You ship it. Then someone renames getUser to fetchUser, and the test still passes because it's mocking a module that no longer exists in the real code. The test is green, but it's testing nothing.

This is implementation coupling — your test knows how your component fetches data internally. Change the implementation, and the test either breaks for the wrong reason or passes when it shouldn't.

Here's the thing most people miss: the component doesn't care how data arrives. It sends a network request and uses the response. Your tests should work the same way — intercept at the network level, not at the module level.

That's exactly what MSW does.

What MSW Actually Does

MSW (Mock Service Worker) intercepts outgoing network requests at the lowest level your code touches — the fetch or XMLHttpRequest API. Your application code runs exactly as it does in production. No modules are replaced. No functions are stubbed. The only difference is that the network response comes from your handler instead of a real server.

Your component           Production        With MSW
     |                      |                  |
  fetch('/api/user')  →  Real server     →  MSW handler
     |                      |                  |
  Gets response       ←  Real response  ←  Mocked response

Your component calls fetch('/api/user') the same way in both cases. It has no idea MSW exists. This means:

  • Rename your API wrapper? Tests still work — they don't know about your wrapper.
  • Switch from Axios to fetch? Tests still work — MSW intercepts both.
  • Add a caching layer? Tests still work — the network request still happens (or doesn't, which is also testable).
Quiz
Why is intercepting at the network level better than mocking module imports for API tests?

Setting Up MSW with Vitest

MSW uses a concept of handlers (what to intercept) and a server (what runs in Node.js tests). Here's the setup from scratch.

Install MSW

pnpm add -D msw

Define Your Handlers

Handlers describe what requests to intercept and what to respond with. Think of them as a tiny API server you control completely.

// src/mocks/handlers.ts
import { http, HttpResponse } from 'msw'

export const handlers = [
  http.get('/api/user/:id', ({ params }) => {
    return HttpResponse.json({
      id: params.id,
      name: 'Alice',
      email: 'alice@example.com',
    })
  }),

  http.get('/api/posts', ({ request }) => {
    const url = new URL(request.url)
    const limit = Number(url.searchParams.get('limit')) || 10

    return HttpResponse.json(
      Array.from({ length: limit }, (_, i) => ({
        id: i + 1,
        title: `Post ${i + 1}`,
      }))
    )
  }),

  http.post('/api/posts', async ({ request }) => {
    const body = await request.json()
    return HttpResponse.json(
      { id: crypto.randomUUID(), ...body },
      { status: 201 }
    )
  }),
]

Notice a few things:

  1. http.get, http.post — matches the HTTP method. MSW also has http.put, http.patch, http.delete.
  2. Path params:id in the path becomes params.id in the resolver. Works just like Express routes.
  3. Query params — read from the standard Request object. No special API to learn.
  4. Request body — use await request.json() for JSON, await request.text() for text, await request.formData() for forms. It's the standard Request API.
  5. HttpResponse.json() — creates a proper JSON response. You can also use HttpResponse.text(), HttpResponse.xml(), or new HttpResponse() for full control.

Create the Server

// src/mocks/server.ts
import { setupServer } from 'msw/node'
import { handlers } from './handlers'

export const server = setupServer(...handlers)

setupServer takes your handlers and creates a server that intercepts requests in Node.js. It doesn't start a real HTTP server — it patches the global fetch and XMLHttpRequest.

Wire It into Vitest

// vitest.setup.ts
import { server } from './src/mocks/server'

beforeAll(() => server.listen())
afterEach(() => server.resetHandlers())
afterAll(() => server.close())

And in your Vitest config:

// vitest.config.ts
import { defineConfig } from 'vitest/config'

export default defineConfig({
  test: {
    setupFiles: ['./vitest.setup.ts'],
    environment: 'jsdom',
  },
})

That's it. Every test in your project now has MSW running. Requests to /api/user/:id get intercepted automatically. No per-test setup needed for the happy path.

Key Rules
  1. 1Define happy-path handlers in a shared handlers file — these run for every test automatically
  2. 2Call server.resetHandlers() in afterEach to clear per-test overrides and prevent test pollution
  3. 3Call server.listen() before all tests and server.close() after all tests to properly set up and tear down the interception
  4. 4Use the standard Request API (request.json(), request.url, request.headers) — MSW does not invent its own API for reading requests
  5. 5Path parameters use Express-style :param syntax and are available via params in the resolver

Response Resolvers in Depth

The function you pass to http.get() or http.post() is called a response resolver. It receives a context object with everything about the incoming request and returns a response.

http.get('/api/resource', ({ request, params, cookies }) => {
  // request — the full Request object (url, headers, body, method)
  // params — path parameters (from :param segments)
  // cookies — parsed cookies from the request

  return HttpResponse.json({ data: 'value' })
})

Accessing Different Parts of the Request

// Path parameters
http.get('/api/users/:userId/posts/:postId', ({ params }) => {
  const { userId, postId } = params
  return HttpResponse.json({ userId, postId })
})

// Query parameters
http.get('/api/search', ({ request }) => {
  const url = new URL(request.url)
  const query = url.searchParams.get('q')
  const page = Number(url.searchParams.get('page')) || 1
  return HttpResponse.json({ query, page, results: [] })
})

// Request headers
http.get('/api/protected', ({ request }) => {
  const authHeader = request.headers.get('Authorization')
  if (!authHeader) {
    return new HttpResponse(null, { status: 401 })
  }
  return HttpResponse.json({ data: 'secret' })
})

// Request body (POST/PUT/PATCH)
http.post('/api/login', async ({ request }) => {
  const { email, password } = await request.json()
  if (email === 'admin@test.com') {
    return HttpResponse.json({ token: 'abc-123' })
  }
  return HttpResponse.json(
    { error: 'Invalid credentials' },
    { status: 401 }
  )
})
Quiz
In an MSW response resolver, how do you read the JSON body of a POST request?

Dynamic Handlers Per Test

Your shared handlers define the happy path — the "everything works" scenario. But individual tests often need specific scenarios: error responses, empty data, specific edge cases.

Use server.use() to add temporary handlers that override the defaults for a single test:

import { http, HttpResponse } from 'msw'
import { server } from '../mocks/server'
import { render, screen, waitFor } from '@testing-library/react'
import { UserProfile } from './UserProfile'

it('shows user info on success', async () => {
  render(<UserProfile userId="1" />)

  await waitFor(() => {
    expect(screen.getByText('Alice')).toBeVisible()
  })
})

it('shows error message when API fails', async () => {
  server.use(
    http.get('/api/user/:id', () => {
      return new HttpResponse(null, { status: 500 })
    })
  )

  render(<UserProfile userId="1" />)

  await waitFor(() => {
    expect(screen.getByRole('alert')).toHaveTextContent(
      'Failed to load user'
    )
  })
})

it('shows empty state when user has no posts', async () => {
  server.use(
    http.get('/api/user/:id/posts', () => {
      return HttpResponse.json([])
    })
  )

  render(<UserProfile userId="1" />)

  await waitFor(() => {
    expect(screen.getByText('No posts yet')).toBeVisible()
  })
})

The critical detail: server.use() prepends handlers. MSW matches handlers top-down and stops at the first match. So your per-test handler takes priority over the default one. After the test, server.resetHandlers() (from your afterEach) removes these overrides, and the next test starts clean.

This pattern gives you the best of both worlds:

  • Default handlers = happy path shared across all tests (no repetition)
  • Per-test overrides = specific scenarios when you need them

Error Scenarios

Testing error handling is where MSW really earns its keep. Here are the patterns you'll use constantly:

HTTP Error Statuses

server.use(
  http.get('/api/user/:id', () => {
    return new HttpResponse(null, { status: 404 })
  })
)

server.use(
  http.post('/api/checkout', () => {
    return HttpResponse.json(
      { error: 'Payment declined' },
      { status: 402 }
    )
  })
)

server.use(
  http.get('/api/data', () => {
    return HttpResponse.json(
      { message: 'Rate limit exceeded' },
      { status: 429, headers: { 'Retry-After': '60' } }
    )
  })
)

Network Errors

Sometimes the request itself fails — no response at all. MSW can simulate that too:

server.use(
  http.get('/api/user/:id', () => {
    return HttpResponse.error()
  })
)

HttpResponse.error() creates a network error — the fetch promise rejects, just like when the user is offline. This is different from a 500 status (where fetch resolves with response.ok === false).

Validation Errors with Structured Bodies

server.use(
  http.post('/api/register', () => {
    return HttpResponse.json(
      {
        errors: {
          email: 'Email already taken',
          password: 'Must be at least 8 characters',
        },
      },
      { status: 422 }
    )
  })
)
Quiz
What is the difference between returning HttpResponse with status 500 and returning HttpResponse.error() in MSW?

Delay Simulation

Real APIs don't respond instantly. Testing loading states, timeouts, and race conditions requires simulating network latency.

import { http, HttpResponse, delay } from 'msw'

// Fixed delay
http.get('/api/slow-endpoint', async () => {
  await delay(2000)
  return HttpResponse.json({ data: 'finally' })
})

// Realistic random delay (between a few hundred ms)
http.get('/api/endpoint', async () => {
  await delay()
  return HttpResponse.json({ data: 'value' })
})

// Infinite delay — request never resolves (great for testing loading states)
http.get('/api/forever', async () => {
  await delay('infinite')
  return HttpResponse.json({ data: 'never sent' })
})

The infinite delay is surprisingly useful. Need to assert that a loading spinner appears? Use an infinite delay so the component stays in the loading state forever — no race conditions in your test:

it('shows a loading spinner while fetching', async () => {
  server.use(
    http.get('/api/user/:id', async () => {
      await delay('infinite')
      return HttpResponse.json({})
    })
  )

  render(<UserProfile userId="1" />)

  expect(screen.getByRole('progressbar')).toBeVisible()
})

Request Assertions

Sometimes you need to verify that your component sent the right request — the right URL, the right headers, the right body. MSW lets you capture requests for assertions:

it('sends the correct payload when creating a post', async () => {
  let capturedBody: Record<string, unknown> | null = null

  server.use(
    http.post('/api/posts', async ({ request }) => {
      capturedBody = await request.json()
      return HttpResponse.json({ id: '1' }, { status: 201 })
    })
  )

  render(<CreatePost />)

  await userEvent.type(screen.getByLabelText('Title'), 'My Post')
  await userEvent.type(screen.getByLabelText('Body'), 'Content here')
  await userEvent.click(screen.getByRole('button', { name: 'Publish' }))

  await waitFor(() => {
    expect(capturedBody).toEqual({
      title: 'My Post',
      body: 'Content here',
    })
  })
})

But be thoughtful here. Your primary assertion should be the UI outcome ("success message appeared"), not the request details. Asserting the payload is useful for forms and mutations where the exact data matters, but don't assert every header and URL parameter — that's implementation coupling sneaking back in.

Passthrough and Bypass

Not every request should be intercepted. MSW gives you two escape hatches:

Passthrough

Let a specific request through to the real server:

import { http, passthrough } from 'msw'

http.get('/api/analytics', () => {
  return passthrough()
})

Conditional Passthrough

http.get('/api/resource', ({ request }) => {
  if (request.headers.has('x-real-request')) {
    return passthrough()
  }
  return HttpResponse.json({ mocked: true })
})

Bypass

Make a real request from inside a handler (useful for response patching):

import { http, bypass, HttpResponse } from 'msw'

http.get('/api/user', async ({ request }) => {
  const realResponse = await fetch(bypass(request))
  const realUser = await realResponse.json()

  return HttpResponse.json({
    ...realUser,
    name: 'Overridden Name',
  })
})

bypass wraps the request so MSW doesn't intercept it again. Without it, you'd create an infinite loop.

MSW in Storybook

MSW isn't just for tests. It's equally powerful in Storybook for previewing components with realistic data — and especially for previewing error and loading states.

Install the addon:

pnpm add -D msw-storybook-addon

Initialize the service worker (MSW uses a service worker in the browser, unlike Node.js where it patches globals):

npx msw init public/ --save

Configure in your Storybook preview:

// .storybook/preview.ts
import { initialize, mswLoader } from 'msw-storybook-addon'

initialize()

export default {
  loaders: [mswLoader],
}

Then use handlers directly in your stories:

// UserProfile.stories.tsx
import type { Meta, StoryObj } from '@storybook/react'
import { http, HttpResponse, delay } from 'msw'
import { UserProfile } from './UserProfile'

const meta: Meta<typeof UserProfile> = {
  component: UserProfile,
}

export default meta
type Story = StoryObj<typeof UserProfile>

export const Default: Story = {
  args: { userId: '1' },
  parameters: {
    msw: {
      handlers: [
        http.get('/api/user/:id', () => {
          return HttpResponse.json({
            id: '1',
            name: 'Alice',
            avatar: 'https://i.pravatar.cc/150',
          })
        }),
      ],
    },
  },
}

export const Loading: Story = {
  args: { userId: '1' },
  parameters: {
    msw: {
      handlers: [
        http.get('/api/user/:id', async () => {
          await delay('infinite')
          return HttpResponse.json({})
        }),
      ],
    },
  },
}

export const Error: Story = {
  args: { userId: '1' },
  parameters: {
    msw: {
      handlers: [
        http.get('/api/user/:id', () => {
          return new HttpResponse(null, { status: 500 })
        }),
      ],
    },
  },
}

Each story gets its own set of handlers. The Loading story uses an infinite delay so the loading state is permanently visible. The Error story returns a 500 so you can style and verify the error UI.

MSW in Development

You can also run MSW in your development server to work without a backend:

// src/mocks/browser.ts
import { setupWorker } from 'msw/browser'
import { handlers } from './handlers'

export const worker = setupWorker(...handlers)
// src/main.tsx (or app entry point)
async function enableMocking() {
  if (process.env.NODE_ENV !== 'development') return

  const { worker } = await import('./mocks/browser')
  return worker.start()
}

enableMocking().then(() => {
  // Render your app
})

Now your entire frontend runs against mocked APIs during development. The same handlers you use in tests power your dev environment. One source of truth for API behavior.

Quiz
What is the key architectural difference between how MSW works in Node.js tests versus in the browser?

Structuring Handlers for Large Projects

As your app grows, a single handlers.ts file gets unwieldy. Organize by feature:

src/mocks/
  handlers/
    auth.ts       // login, logout, session
    users.ts      // user CRUD
    posts.ts      // posts and comments
    index.ts      // combines all handlers
  server.ts       // setupServer
  browser.ts      // setupWorker
// src/mocks/handlers/auth.ts
import { http, HttpResponse } from 'msw'

export const authHandlers = [
  http.post('/api/login', async ({ request }) => {
    const { email } = await request.json()
    return HttpResponse.json({
      user: { id: '1', email },
      token: 'mock-jwt-token',
    })
  }),

  http.post('/api/logout', () => {
    return new HttpResponse(null, { status: 204 })
  }),
]
// src/mocks/handlers/index.ts
import { authHandlers } from './auth'
import { userHandlers } from './users'
import { postHandlers } from './posts'

export const handlers = [
  ...authHandlers,
  ...userHandlers,
  ...postHandlers,
]

This scales cleanly. Each feature team owns their handlers. Adding a new API endpoint means adding a handler in the right file — no merge conflicts on a giant shared file.

TypeScript and MSW

MSW 2.0 has first-class TypeScript support. You can type your path parameters, request bodies, and response bodies:

import { http, HttpResponse } from 'msw'

type UserParams = { userId: string }
type CreateUserBody = { name: string; email: string }
type UserResponse = { id: string; name: string; email: string }

http.get<UserParams, never, UserResponse, '/api/users/:userId'>(
  '/api/users/:userId',
  ({ params }) => {
    // params.userId is typed as string
    return HttpResponse.json({
      id: params.userId,
      name: 'Alice',
      email: 'alice@example.com',
    })
  }
)

http.post<never, CreateUserBody, UserResponse>(
  '/api/users',
  async ({ request }) => {
    const body = await request.json()
    // body is typed as CreateUserBody
    return HttpResponse.json({
      id: crypto.randomUUID(),
      name: body.name,
      email: body.email,
    })
  }
)

The generic parameters follow the order: PathParams, RequestBody, ResponseBody, RequestPath. You only need to specify the ones you care about.

Common Patterns and Recipes

Testing Authenticated Routes

const authenticatedHandlers = [
  http.get('/api/me', ({ request }) => {
    const token = request.headers.get('Authorization')

    if (!token || !token.startsWith('Bearer ')) {
      return HttpResponse.json(
        { error: 'Unauthorized' },
        { status: 401 }
      )
    }

    return HttpResponse.json({
      id: '1',
      name: 'Alice',
      role: 'admin',
    })
  }),
]

Testing Paginated Endpoints

const allPosts = Array.from({ length: 50 }, (_, i) => ({
  id: i + 1,
  title: `Post ${i + 1}`,
}))

http.get('/api/posts', ({ request }) => {
  const url = new URL(request.url)
  const page = Number(url.searchParams.get('page')) || 1
  const perPage = Number(url.searchParams.get('per_page')) || 10

  const start = (page - 1) * perPage
  const items = allPosts.slice(start, start + perPage)

  return HttpResponse.json({
    items,
    total: allPosts.length,
    page,
    totalPages: Math.ceil(allPosts.length / perPage),
  })
})

Testing Optimistic Updates

it('shows the new comment immediately before server confirms', async () => {
  server.use(
    http.post('/api/comments', async ({ request }) => {
      await delay(1000)
      const body = await request.json()
      return HttpResponse.json(
        { id: '99', ...body },
        { status: 201 }
      )
    })
  )

  render(<CommentSection postId="1" />)

  await userEvent.type(screen.getByLabelText('Comment'), 'Great post!')
  await userEvent.click(screen.getByRole('button', { name: 'Submit' }))

  expect(screen.getByText('Great post!')).toBeVisible()
  expect(screen.getByText('Sending...')).toBeVisible()
})

One-Time Handlers

Need a handler that only works once — like simulating a retry succeeding on the second attempt?

import { http, HttpResponse } from 'msw'

let callCount = 0

server.use(
  http.get('/api/flaky', () => {
    callCount++
    if (callCount === 1) {
      return new HttpResponse(null, { status: 503 })
    }
    return HttpResponse.json({ data: 'success' })
  })
)

Or use the { once: true } option for handlers that should only match once:

server.use(
  http.get('/api/resource', () => {
    return new HttpResponse(null, { status: 500 })
  }, { once: true })
)

After matching once, this handler is removed, and the next request falls through to the default handler.

What developers doWhat they should do
Forgetting server.resetHandlers() in afterEach, causing test pollution where per-test overrides leak into other tests
Without resetHandlers, a server.use() override in one test persists for all subsequent tests. This causes hard-to-debug failures where tests pass in isolation but fail when run together.
Always call server.resetHandlers() in afterEach — it removes per-test overrides added via server.use() and restores the original handlers
Using MSW v1 syntax (ctx.json, ctx.status, rest.get) with MSW 2.0
MSW 2.0 was a complete rewrite. The old rest.get/ctx.json API was removed. The new API uses standard Web APIs (Request, Response) instead of custom abstractions, making it more portable and easier to learn.
Use the MSW 2.0 API: http.get/post with HttpResponse.json() for responses — there is no ctx or rest namespace in v2
Asserting the request URL and headers in every test instead of asserting the UI outcome
Over-asserting request details couples your test to implementation. If you refactor the URL from /api/v1/users to /api/v2/users, every test breaks even though the UI behavior is identical. Focus on what the user experiences.
Assert the UI outcome first (what the user sees), and only assert request details when the exact payload matters (like form submissions)
Defining all handlers inside individual test files instead of sharing a common set
Duplicating handlers across test files violates DRY and makes it painful to update when your API shape changes. A shared handlers file is your single source of truth for default API behavior.
Define happy-path handlers in a shared handlers file, and only use server.use() in individual tests for scenario-specific overrides
Using HttpResponse.error() when you mean to return a 500 status code
HttpResponse.error() causes the fetch promise to reject (like being offline). A 500 status causes fetch to resolve with response.ok = false. These trigger completely different error handling code paths in your application.
Use new HttpResponse(null, { status: 500 }) for server errors, and HttpResponse.error() only when you want to simulate a network failure where no response is received at all

When NOT to Use MSW

MSW is the right tool for integration tests where your component makes real fetch calls. But it's not always the answer:

  • Pure unit tests — testing a utility function that transforms data? Just call the function. No network involved, no MSW needed.
  • E2E tests with Playwright — Playwright has its own page.route() API for intercepting requests. MSW can work with Playwright via a dedicated package, but Playwright's built-in interception is simpler for E2E.
  • Testing the API itself — MSW mocks the API. If you're testing your actual API routes (Next.js Route Handlers), you need a real server or a supertest-style approach.

Quick Reference

TaskMSW API
Intercept GEThttp.get('/path', resolver)
Intercept POSThttp.post('/path', resolver)
JSON responseHttpResponse.json(data)
Error statusnew HttpResponse(null, { status: 500 })
Network errorHttpResponse.error()
Read path params({ params }) => params.id
Read query paramsnew URL(request.url).searchParams
Read bodyawait request.json()
Add delayawait delay(ms) or await delay()
Infinite delayawait delay('infinite')
Per-test overrideserver.use(handler)
Reset overridesserver.resetHandlers()
Let request throughreturn passthrough()
One-time handlerhttp.get('/path', resolver, { once: true })
Quiz
You have a shared handler that returns a successful response for GET /api/user/:id. In one test, you need to simulate a 404 error. After that test, the next test should use the original successful handler. What is the correct approach?