| |

React 86 ⚛️ Mocking API Calls with MSW (Mock Service Worker)

⏱️ Estimated read: 12 min | |

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

FilePurpose
src/mocks/handlers.jsShared request handlers
src/mocks/server.jssetupServer(...handlers)
src/setupTests.jsLifecycle hooks

Lifecycle Hooks

HookCallPurpose
beforeAllserver.listen()Start interception
afterEachserver.resetHandlers()Remove per-test overrides
afterAllserver.close()Stop interception

Handler Namespaces

NamespaceProtocol
httpREST (GET, POST, etc.)
graphqlGraphQL queries and mutations
wsWebSocket connections

Per-Test Override

MethodEffect
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

PitfallWhy It HappensFix
Test sees loading stateUsed getBy instead of findByawait screen.findByText
Override leaks to next testNo resetHandlersAdd afterEach
Unhandled request hits real APINo onUnhandledRequestSet 'error'
setupServer import failsImported from mswImport from msw/node
Handler path does not matchRelative URL in NodeUse 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

ItemValue
Interception levelNetwork
Node setupsetupServer from msw/node
Handler namespacehttp, graphql, ws
Response classHttpResponse
Lifecyclelisten, resetHandlers, close
Per-test overrideserver.use()
Unhandled requestonUnhandledRequest: 'error'
Async queryfindBy

Key takeaways:

  • MSW intercepts at the network level, not the module level. The component’s fetch call runs, the request is matched against handlers, and the response is returned as if from a real server .
  • The setupServer import is from msw/node. The handlers are imported from msw. Mixing the paths is the most common setup error .
  • The lifecycle is mandatory. beforeAll starts interception, afterEach resets overrides, and afterAll stops interception. Without the reset, a server.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 .
  • findBy is the async query. The component fetches on mount, and the element appears after the promise resolves. getBy would 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.


Was this helpful?


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!