React 86 ⚛️ Mocking API Calls with MSW (Mock Service Worker)
Mock Service Worker is not a Jest mock. It is a network-level interceptor. When your React component calls fetch('/api/users'), MSW intercepts that request before it leaves the process, matches it against the handlers you defined, and returns the mocked response as if it came from a real server . The component’s actual fetch call runs, the actual response parsing runs, and the actual state update runs. The only thing that is fake is the response body. This is why MSW produces tests that are closer to production than module-level mocks, which replace the entire fetch function and skip the component’s real request code .
The distinction matters because module-level mocking tests the component against a mock, not against the API contract. If the component changes its request URL or its headers, a Jest mock of fetch will still return the old data and the test will pass. MSW will fail because the URL no longer matches the handler . The handler is the contract; the test asserts that the component respects it.
Key point: MSW intercepts requests at the network level, not the module level. Use setupServer from msw/node in the test setup, define shared handlers in handlers.js, and override them per test with server.use() for error cases. The beforeAll/afterEach/afterAll lifecycle is mandatory: server.listen() starts interception, server.resetHandlers() removes per-test overrides, and server.close() stops interception.
Why MSW is the recommended mocking approach
The realism problem. A Jest mock of fetch replaces the function entirely. The component’s code that constructs the Request object, sets the headers, and serializes the body never runs. A test that passes under that mock can fail in production because the real fetch rejects the malformed request. MSW intercepts the request after the component has built it, so the request construction is tested along with the response handling .
The shared-contract problem. A project with ten components that all call the same API needs the same response shape in every test. With module mocks, each test file defines its own mock and the shapes drift. With MSW, the handlers are defined once in a shared file and imported by every test. The response shape is a single source of truth .
The error-path problem. Testing a 500 response with a module mock means replacing fetch with a function that returns a rejected promise, which is not how a 500 works. A 500 is a resolved response with a status code, not a rejected promise. MSW’s HttpResponse with { status: 500 } produces the correct behavior: the component’s response.ok check fails, and the error path runs as it would in production .
The unhandled-request problem. By default, MSW allows unhandled requests to pass through to the real network. In a test suite, that means a component that calls an endpoint the handlers do not cover will make a real network request, possibly to production. The onUnhandledRequest: 'error' option turns any unhandled request into a test failure, which is the correct behavior for a hermetic test suite .
a. The setup and the shared handlers
The setup has three files: the handlers that define the mock API, the server that wires them to the test lifecycle, and the test that uses the component.
The handlers file uses the http namespace and HttpResponse class. The path can be relative, absolute, or a pattern with path parameters.
// src/mocks/handlers.js
// Import the http namespace for request matching and HttpResponse for responses
import { http, HttpResponse } from 'msw'
export const handlers = [
// A GET handler for the user detail endpoint
// The :id segment is a path parameter
http.get('/api/users/:id', ({ params }) => {
// params.id is the value from the URL
const { id } = params
// Return a JSON response with the user data
return HttpResponse.json({
id: parseInt(id),
name: 'John Doe',
email: 'john@example.com',
})
}),
// A GET handler for the list endpoint
http.get('/api/posts', () => {
// Return an array of posts
return HttpResponse.json([
{ id: 1, title: 'Post 1' },
{ id: 2, title: 'Post 2' },
])
}),
// A POST handler that reads the request body
http.post('/api/users', async ({ request }) => {
// Read the JSON body from the request
const body = await request.json()
// Return the created resource with a 201 status
return HttpResponse.json({ id: 3, ...body }, { status: 201 })
}),
]
The server file creates the setupServer instance with the handlers. The import path is msw/node, not msw .
// src/mocks/server.js
// The setupServer is the Node.js equivalent of the browser worker
import { setupServer } from 'msw/node'
import { handlers } from './handlers'
// The spread operator passes each handler as a separate argument
export const server = setupServer(...handlers)
The test setup file wires the server to the Vitest or Jest lifecycle. The beforeAll starts interception, the afterEach resets the per-test overrides, and the afterAll stops interception .
// src/setupTests.js
import { beforeAll, afterEach, afterAll } from 'vitest'
import { server } from './mocks/server'
// Start interception before any test runs
// onUnhandledRequest: 'error' fails the test on an unmatched request
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
// Remove handlers added via server.use() in individual tests
afterEach(() => server.resetHandlers())
// Stop interception and clean up after all tests
afterAll(() => server.close())
The onUnhandledRequest: 'error' is not optional. Without it, a request that does not match a handler falls through to the real network, and the test can pass or fail based on external factors .
b. The test and the per-test overrides
The test renders the component, waits for the mocked response, and asserts on the rendered output. The findBy query is the async variant that retries until the element appears.
// src/UserProfile.test.jsx
import { render, screen } from '@testing-library/react'
import { http, HttpResponse } from 'msw'
import { server } from './mocks/server'
import UserProfile from './UserProfile'
describe('UserProfile', () => {
it('displays user data', async () => {
// Render the component
render(<UserProfile userId={1} />)
// findByText waits for the mocked response to render
expect(await screen.findByText('John Doe')).toBeInTheDocument()
expect(screen.getByText('john@example.com')).toBeInTheDocument()
})
it('handles server errors', async () => {
// Override the handler for this test only
// server.use() prepends the handler so it takes precedence
server.use(
http.get('/api/users/:id', () => {
// Return a 500 response with no body
return new HttpResponse(null, { status: 500 })
})
)
render(<UserProfile userId={1} />)
// The component's error state renders
expect(await screen.findByText(/Error/i)).toBeInTheDocument()
})
it('handles different user data', async () => {
// Override with a different user
server.use(
http.get('/api/users/:id', () => {
return HttpResponse.json({
id: 2,
name: 'Jane Smith',
email: 'jane@example.com',
})
})
)
render(<UserProfile userId={2} />)
expect(await screen.findByText('Jane Smith')).toBeInTheDocument()
})
})
The server.use() call prepends the override to the handler stack, so it takes precedence over the shared handler. The afterEach reset removes it before the next test, which prevents the error handler from leaking into the success test .
The findByText is the correct query for an async render. The component fetches on mount, the promise resolves in a microtask, the state updates, and Vue or React re-renders. The getByText would run before the re-render and fail. The findByText retries until the element appears or the timeout expires .
c. The GraphQL and WebSocket handlers
MSW is not limited to REST. The graphql namespace intercepts queries and mutations by operation name, and the ws namespace intercepts WebSocket connections.
// src/mocks/handlers.js
import { graphql, http, HttpResponse, ws } from 'msw'
// A GraphQL query handler
// The operation name is the first argument
const graphqlHandlers = [
graphql.query('GetUser', ({ variables }) => {
// variables.id is the query variable
return HttpResponse.json({
data: {
user: { id: variables.id, name: 'Alice' },
},
})
}),
]
// A WebSocket handler
// The ws.link matches the WebSocket URL
const chat = ws.link('wss://chat.example.com')
const wsHandlers = [
chat.addEventListener('connection', ({ client }) => {
// Listen for messages from the client
client.addEventListener('message', (event) => {
// Echo the message back with a prefix
client.send(`Server received: ${event.data}`)
})
}),
]
export const handlers = [...graphqlHandlers, ...wsHandlers]
The GraphQL response must be wrapped in data for a successful query and errors for a failure. The WebSocket handler receives the client object, which has send for messages to the client and addEventListener for messages from the client .
Complete Example Session
The following session builds a search component with a debounced API call, a success test, and an error test using MSW.
// ============================================
// PART 1: THE COMPONENT
// ============================================
import { useState, useEffect } from 'react'
export function SearchBox({ onSearch }) {
const [query, setQuery] = useState('')
const [results, setResults] = useState([])
const [error, setError] = useState(null)
useEffect(() => {
if (!query) return
// The fetch call is what MSW intercepts
fetch(`/api/search?q=${query}`)
.then((res) => {
// A 500 response is not an exception; res.ok is false
if (!res.ok) throw new Error('Search failed')
return res.json()
})
.then(setResults)
.catch(setError)
}, [query])
return (
<div>
<input value={query} onChange={(e) => setQuery(e.target.value)} />
{error && <p role="alert">Error</p>}
<ul>
{results.map((r) => <li key={r.id}>{r.title}</li>)}
</ul>
</div>
)
}
// ============================================
// PART 2: THE HANDLERS
// ============================================
// src/mocks/handlers.js
import { http, HttpResponse } from 'msw'
export const handlers = [
http.get('/api/search', ({ request }) => {
// Read the query parameter from the request URL
const url = new URL(request.url)
const q = url.searchParams.get('q')
// Return results that reflect the query
return HttpResponse.json([
{ id: 1, title: `Result for "${q}"` },
])
}),
]
// ============================================
// PART 3: THE TEST
// ============================================
// src/SearchBox.test.jsx
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { http, HttpResponse } from 'msw'
import { server } from './mocks/server'
import { SearchBox } from './SearchBox'
it('shows search results', async () => {
const user = userEvent.setup()
render(<SearchBox />)
// Type into the input
await user.type(screen.getByRole('textbox'), 'react')
// Wait for the mocked response to render
expect(await screen.findByText(/Result for "react"/)).toBeInTheDocument()
})
it('shows an error on failure', async () => {
// Override the handler to return a 500
server.use(
http.get('/api/search', () => new HttpResponse(null, { status: 500 }))
)
const user = userEvent.setup()
render(<SearchBox />)
await user.type(screen.getByRole('textbox'), 'react')
// The error state renders
expect(await screen.findByRole('alert')).toHaveTextContent('Error')
})
The three parts cover the component, the handler, and the test. What the session demonstrates is that the component’s fetch runs for real, the handler intercepts it, and the test asserts on the rendered output rather than on the mock’s calls.
Quick Reference
Setup Files
| File | Purpose |
|---|---|
src/mocks/handlers.js | Shared request handlers |
src/mocks/server.js | setupServer(...handlers) |
src/setupTests.js | Lifecycle hooks |
Lifecycle Hooks
| Hook | Call | Purpose |
|---|---|---|
beforeAll | server.listen() | Start interception |
afterEach | server.resetHandlers() | Remove per-test overrides |
afterAll | server.close() | Stop interception |
Handler Namespaces
| Namespace | Protocol |
|---|---|
http | REST (GET, POST, etc.) |
graphql | GraphQL queries and mutations |
ws | WebSocket connections |
Per-Test Override
| Method | Effect |
|---|---|
server.use(handler) | Prepend handler for this test |
server.resetHandlers() | Remove all overrides |
Best Practices
✅ Do This:
// Use findBy for async elements
expect(await screen.findByText('John')).toBeInTheDocument()
// Override handlers per test
server.use(http.get('/api/users', () => new HttpResponse(null, { status: 500 })))
// Use onUnhandledRequest: 'error'
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
❌ Don’t Do This:
// Use getBy for async data
expect(screen.getByText('John')).toBeInTheDocument() // fails
// Forget resetHandlers
afterEach(() => {}) // overrides leak
// Mock the module instead of the network
jest.mock('axios') // skips the real request code
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Test sees loading state | Used getBy instead of findBy | await screen.findByText |
| Override leaks to next test | No resetHandlers | Add afterEach |
| Unhandled request hits real API | No onUnhandledRequest | Set 'error' |
setupServer import fails | Imported from msw | Import from msw/node |
| Handler path does not match | Relative URL in Node | Use absolute or the base URL |
Real-World Examples
1. Basic GET handler
http.get('/api/users', () => HttpResponse.json([{ id: 1 }]))
2. Path parameter
http.get('/api/users/:id', ({ params }) => HttpResponse.json({ id: params.id }))
3. POST with body
http.post('/api/users', async ({ request }) => HttpResponse.json(await request.json(), { status: 201 }))
4. Error override
server.use(http.get('/api/users', () => new HttpResponse(null, { status: 500 })))
5. GraphQL query
graphql.query('GetUser', () => HttpResponse.json({ data: { user: {} } }))
6. WebSocket echo
chat.addEventListener('connection', ({ client }) => client.addEventListener('message', (e) => client.send(e.data)))
7. Setup server
export const server = setupServer(...handlers)
8. Lifecycle
beforeAll(() => server.listen()); afterEach(() => server.resetHandlers())
9. Unhandled request error
server.listen({ onUnhandledRequest: 'error' })
10. Find async element
await screen.findByText('Loaded')
Visual
MSW Interception Flow
┌──────────────────────────────────────────────┐
│ Component calls fetch('/api/users') │
│ │ │
│ ▼ │
│ MSW intercepts the request │
│ │ │
│ ▼ │
│ Match against handlers │
│ ├─ match → return mocked response │
│ └─ no match → error or passthrough │
│ │ │
│ ▼ │
│ Component receives response │
│ State updates, re-renders │
└──────────────────────────────────────────────┘
Module Mock vs MSW
┌──────────────────────────────────────────────┐
│ MODULE MOCK │
│ jest.mock('axios') │
│ → entire client replaced │
│ → request code never runs │
│ │
│ MSW │
│ → request built by component │
│ → intercepted at network level │
│ → response parsing runs │
└──────────────────────────────────────────────┘
Per-Test Override
┌──────────────────────────────────────────────┐
│ Shared handlers │
│ GET /api/users → 200, John │
│ │ │
│ ▼ │
│ server.use(GET /api/users → 500) │
│ → prepended, takes precedence │
│ │ │
│ ▼ │
│ afterEach → resetHandlers() │
│ → shared handler restored │
└──────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| Interception level | Network |
| Node setup | setupServer from msw/node |
| Handler namespace | http, graphql, ws |
| Response class | HttpResponse |
| Lifecycle | listen, resetHandlers, close |
| Per-test override | server.use() |
| Unhandled request | onUnhandledRequest: 'error' |
| Async query | findBy |
Key takeaways:
- MSW intercepts at the network level, not the module level. The component’s
fetchcall runs, the request is matched against handlers, and the response is returned as if from a real server . - The
setupServerimport is frommsw/node. The handlers are imported frommsw. Mixing the paths is the most common setup error . - The lifecycle is mandatory.
beforeAllstarts interception,afterEachresets overrides, andafterAllstops interception. Without the reset, aserver.use()override leaks into the next test . server.use()prepends a handler. The override takes precedence over the shared handler for the duration of the test, and the reset restores the shared handler .onUnhandledRequest: 'error'makes the suite hermetic. A request without a handler fails the test instead of hitting the real network .findByis the async query. The component fetches on mount, and the element appears after the promise resolves.getBywould run before the render .
Remember: MSW is not a replacement for Jest’s mocking utilities. It is a replacement for the pattern of mocking fetch or axios at the module level. The component’s real request code runs, the real response parsing runs, and the test asserts on the rendered output. The handler is the contract, and the server.use() override is the tool for the error case. The findBy query is the synchronization point. The onUnhandledRequest: 'error' option is the guard against silent network calls. The setup is three files and three lifecycle hooks, and the result is a test suite that fails when the component violates the API contract rather than when the mock drifts from the code.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!