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-09tag (the code matches last time’s — this part explains thesrc/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

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/)
// 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.
// 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.
// 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.
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)
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.

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.

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.
