Client code that goes to the network is tested by replacing not fetch but the network boundary itself. The difference is fundamental: mocking fetch ties the test to a specific client and breaks if tomorrow the project moves from fetch to axios. Intercepting at the network level leaves the code under test untouched - it makes a real request, and the tool answers instead of the server. This is the approach Testing Library recommends. On top of that, the test gets real Request and Response objects, not homemade client stubs that easily drift from reality.
The tool for this is MSW (Mock Service Worker). In Node it raises an interceptor server via setupServer with a list of handlers. A handler describes a route: http.get or http.post and a response via HttpResponse.json with a body and status. The application code calls a plain fetch to its URL, and MSW catches the request and returns the given response, replacing neither the client nor the call itself.
The interceptor server's lifecycle is wired around the tests. beforeAll calls server.listen with onUnhandledRequest: 'error', so an unmocked request fails explicitly rather than going to the real network. afterEach calls server.resetHandlers, removing temporary overrides. afterAll calls server.close. Each step closes its own risk: startup, isolation between tests and a clean shutdown.
Individual tests override the response via server.use. It adds a handler on top of the base ones - you can return 500, an empty body or a slow response and check how the interface behaves in that case. This is exactly how the error branch is tested without touching the component code: the server answers differently, and the app goes through its usual handling path.
This gives the full trio of states: loading, success and error. Naive tests often check only the successful response, because writing a fetch mock for an error is tedious. With MSW an error is one line of server.use with the needed status, so an empty list, 500 and a timeout are tested as easily as the happy path and do not remain a blind spot.
MSW v2 is built on Fetch API primitives. Inside a resolver you get a real Request: the body is read via await request.json(), and the response is formed by an HttpResponse object rather than the deprecated res and ctx. These are the same types as in the browser, so handlers read as ordinary server code and move between tests and a development mock without rewriting.
The contrast with mocking the http module via vi.mock is telling. A client mock replaces a specific function and rigidly ties the test to how exactly the code goes to the network; changing the client breaks all such tests at once. MSW stands at the network boundary, below the client, so it survives a library change: the way a request is sent changes, but the network contract stays the same.
The typical failure is forgetting server.resetHandlers in afterEach. Then a temporary handler from one test leaks into the next, and the result starts to depend on order: the test is green alone and red in the set. The second failure is mocking fetch directly after all: the test becomes brittle and checks the client's implementation rather than the app's behavior at the network boundary.
npm i -D msw
import { setupServer } from 'msw/node'
import { http, HttpResponse } from 'msw'
const server = setupServer(
http.get('/api/cart', () =>
HttpResponse.json({ items: [{ sku: 'A', quantity: 1 }] })),
)
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
afterEach(() => server.resetHandlers())
afterAll(() => server.close())test('shows an error on a server failure', async () => {
server.use(
http.get('/api/cart', () => HttpResponse.json(null, { status: 500 })),
)
render(<Cart />)
expect(await screen.findByRole('alert')).toHaveTextContent(/failed/i)
})