mrkeyoor.com_
Thu 06 Aug 07:41 UTC
npmTestingupdated 06 Aug 2026

msw

Mock Service Worker intercepts HTTP requests at the network layer instead of monkey-patching fetch or axios. In the browser it registers a real Service Worker that catches outgoing requests and answers them from handlers you write; in Node it swaps the low-level http, https, XMLHttpRequest, and fetch internals through the @mswjs/interceptors package. The practical consequence is that your application code never learns it is being mocked: no injected client, no adapter, no conditional imports. You write Express-style route handlers once (http.get, graphql.query, ws.link) and reuse the same file for Jest and Vitest unit tests, Playwright and Cypress end-to-end runs, Storybook stories, and local development against an API that does not exist yet.

Verdict

The default choice for API mocking in JavaScript, because network-level interception means the same handlers work in tests, Storybook, and the browser, and your app code stays free of test-only branches. Budget half a day for the Jest and Service Worker setup, and pair it with generated types or contract tests so your mocks cannot quietly stop matching the real API.

API stability4/5The 2.x line has held since 2023 and additions like the ws handler arrived without breaking changes, but the v1 to v2 rewrite threw out the res(ctx) resolver signature and moved every entry point, so a large share of the material online is for an API that no longer exists.
Docs5/5mswjs.io is unusually complete: task-shaped recipes, per-integration guides for Vitest, Jest, Playwright, Cypress, and Storybook, a real migration guide from v1, and an FAQ that answers the actual failure modes rather than restating the API.
Maintenance4/5Pushed July 2026 with 34 open issues (41 counting PRs) and steady releases, but the README says outright that it stays a spare-time hobby project with no funding for even one full-time contributor, so the bus factor sits with Artem Zakharchenko.
Ecosystem5/5About 19.6 million weekly downloads with first-party integrations documented for every major test runner, an official Storybook addon, an examples repo, and companion packages like @mswjs/data and @mswjs/interceptors.

Use it if

  • You want one set of mocks shared by unit tests, component tests, Storybook, and local dev, instead of a jest.mock file, a Cypress intercept file, and a dev proxy that all drift apart
  • Your tests currently stub fetch or axios and you keep shipping bugs the tests could not catch, because the stub skipped your interceptors, retry logic, and response parsing
  • You need to develop against an API that is not built yet, and you want the browser Network tab to show real request and response entries you can inspect
  • You mock more than plain REST: msw handles GraphQL operations by name and WebSocket links through the same handler model
  • You want per-test overrides: server.use() adds a handler for one test and resetHandlers() clears it, so the error case and the happy path live next to each other
Skip it if

Setup reality

npm install msw --save-dev is the easy half. Browser usage also needs npx msw init ./public --save, which copies mockServiceWorker.js into your static directory; that file is versioned, so every msw upgrade prints a console warning until you re-run init, and forgetting it in CI or a Docker build gives you a 404 and silently unmocked requests. Node usage needs a setup file wiring beforeAll(() => server.listen()), afterEach(() => server.resetHandlers()), and afterAll(() => server.close()), and you almost always want onUnhandledRequest: 'error' so a typo'd URL fails loudly instead of hitting the real internet. Vitest works out of the box with the node or jsdom environment. Jest is the rough path: jsdom lacks the fetch primitives msw v2 needs, so you end up on a custom test environment or polyfills, and the package's exports map (msw/node, msw/browser, msw/native) trips older Jest resolvers until you set testEnvironmentOptions customExportConditions. TypeScript is a peer dependency but optional, and it wants 4.8 or newer.

Patterns

Wire setupServer into a test runnernode-test-setup

// vitest.setup.ts (add to test.setupFiles in vitest.config.ts)
import { afterAll, afterEach, beforeAll } from 'vitest'
import { setupServer } from 'msw/node'
import { http, HttpResponse } from 'msw'

export const server = setupServer(
  http.get('https://api.example.com/user', () =>
    HttpResponse.json({ id: 1, name: 'Ada' }),
  ),
)

beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
afterEach(() => server.resetHandlers())
afterAll(() => server.close())

onUnhandledRequest: 'error' is the setting that earns its keep. The default is 'warn', which lets a typo'd URL escape to the real internet and turns a fast unit test into a flaky network test.

Start the Service Worker in the browserbrowser-worker-setup

// terminal, once per project:
//   npx msw init ./public --save

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

export const worker = setupWorker(...handlers)

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

await worker.start() before rendering, or the first requests race the worker registration and hit the real network. Re-run msw init after every msw upgrade; a stale mockServiceWorker.js logs a version warning and can behave incorrectly.

Read path params, query string, and request bodyrest-handler-params-and-body

import { http, HttpResponse } from 'msw'

http.post('/api/posts/:postId/comments', async ({ request, params }) => {
  const { postId } = params
  const page = new URL(request.url).searchParams.get('page') ?? '1'
  const body = await request.json()

  return HttpResponse.json(
    { id: 'c1', postId, page, text: body.text },
    { status: 201 },
  )
})

request is a standard Request, so the body is read with await request.json() or request.formData(), and query params come off new URL(request.url), not a params object. Relative paths like /api/posts resolve against the page origin in the browser and need an absolute URL in Node.

Override a handler for a single testper-test-override

import { http, HttpResponse } from 'msw'
import { server } from '../vitest.setup'

test('shows an error banner when the API fails', async () => {
  server.use(
    http.get('https://api.example.com/user', () =>
      HttpResponse.json({ message: 'boom' }, { status: 500 }),
    ),
  )

  render(<Profile />)
  expect(await screen.findByRole('alert')).toBeInTheDocument()
})

server.use() prepends the handler so it wins over the default. It stays active until resetHandlers(), which is why the afterEach in the setup file is not optional; without it, test order starts changing results.

Simulate network failures, timeouts, and empty bodiesnetwork-error-and-status

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

http.get('/api/flaky', () => HttpResponse.error())              // TypeError: Failed to fetch
http.get('/api/slow', async () => {
  await delay(3000)
  return HttpResponse.json({ ok: true })
})
http.get('/api/hang', async () => {
  await delay('infinite')                                       // never responds
})
http.delete('/api/item/:id', () => new HttpResponse(null, { status: 204 }))

HttpResponse.error() produces a real rejected fetch, not a 500, which is the only way to test your offline path. delay('infinite') is how you test loading spinners and abort handling; combine it with fake timers carefully, since delay uses real timers by default.

Return a different response on the second callone-time-and-sequenced-responses

import { http, HttpResponse } from 'msw'

server.use(
  http.get('/api/job', () => HttpResponse.json({ status: 'pending' }), {
    once: true,
  }),
  http.get('/api/job', () => HttpResponse.json({ status: 'done' })),
)

The { once: true } handler is consumed after one match, then the next matching handler takes over, which is how you test polling and retry logic. resetHandlers() restores consumed one-time handlers too.

Mock GraphQL queries and mutations by operation namegraphql-operations

import { graphql, HttpResponse } from 'msw'

const api = graphql.link('https://api.example.com/graphql')

export const handlers = [
  api.query('GetUser', ({ variables }) =>
    HttpResponse.json({ data: { user: { id: variables.id, name: 'Ada' } } }),
  ),
  api.mutation('DeleteUser', () =>
    HttpResponse.json({
      errors: [{ message: 'Not authorized' }],
    }),
  ),
]

Matching is by operation name, so an anonymous query never matches; name your operations. GraphQL errors are a 200 response with an errors array, not an HTTP status, which is the mistake people make when testing error states.

Mock a WebSocket connectionwebsocket-mocking

import { ws } from 'msw'

const chat = ws.link('wss://chat.example.com')

export const handlers = [
  chat.addEventListener('connection', ({ client }) => {
    client.send(JSON.stringify({ type: 'welcome' }))

    client.addEventListener('message', (event) => {
      const msg = JSON.parse(event.data.toString())
      client.send(JSON.stringify({ type: 'echo', text: msg.text }))
    })
  }),
]

client.send() pushes to your app; server.connect() inside the same handler opens the real upstream socket if you want to observe and modify real traffic instead of faking it. event.data is a string or Buffer depending on environment, so coerce before parsing.

Let some requests reach the real serverpassthrough-and-bypass

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

// send this route to the real network untouched
http.get('/assets/*', () => passthrough())

// call the real API, then modify the response
http.get('https://api.example.com/user', async ({ request }) => {
  const real = await fetch(bypass(request))
  const data = await real.json()
  return HttpResponse.json({ ...data, plan: 'enterprise' })
})

bypass(request) marks the outgoing request so msw does not intercept its own call; plain fetch(request) inside a handler causes infinite recursion. Requests without a matching handler already pass through, so passthrough() is for carving exceptions out of a broad matcher.

Type params, request body, and response bodytyped-handlers

import { http, HttpResponse } from 'msw'

type Params = { userId: string }
type Body = { name: string }
type Response = { id: string; name: string }

http.put<Params, Body, Response>('/api/users/:userId', async ({ request, params }) => {
  const { name } = await request.json()
  return HttpResponse.json({ id: params.userId, name })
})

The generic order is path params, request body, response body. Without it params values are typed as string or string[] and request.json() returns unknown, so most teams generate these types from their OpenAPI or GraphQL schema and get drift detection for free.

Assert on requests through the life-cycle eventsassert-request-was-made

import { server } from '../vitest.setup'

test('sends the auth header', async () => {
  const seen: Request[] = []
  server.events.on('request:start', ({ request }) => seen.push(request))

  await callApi()

  expect(seen).toHaveLength(1)
  expect(seen[0].headers.get('authorization')).toBe('Bearer token')
})

afterEach(() => server.events.removeAllListeners())

msw has no built-in spy assertions on purpose; life-cycle events are the supported way. Remove listeners between tests or they accumulate across the whole file. Clone the request before reading its body, since the body stream can only be consumed once.

Scope mocks to one request in a running Node serverscoped-handlers-in-a-server

import express from 'express'
import { http, HttpResponse } from 'msw'
import { setupServer } from 'msw/node'

const app = express()
const mocks = setupServer()
mocks.listen()

app.get('/checkout', mocks.boundary(async (req, res) => {
  mocks.use(
    http.get('https://api.stripe.com/v1/checkout/sessions/:id', ({ params }) =>
      HttpResponse.json({ id: params.id, status: 'open' }),
    ),
  )
  res.json(await handleCheckout(req))
}))

boundary() puts the handlers in async local storage so concurrent requests do not see each other's overrides. Without it, mocks.use() inside a request handler is global and two parallel requests will fight over which mock is active.

Alternatives

PackageRegistryPick it when
nocknpmNode-only testing where you also want to record real HTTP traffic to fixtures and replay it; no Service Worker, no browser support.
miragejsnpmYou want a stateful fake backend with an ORM, factories, and seed data rather than stateless request handlers.
@pact-foundation/pactnpmThe risk you care about is mocks drifting from the real API, so you need consumer-driven contracts verified against the actual provider.
@mswjs/datanpmYou are keeping msw but need a data layer: this is the companion package that adds models, relationships, and generated CRUD handlers.