msw-skill
AGENTS.md2 starsChanged 9 days agoMITView on GitHub
This document is for AI agents and LLMs to follow when writing, reviewing, or debugging MSW (Mock Service Worker) handlers, server setup, and test patterns. It compiles all rules and references into a single executable guide. Baseline: msw ^3.0.0 (audited against 3.0.3; ESM-only, Node.js >=22, TypeScript >=5.9) MSW (Mock Service Worker) is a network-level API mocking library for JavaScript/TypeScript. It intercepts HTTP and GraphQL requests using the http and graphql namespaces, returning mock responses via HttpResponse; it also mocks Server-Sent…
- Reads credentials
In this repositoryOne other file it gives its agents.
- mswSkill
The file
Files
MSW — Complete Guide
This document is for AI agents and LLMs to follow when writing, reviewing, or debugging MSW (Mock Service Worker) handlers, server setup, and test patterns. It compiles all rules and references into a single executable guide.
Baseline: msw ^3.0.0 (audited against 3.0.3; ESM-only, Node.js >=22, TypeScript >=5.9)
Abstract
MSW (Mock Service Worker) is a network-level API mocking library for JavaScript/TypeScript. It intercepts HTTP and GraphQL requests using the http and graphql namespaces, returning mock responses via HttpResponse; it also mocks Server-Sent Events with sse and WebSocket connections with ws. In tests, setupServer (from msw/node) intercepts at the request-client level; in the browser, setupWorker (from msw/browser) uses a Service Worker; React Native uses @msw/react-native. MSW v2 completely removed the v1 rest namespace, res(ctx.*) response composition, and (req, res, ctx) resolver signature. MSW v3 is ESM-only and requires Node.js >=22 and TypeScript >=5.9; handlers and utilities moved to subpath entrypoints (msw/http, msw/graphql, msw/ws, msw/sse, msw/utils/*), GraphQL mocking is link-first, onUnhandledRequest is now onUnhandledFrame, and the server.events life-cycle connection event is now websocket:connection (the ws link's own addEventListener('connection') is unchanged). This guide covers v2 and v3 patterns, testing best practices, and migration from v1 and v2.
Table of Contents
- Handler Design — CRITICAL
- Setup & Lifecycle — CRITICAL
- Request Reading — HIGH
- Response Construction — HIGH
- Test Patterns — HIGH
- GraphQL — MEDIUM
- Utilities — MEDIUM
1. Handler Design
Impact: CRITICAL
Rule: Use http Namespace Instead of rest
The rest namespace was removed in MSW 2.0. All HTTP handlers use http.
// INCORRECT — rest does not exist in v2
// @ts-expect-error msw has no 'rest' export since v2
import { rest } from 'msw'
// @ts-expect-error v1 resolver signature (req, res, ctx)
rest.get('/api/user', (req, res, ctx) => res(ctx.json({ name: 'John' })))
// CORRECT
import { http, HttpResponse } from 'msw/http'
http.get('/api/user', () => HttpResponse.json({ name: 'John' }))Rule: Never Put Query Parameters in Handler URL Predicates
MSW matches by pathname only. Query parameters are stripped from the predicate, so including them widens the handler to every request on that path rather than narrowing it. MSW logs a warning when it finds them.
// INCORRECT — query string stripped; this matches ALL '/post' requests
http.get('/post?id=1', resolver)
// CORRECT — read query params inside the resolver
http.get('/post', ({ request }) => {
const url = new URL(request.url)
const id = url.searchParams.get('id')
return HttpResponse.json({ id })
})Rule: Use v2 Destructured Resolver Signature
v2 resolvers receive a single info object: { request, params, cookies, requestId }.
// INCORRECT — v1 triple signature
// @ts-expect-error v2 resolvers take one info object, not (req, res, ctx)
http.get('/api/user/:id', (req, res, ctx) => {
return res(ctx.json({ id: req.params.id }))
})
// CORRECT — v2 destructured info object
http.get('/api/user/:id', ({ request, params, cookies }) => {
return HttpResponse.json({ id: params.id })
})Resolver info properties:
| Property | Type | Description |
request | Request | Standard Fetch API Request object |
params | Record<string, string | string[]> | Path parameters. In msw 3.0.3 a repeating param (:segments+, :path*) arrives as one string with its slashes ('a/b'); the docs and the type also allow an array |
cookies | Record<string, string> | Parsed request cookies |
requestId | string | Unique request identifier |
finalize | Function | Schedule a cleanup callback to run after the resolver completes |
Rule: Use HttpResponse Static Methods Instead of res(ctx.*)
v2 uses standard Response objects constructed via HttpResponse.
// INCORRECT — res() and ctx are removed
// @ts-expect-error v2 resolvers take one info object, not (req, res, ctx)
http.get('/api/user', (req, res, ctx) => {
return res(ctx.status(200), ctx.json({ name: 'John' }))
})
// CORRECT
http.get('/api/user', () => {
return HttpResponse.json({ name: 'John' }, { status: 200 })
})Complete v1 to v2 mapping:
| v1 Pattern | v2 Equivalent |
res(ctx.json(data)) | HttpResponse.json(data) |
res(ctx.text(str)) | HttpResponse.text(str) |
res(ctx.xml(str)) | HttpResponse.xml(str) |
res(ctx.body(str)) | new HttpResponse(str) |
res(ctx.status(code)) | new HttpResponse(null, { status: code }) |
res(ctx.set(name, val)) | Include in headers init |
res(ctx.delay(ms), ...) | await delay(ms); return HttpResponse.json(...) |
res(ctx.cookie(name, val)) | headers: { 'Set-Cookie': 'name=val' } |
res.networkError(msg) | HttpResponse.error() |
res.once(...) | http.get(url, resolver, { once: true }) |
WebSocket Handlers (ws)
MSW mocks WebSocket connections too, through the ws namespace:
import { ws } from 'msw/ws'
const chat = ws.link('wss://chat.example.com')
export const handlers = [
chat.addEventListener('connection', ({ client }) => {
client.addEventListener('message', (event) => {
client.send(`echo: ${event.data}`)
})
}),
]ws.link(url: string | URL | RegExp) matches an endpoint; the connection event fires whenever a client opens a matching connection. client.send() accepts string, Blob and ArrayBuffer; link.broadcast() / link.broadcastExcept() reach every connected client; server.connect() forwards to the real server — without it, the handler is the server. See references/handler-api.md for the full member list.
2. Setup & Lifecycle
Impact: CRITICAL
Rule: Import Server/Worker from Correct Subpaths
// INCORRECT
// @ts-expect-error setupServer is exported from 'msw/node', not 'msw'
import { setupServer } from 'msw'// CORRECT
import { setupServer } from 'msw/node' // Node.js (tests, SSR)
import { setupWorker } from 'msw/browser' // Browser (Storybook, dev)
import { http, HttpResponse } from 'msw/http' // Handlers and utilities| Export | Import from |
http, HttpResponse | 'msw/http' |
graphql | 'msw/graphql' (install the graphql package) |
ws, sse | 'msw/ws', 'msw/sse' |
delay, bypass, passthrough | 'msw/utils/delay', 'msw/utils/bypass', 'msw/utils/passthrough' |
setupServer | 'msw/node' |
setupWorker | 'msw/browser' |
setupServer (React Native) | '@msw/react-native' |
msw/node pulls in Node.js modules such as http that do not exist in React Native — replace any msw/node import in React Native code with @msw/react-native.
Rule: Always Use beforeAll/afterEach/afterAll Lifecycle Pattern
Missing afterEach(() => server.resetHandlers()) causes handlers added via server.use() to leak between tests.
// INCORRECT — handlers leak
beforeAll(() => server.listen())
afterAll(() => server.close())
// CORRECT
beforeAll(() => server.listen({ onUnhandledFrame: 'error' }))
afterEach(() => server.resetHandlers())
afterAll(() => server.close())| Hook | Method | Purpose |
beforeAll | server.listen() | Start intercepting requests |
afterEach | server.resetHandlers() | Remove runtime overrides |
afterAll | server.close() | Stop intercepting, clean up |
Rule: Organize Mocks in src/mocks/
src/mocks/
├── handlers.ts # Shared happy-path handlers
├── node.ts # setupServer(...handlers)
└── browser.ts # setupWorker(...handlers)// src/mocks/handlers.ts
import { http, HttpResponse } from 'msw/http'
export const handlers = [
http.get('/api/user', () => HttpResponse.json({ name: 'John' })),
http.get('/api/posts', () => HttpResponse.json([{ id: 1, title: 'Post' }])),
]// src/mocks/node.ts
import { setupServer } from 'msw/node'
import { handlers } from './handlers'
export const server = setupServer(...handlers)// src/mocks/browser.ts
import { setupWorker } from 'msw/browser'
import { handlers } from './handlers'
export const worker = setupWorker(...handlers)Browser setup also needs the worker script on disk before worker.start() will work:
npx msw init <PUBLIC_DIR> --saveThis copies mockServiceWorker.js into your public directory — commit it, and verify it is served at /mockServiceWorker.js. The --save flag records msw.workerDirectory in package.json, so the script is copied again automatically whenever you install msw.
On Vite projects, prefer the msw/vite plugin (v3): it serves the worker script for you — no msw init copy step — and exposes a preconfigured network via the virtual:msw module.
3. Request Reading
Impact: HIGH
Rule: Clone Request Before Reading Body in Lifecycle Events
Reading request.json() consumes the body stream. In lifecycle events, clone first.
// INCORRECT — body consumed: the handler lookup fails and the client gets a 500
server.events.on('request:start', async ({ request }) => {
const body = await request.json()
})
// CORRECT
server.events.on('request:start', async ({ request }) => {
const body = await request.clone().json()
})Rule: Always Await Body Reading Methods
v2 uses the standard Fetch API Request — body reading is always async.
// INCORRECT — request.body is a ReadableStream
http.post('/api/user', ({ request }) => {
const body = request.body
})
// CORRECT
http.post('/api/user', async ({ request }) => {
const body = await request.json()
})| Method | Returns | Use for |
await request.json() | Parsed object | JSON payloads |
await request.text() | String | Plain text, HTML |
await request.formData() | FormData | Form submissions |
await request.arrayBuffer() | ArrayBuffer | Binary data |
await request.blob() | Blob | Binary with MIME type |
4. Response Construction
Impact: HIGH
Rule: Use HttpResponse for Cookie Mocking
In the browser, a native Response drops Set-Cookie (a forbidden response header). HttpResponse keeps it. Node.js keeps the header on either class, so use HttpResponse for handlers that run in both.
// INCORRECT in the browser — Set-Cookie silently dropped
new Response(null, { headers: { 'Set-Cookie': 'token=abc' } })
// CORRECT
new HttpResponse(null, { headers: { 'Set-Cookie': 'token=abc' } })Rule: Use HttpResponse.error() for Network Failures
// INCORRECT — MSW handles this as an unhandled resolver exception, not a network error
http.get('/api/data', () => { throw new Error('fail') })
// CORRECT — fetch rejects with a TypeError (Node.js: 'fetch failed'; each browser has its own text)
http.get('/api/data', () => HttpResponse.error())Throwing a Response/HttpResponse is different, and is supported: a thrown Response is used as the mocked response, which short-circuits a shared guard.
function withAuthorization(request: Request) {
if (!request.headers.get('authorization')) {
throw HttpResponse.text('Unauthorized', { status: 401 })
}
}
http.get('/resource', ({ request }) => {
withAuthorization(request)
return HttpResponse.json({ id: 'abc-123' })
})Rule: Use sse() for Server-Sent Events, ReadableStream for Chunked
import { sse } from 'msw/sse'
// Server-Sent Events — the sse handler owns the wire format and headers
export const handlers = [
sse<{ greeting: string }>('/api/events', ({ client }) => {
client.send({ id: 'abc-123', event: 'greeting', data: 'Hello, John!' })
client.close()
}),
]import { http, HttpResponse } from 'msw/http'
import { delay } from 'msw/utils/delay'
// Non-SSE chunked transfer — a ReadableStream body is still the right tool
http.get('/api/stream', () => {
const stream = new ReadableStream({
async start(controller) {
controller.enqueue(new TextEncoder().encode('chunk1'))
await delay(100)
controller.enqueue(new TextEncoder().encode('chunk2'))
controller.close()
},
})
return new HttpResponse(stream, {
headers: { 'Content-Type': 'text/plain' },
})
})5. Test Patterns
Impact: HIGH
Rule: Test Application Behavior, Not Request Mechanics
// INCORRECT — tests implementation
expect(fetch).toHaveBeenCalledWith('/api/login', expect.any(Object))
// CORRECT — tests observable behavior
await waitFor(() => {
expect(screen.getByText('Welcome, John!')).toBeInTheDocument()
})Where the request's validity is the point, validate it inside the handler and return an error response. Exception: one-way requests that leave no trace in the application (analytics, monitoring) may be asserted directly via the life-cycle events API (server.events.on('request:start', ...)).
Rule: Use server.use() for Per-Test Overrides
test('shows error state', async () => {
server.use(
http.get('/api/user', () => new HttpResponse(null, { status: 500 }))
)
render(<UserProfile />)
await waitFor(() => {
expect(screen.getByText('Something went wrong')).toBeInTheDocument()
})
})Rule: Use server.boundary() for Concurrent Test Isolation
it.concurrent('admin view', server.boundary(async () => {
server.use(
http.get('/api/user', () => HttpResponse.json({ role: 'admin' }))
)
// Override scoped to this boundary
}))
it.concurrent('member view', server.boundary(async () => {
server.use(
http.get('/api/user', () => HttpResponse.json({ role: 'member' }))
)
// Isolated from admin test
}))Rule: Set onUnhandledFrame: 'error'
// Default 'warn' prints a warning and passes the request through — missing handlers don't fail tests
server.listen({ onUnhandledFrame: 'error' })| Strategy | Behavior |
'warn' (default) | Console warning, request passes through |
'error' | Console error; the request fails with a network error (TypeError) |
'bypass' | Silent, passes through |
({ frame, defaults }) => {} | Conditional handling. Opts you out of the built-in common-asset exemption — re-apply it with isCommonAssetRequest(frame.data.request) after frame instanceof HttpNetworkFrame |
6. GraphQL
Impact: MEDIUM
Rule: Return { data } / { errors } Directly
// INCORRECT — ctx.data() removed in v2
// @ts-expect-error v1 resolver signature (req, res, ctx), and msw 3 has no graphql.query()
graphql.query('GetUser', (req, res, ctx) => res(ctx.data({ user: {} })))
// INCORRECT in v3 — query/mutation/operation are no longer on the namespace
// @ts-expect-error msw 3 has no graphql.query(); define operations on graphql.link()
graphql.query('GetUser', ({ variables }) => { /* ... */ })
// CORRECT — v3 is link-first: define operations on a graphql.link()
import { graphql } from 'msw/graphql'
import { HttpResponse } from 'msw/http'
const api = graphql.link('https://api.example.com/graphql')
api.query('GetUser', ({ variables }) => {
return HttpResponse.json({
data: { user: { id: variables.id, name: 'John' } },
})
})Rule: Use graphql.link() for Every Endpoint (required in v3)
v2 allowed graphql.query() directly on the namespace; v3 is link-first — operations exist only on a link. Give each API its own link: a wildcard or RegExp link ('*', /graphql$/) matches the operation on every endpoint. msw/graphql needs the graphql package (>=16), an optional peer dependency of msw; without it the import fails with Cannot find package 'graphql'.
const github = graphql.link('https://api.github.com/graphql')
const internal = graphql.link('/graphql')
export const handlers = [
github.query('GetUser', ({ variables }) => {
return HttpResponse.json({ data: { user: { login: variables.login } } })
}),
internal.query('GetUser', ({ variables }) => {
return HttpResponse.json({ data: { user: { id: variables.id } } })
}),
]Link URLs accept relative or absolute strings, wildcards, and regular expressions. v3 also adds api.subscription() for mocking GraphQL subscriptions, publishing data through the subscription object exposed on the resolver:
api.subscription('OnCreateMessage', ({ subscription }) => {
subscription.publish({ data: { messageCreated: { content: 'Welcome!' } } })
})7. Utilities
Impact: MEDIUM
Rule: bypass() vs passthrough() — Not Interchangeable
import { bypass } from 'msw/utils/bypass'
import { passthrough } from 'msw/utils/passthrough'
// passthrough() — let intercepted request through to real server
http.get('/api/flags', () => {
if (process.env.USE_REAL_FLAGS) return passthrough()
return HttpResponse.json({ enabled: true })
})
// bypass() — make additional request that won't be intercepted
http.get('/api/user', async ({ request }) => {
const real = await fetch(bypass(request))
const data = await real.json()
return HttpResponse.json({ ...data, extra: 'mocked' })
})| Function | Creates new request? | Original continues? | Use case |
passthrough() | No | Yes | Conditional mocking |
bypass(request) | Yes | No | Response patching |
Rule: Use Explicit Milliseconds with delay()
// INCORRECT — in Node.js the realistic delay is cut down ("negated to prevent
// it affecting the test performance"), so neither is observable in a test
await delay()
await delay('real') // Sets the same realistic response time — same result
// CORRECT
await delay(200) // Always waits 200ms
await delay('infinite') // Never resolves — test timeout handling| Usage | Browser | Node.js |
delay() | Random ~100-400ms | Negated |
delay(ms) | Waits ms | Waits ms |
delay('real') | Random ~100-400ms | Negated — identical to delay() |
delay('infinite') | Never resolves | Never resolves |
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

