A Cart component shows the contents of the basket and a place order button, and it gets its data from the network: GET /api/cart for the items and POST /api/orders when the user confirms the purchase. Mocking the network is not replacing your code but the HTTP exchange itself: the component under test honestly calls fetch, but the request is intercepted and handed a response you defined in advance. The boundary here is the network, not the client function, and that decides everything.
The naive move is to mock fetch itself. One line, global.fetch = jest.fn() with mockResolvedValue, and the test gets any response it wants. It feels natural: fetch is a global function, and it looks like the response is under control.
It breaks where it meets reality. The test is nailed to a client: swap fetch for axios and the mock falls apart even though the behavior is the same. And your stub is not a real response: it has no ok, status, or headers, and no honest json, so you hand-craft an object that merely pretends to be a Response, and it is easy to miss a field the code reads. The boundary is replaced too early - part of the path from request to parsing the response never runs.
MSW - Mock Service Worker - turns the approach around: it intercepts the request at the network boundary, not inside the code. The module under test calls fetch with no edits, MSW catches the request and returns the response you described. This is exactly what Testing Library recommends: the less code you touch for a test, the closer it is to real usage. Swap fetch for axios and the tests will not notice - both go out to the same network.
In Node MSW is brought up through setupServer - not a real server on a port, but an interceptor for outgoing requests. You describe handlers: http.get and http.post take a path and a resolver that returns HttpResponse.json with a body and a status. The list of handlers defines the happy path - a cart and a successful order.
The lifecycle wiring is three lines, each with its reason. server.listen in beforeAll turns interception on before the first test; the onUnhandledRequest: 'error' option fails the test on a request with no handler - at the default value warn such a request would only get a warning in the log and go out to the real network. server.resetHandlers in afterEach drops the test's overrides. server.close in afterAll turns interception off so it does not leak into other files.
The base handlers describe normal operation. Edge cases are set pointwise through server.use - it puts a handler on top of the base ones for the duration of the test. This is how you check the three states: loading, while the response is in flight; success with data; error, when the server returns a 500. The same trick sets a slow response through a delay, so you can catch the spinner.
The alternative is to mock the http module with jest.mock('axios'). Formally it works, but it brings you back to the same coupling: the test again knows the client's name and again gets something that is not a real Response. For the network boundary MSW is strictly preferable - it isolates at the real seam, not at a library. Hence the main traps: forget resetHandlers and an override from one test leaks into the next, giving an order-dependent flake; leave onUnhandledRequest at its default warn and a typo in a path does not fail the test, it turns into a warning that is easy to miss.
MSW version 2 is built on the primitives of the Fetch API - the same Request and Response as in the browser, and HttpResponse is a thin wrapper over the standard Response. A body is read with await request.json, and a response is assembled as the real code will see it. The failing scenario is now caught: mocking fetch, the 500 test would check your own invention of an error; with MSW the code runs the real parsing, and if status handling is broken, the test fails right where it would break for the user.
npm i -D msw
import { setupServer } from 'msw/node'
import { http, HttpResponse } from 'msw'
export const server = setupServer(
http.get('/api/cart', () => {
return HttpResponse.json({ items: [{ id: 'sku-1', qty: 2 }] })
}),
http.post('/api/orders', async ({ request }) => {
const order = await request.json()
return HttpResponse.json({ id: 'order-1', ...order }, { status: 201 })
}),
)
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
afterEach(() => server.resetHandlers())
afterAll(() => server.close())test('shows an error when the order fails', async () => {
server.use(
http.post('/api/orders', () => {
return HttpResponse.json({ error: 'Server error' }, { status: 500 })
}),
)
const user = userEvent.setup()
render(<Cart />)
await user.click(
await screen.findByRole('button', { name: /place order/i }),
)
expect(await screen.findByRole('alert')).toHaveTextContent(
/could not place the order/i,
)
})