This is Part 9 of the “Frontend Testing, Done Right” series. Browse the full series · Glossary

The moment a component starts calling an API, tests get shaky. A slow server makes them slow, changed data breaks them, and offline they’re wiped out. MSW (Mock Service Worker) intercepts network requests at the boundary and returns prepared responses, solving the problem at its root. This one article covers installation, handlers, and simulating both success and failure.

If you’ve been following the series — today we reveal the identity of that src/mocks/ folder you’ve been “just copying” since the async part. Landed here from a search? The examples are self-contained; we use Vitest, but MSW usage is identical under Jest. (The demo app’s UI strings are Korean — read 검색 as the “Search” label and 불러오지 못했습니다 as the failure message.)

Practice code: this installment’s state lives in the step-09 tag (the code matches last time’s — this part explains the src/mocks/ that was already there). You can also open it in StackBlitz.

What we cover today

  • MSW handler and server setup
  • Simulating success and failure responses
  • Why not just mock fetch directly
A diagram of MSW intercepting a component's API request at the network boundary and returning a mock response, while the real server never receives the request
A diagram of MSW intercepting a component's API request at the network boundary and returning a mock response, while the real server never receives the request

MSW in one phrase: the apartment reception desk. Just before a request (the courier) leaves the front door, the desk intercepts it and hands over a package it was keeping (the prepared response). The crucial part — the component never knows. It believes it spoke to a real server. The fetch code, error handling, JSON parsing all run for real. The only fake thing is what lies beyond the wire.

“Wait — couldn’t I just replace fetchUsers wholesale with the vi.fn() from the test doubles part?” Good instinct, but there’s a decisive difference — that way, the code inside fetchUsers never executes. A wrong URL or missing error handling would go unnoticed. MSW runs your function to the end, for real, and fakes only the network. We’ll come back to where to draw that line.


Handlers and the server

Version first: this article assumes MSW v2 (npm i -D msw@latest). Import paths and APIs differ from v1, so mixing older articles in gets confusing.

Define the interception rules (handlers), then stand up a Node server for tests. (repo src/mocks/)

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

export const users = [
  { id: '1', name: 'Alice Kim', email: '[email protected]' },
  { id: '2', name: 'Bob Lee', email: '[email protected]' },
  { id: '3', name: 'Carol Park', email: '[email protected]' },
]

export const handlers = [
  // when a GET /api/users request arrives → respond with the list above as JSON
  http.get('/api/users', () => HttpResponse.json(users)),
]

Reading it is simple. http.get(path, resolver) — “when a GET request hits this path, this function (the resolver) builds the response.” HttpResponse.json(users) is the helper that shapes it into JSON. Stack rules into the handler array, and it becomes your fake server’s API spec.

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

// a test (Node) mock server armed with the handler rules
export const server = setupServer(...handlers)

Then wire the lifecycle into the setup file. The setup.ts from the environment part reaches its final form here.

ts
// src/test/setup.ts
import '@testing-library/jest-dom/vitest'
import { afterAll, afterEach, beforeAll } from 'vitest'
import { server } from '../mocks/server'

beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
afterEach(() => server.resetHandlers())   // prevent cross-test contamination
afterAll(() => server.close())

Each of the three lines has a job. listen is the desk clocking in when the test file starts; close clocks out at the end. The middle one, resetHandlers, restores the handlers to their defaults after every test — you’ll see why that matters in the next section. onUnhandledRequest: 'error' is the “no unregistered visitors” option: any request without a handler fails the test on the spot, so you learn “wait, this component calls that API too?” right where it happens.


The success path — with handlers in place, it just works

Let’s see what a component test looks like on top of these handlers. The stage is UserSearch, the search component we upgraded to server data last time — on mount, it loads the list from /api/users and draws it. New here? Think “an ordinary component that fetches a list and shows it,” and you’re set.

tsx
it('loads and shows the user list', async () => {
  render(<UserSearch />)
  // wait until the list MSW returned is drawn, then assert
  expect(await screen.findByText('Alice Kim')).toBeInTheDocument()
})

Notice MSW appears nowhere in the test? That’s the point. The component calls /api/users, the desk answers with Alice·Bob·Carol per the registered rule, and the test verifies only what’s drawn. A test with the network variable removed — same result every run.


Failure, just as easily

You might remember — last time we deferred the error-state (role="alert") test, calling it “the next part’s specialty.” That’s now. To fake a server error in just one test, override the handler. (an actual repo test)

tsx
it('announces server errors with role="alert"', async () => {
  // override /api/users with a 500 for this test only (auto-restored afterward)
  server.use(http.get('/api/users', () => new HttpResponse(null, { status: 500 })))
  render(<UserSearch />)

  expect(await screen.findByRole('alert')).toHaveTextContent('불러오지 못했습니다')
})

server.use(...) lays a temporary rule on top of the existing ones. And the resetHandlers() from setup sweeps that temporary rule away after each test — so this 500 applies only here, and the next test gets normal responses again. You could never ask a real server “please return a 500 for a minute” — the reception desk grants it in one line.

A timeline diagram of the MSW lifecycle - it starts with listen in beforeAll, test 1 gets a 200 from the default handlers, test 2 overlays a 500 override with server.use, but afterEach's resetHandlers sweeps the temporary rule away each time so test 3 gets a 200 again, and it ends with close in afterAll
A timeline diagram of the MSW lifecycle - it starts with listen in beforeAll, test 1 gets a 200 from the default handlers, test 2 overlays a 500 override with server.use, but afterEach's resetHandlers sweeps the temporary rule away each time so test 3 gets a 200 again, and it ends with close in afterAll

Why MSW instead of mocking fetch

vi.spyOn(global, 'fetch') ties you to the implementation. If someone swaps fetch for axios tomorrow, behavior stays identical and the tests shatter anyway — the ‘implementation test’ the philosophy part warned about, replayed at the network. And as noted above, replacing fetchUsers wholesale leaves the code inside unverified. MSW intercepts at the network boundary, so whether your code uses axios, fetch, or React Query, tests stand unchanged — and your code runs for real down to the last line.

A comparison diagram of direct fetch mocking versus MSW - on the left, vi.spyOn swaps out fetch so the code inside fetchUsers never runs and switching to axios breaks the tests; on the right, MSW intercepts at the network boundary so all of your code runs for real and swapping HTTP clients leaves tests untouched
A comparison diagram of direct fetch mocking versus MSW - on the left, vi.spyOn swaps out fetch so the code inside fetchUsers never runs and switching to axios breaks the tests; on the right, MSW intercepts at the network boundary so all of your code runs for real and swapping HTTP clients leaves tests untouched

So the rule is: my code real, only beyond the wire fake. It’s the test doubles part’s “fake only the boundary” principle, completed at the network.

On top of that, the same handlers can power a browser mock during development (src/mocks/browser.ts + npx msw init public). Build once, use twice.

Actually — three times. This series’ live demo is on GitHub Pages with no backend at all, yet search works — the same MSW handlers answer /api/users in the deployed build. The mock we made for tests moonlights as the demo server.


One-page summary

  • MSW = intercepts requests at the network boundary and returns fake responses — your code never knows it isn’t a real server
  • Three pieces: handlers (request→response rules) + server (test Node) + the setup lifecycle (listen / resetHandlers / close)
  • Failure simulation is one line of server.use(...) — valid for that test only, auto-restored afterward
  • The rule is “my code real, only beyond the wire fake” — mock fetch directly and tests break whenever the implementation changes
  • onUnhandledRequest: 'error' watches for requests sneaking out, and the same handlers get reused for dev and the demo

The network is in your hands now

A stable foundation for component tests is in place. Next up is this chapter’s highlight — folding accessibility into your tests.

The reception desk we built today stays on duty for the rest of the series. Whatever component calls whatever API from here on, one more handler card gets you any scene you want — success or failure.

Level up: you can isolate the network and simulate success and failure scenarios at will.

Next up: folding accessibility into tests — role-based queries

Bumped into an unfamiliar term? The glossary has them all, one line each.