Mocking the client in a component test looks convenient, but it leaves a gap: nothing verifies that the client is being called correctly. The unit tests around the client may confirm that it reaches window.fetch, yet they won't catch a client whose API shifted from data to body. TypeScript removes a category of such bugs, not all of them. End-to-end tests can restore some confidence, but that confidence arrives late; calling into the client directly gives the same signal at a lower level with a tighter feedback loop, provided the cost is small.
Why mocking fetch doesn't close the gap
Mocking window.fetch instead of the client at least exercises the request path, but assertions are still missing. Nothing in a typical mock checks that headers carries a Content-Type of application/json, or that the correct authentication information is attached. Duplicating those assertions in every test is not attractive, and relying on the client's own unit tests to cover them raises the same trust problem as before.
We mock out the client and rely on some E2E tests to give us a little confidence that at least the most important parts are using the client correctly.
Consider what mocking fetch does to a codebase over time. It means re-implementing the backend in every test that touches the network, frequently across multiple files. When a test only assumes the normal backend responses, that re-implementation becomes pure setup noise between the test and the behavior under scrutiny. Realistically, teams end up in one of a few places:
- Mock the client and depend on a few E2E tests, re-implementing the backend everywhere it is needed and often duplicating work.
- Mock
window.fetch, which is slightly better but inherits most of the same problems. - Push everything into small functions, unit test them in isolation, and skip integration testing altogether.
The result is weaker confidence, a slower feedback loop, duplicated code, or some combination of the three.
A pattern that worked well for a long time was a single mocked fetch function representing every tested part of the backend. That approach held up in production use and keeps most tests free of special casing: the happy path needs no extra wiring, and a failure-case mock can be added only where it matters. Test code shrinks for the majority of cases while confidence goes up.
Mock Service Worker as the interception layer
msw, short for Mock Service Worker, replaces that hand-rolled function. Service workers are a browser feature and do not run in Node, but msw supports Node for testing purposes. The model is a mock server that intercepts requests and handles them the way a real server would. In practice that means a "database" seeded from json files or produced by builders using something like faker or test-data-bot, plus server handlers styled after the Express API that read and write that database. Once the setup exists, tests are fast and easy to write.
Tools like nock cover similar ground, but msw lets the same server handlers run in the browser during development, which helps when an endpoint isn't ready, when one is broken, or when the connection is slow or absent. Mirage overlaps here too; it does not currently use a service worker on the client, and the advantage cited for msw is that the network tab behaves the same whether or not msw is installed. The project documents the differences in its own comparison guide.
Rewriting the earlier example
Backed by an msw mock server, the first test's component now has a real request path underneath it. The advantages over mocking fetch are threefold:
- Fetch response properties and headers no longer have to be reproduced by hand.
- Calling
fetchincorrectly means the server handler is never invoked, so the test fails — which would have surfaced broken code before release. - The exact same server handlers can be reused during development.
Colocation, edge cases and errors
The obvious objection is that all server handlers land in one place while the tests depending on them live in other files, so colocation is lost. The counterargument is selective colocation: only the parts that are important and unique to a test belong next to it. Repeating all the setup in every test is noise that hides what is actually under test, so happy-path behavior normally belongs in a shared setup file. Edge cases are the exception.
For those, msw allows extra server handlers to be added at runtime inside a test and then discarded by resetting the server to the original handlers, keeping tests isolated. That yields colocation where it is needed and abstraction where abstraction makes sense.
What this buys you
Testing this far from implementation details means large refactorings can happen without breaking the test suite, and the suite still reports on the user experience. One account describes reworking authentication in an application and needing only a small change in test utilities; the unit, integration and E2E tests all passed, indicating the user experience was unaffected. A separate report describes a day spent refactoring a React component, where well-written tests with react-testing-library provided the confidence to catch a subtitle error.



