with Mock Service Worker at the HTTP level instead of stubs
Anyone who simply overwrites globalThis.fetch in unit tests builds fragile mocks that need adjusting with every code change. Mock Service Worker instead intercepts network requests at the HTTP level, exercises the real fetch call, and can be configured precisely for both success and error cases without touching the application code itself.
Table of contents
- 1. Why network mocking matters more than it sounds
- 2. Three approaches at a glance: stub, interceptor, service worker
- 3. Installing MSW and writing the first handler
- 4. Defining handlers for different endpoints
- 5. Simulating error cases: timeouts, 500s, rate limits
- 6. Dynamic behavior: inspecting request body and query
- 7. Integration into Vitest suites and setup files
- 8. Limits: when network mocking is not enough
- 9. Network mocking approaches compared
- 10. Summary
- 11. FAQ
1. Why network mocking matters more than it sounds
Tests that fire real HTTP requests against a live backend are problematic for several reasons. They are slow, because every test waits for a real network response. They are unreliable, because a brief backend outage or a rate limit fails the whole run for no reason related to the code under test. And they are hard to control, because edge cases like a 500 error or a timeout are barely reproducible on demand in a real system. That is exactly why mocking fetch and network calls in tests is a basic requirement for a fast, deterministic test suite.
The naive approach of overwriting globalThis.fetch directly with a test double works for simple cases but quickly becomes unwieldy once a component calls several different endpoints. Every test case would then have to decide by itself which URL should return which response, often through fragile if chains inside the mock implementation. Mocking fetch and network calls in tests at a higher level of abstraction, as Mock Service Worker offers, solves this problem in a fundamentally different way.
Mock Service Worker, MSW for short, intercepts requests not at the function level but at the network level, right at the interceptor for fetch, XMLHttpRequest, or in Node.js via native http module interception. That means the application code calls fetch('/api/products') completely normally, without knowing the response comes from a mock instead of a real server. This difference makes tests more realistic, because exactly the same code path is exercised that also runs in production.
2. Three approaches at a glance: stub, interceptor, service worker
The simplest approach is stubbing the fetch function directly, for instance with vi.fn() in Vitest. That works for trivial cases but tightly couples the test to the concrete implementation of how fetch is called, rather than to what actually goes over the network. If the implementation switches internally from fetch to axios, every affected test needs adjusting even though the actual behavior did not change.
A second approach is interception at the HTTP client level, as offered by libraries like Nock for Node.js. Nock works well for pure Node.js backend tests but does not support browser environments natively. The third and today's preferred approach is mocking fetch and network calls in tests with MSW, which uses the same handler definitions in both the browser and Node.js, making it equally useful for unit tests, component tests, and even browser-based development through a real service worker.
3. Installing MSW and writing the first handler
Installation is a plain npm install, and for Node.js-based test environments like Vitest, MSW's server mode is used instead of the service worker mode for browsers. The central building block is http.get() and the corresponding methods for other HTTP verbs, which define a handler reacting to a specific URL.
// npm install --save-dev msw vitest
// mocks/handlers.js
import { http, HttpResponse } from 'msw';
export const handlers = [
http.get('/api/products/:id', ({ params }) => {
return HttpResponse.json({
id: params.id,
name: 'Wireless Keyboard',
price: 49.99,
inStock: true,
});
}),
http.get('/api/products', () => {
return HttpResponse.json([
{ id: '1', name: 'Wireless Keyboard', price: 49.99 },
{ id: '2', name: 'USB-C Hub', price: 29.5 },
]);
}),
];
// mocks/server.js — Node.js server for test environments
import { setupServer } from 'msw/node';
import { handlers } from './handlers.js';
export const server = setupServer(...handlers);
Important when getting started with mocking fetch and network calls in tests using MSW is the separation between handler definition and server instance. Handlers describe how a request is responded to, while the server registered via setupServer() actively activates these handlers and intercepts real network traffic during the test run. This separation lets you override handlers per test file or even per individual test without touching the global configuration.
4. Defining handlers for different endpoints
In real applications, a single component often calls several endpoints, for example product data, stock levels and user data. Mocking fetch and network calls in tests therefore requires a structure where handlers are clearly organized by domain, instead of collecting every endpoint in one confusing file. MSW natively supports path parameters, query parameters and different HTTP methods, which realistically models complex REST APIs.
import { http, HttpResponse } from 'msw';
export const cartHandlers = [
// Path parameter matching
http.get('/api/cart/:cartId', ({ params }) => {
return HttpResponse.json({ cartId: params.cartId, items: [], total: 0 });
}),
// Reading the request body for POST requests
http.post('/api/cart/:cartId/items', async ({ request, params }) => {
const body = await request.json();
return HttpResponse.json(
{ cartId: params.cartId, added: body.sku, quantity: body.quantity },
{ status: 201 }
);
}),
// Query parameter matching
http.get('/api/search', ({ request }) => {
const url = new URL(request.url);
const query = url.searchParams.get('q');
if (!query) {
return HttpResponse.json({ error: 'Missing query parameter' }, { status: 400 });
}
return HttpResponse.json({ query, results: [] });
}),
];
5. Simulating error cases: timeouts, 500s, rate limits
The real value of mocking fetch and network calls in tests shows not in success cases, but in error scenarios that are barely provokable on demand in a real backend. How does the application react when the API answers with status 500? Does the UI show a meaningful error message instead of simply hanging? How does the retry logic behave on a 429 Too Many Requests? Such questions can be answered precisely and repeatably with MSW, because every handler defines exactly what response comes back.
For network failures such as an aborted request or a timeout, MSW offers the function HttpResponse.error(), which simulates a genuine network error, the kind the browser throws on a failed connection. That differs fundamentally from an error response carrying an HTTP status code, because the fetch call itself fails with an exception instead of returning a valid response with an error status. Both cases must be handled differently by robust application code, and both can be reproduced on demand with network mocking.
import { http, HttpResponse, delay } from 'msw';
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { server } from '../mocks/server.js';
describe('error handling with mocked network failures', () => {
it('shows a friendly message on 500 Internal Server Error', async () => {
server.use(
http.get('/api/products', () => {
return HttpResponse.json({ error: 'Internal Server Error' }, { status: 500 });
})
);
const result = await fetchProductsSafely();
expect(result.error).toBe('Something went wrong. Please try again.');
});
it('retries on 429 Too Many Requests', async () => {
let attempts = 0;
server.use(
http.get('/api/products', () => {
attempts += 1;
if (attempts < 3) {
return HttpResponse.json({ error: 'Rate limited' }, { status: 429 });
}
return HttpResponse.json([{ id: '1', name: 'Wireless Keyboard' }]);
})
);
const result = await fetchProductsWithRetry();
expect(attempts).toBe(3);
expect(result).toHaveLength(1);
});
it('handles a genuine network failure, not just an HTTP error status', async () => {
server.use(http.get('/api/products', () => HttpResponse.error()));
await expect(fetchProductsSafely()).resolves.toEqual({
error: 'Network unavailable. Check your connection.',
});
});
it('simulates slow responses to test loading states', async () => {
server.use(
http.get('/api/products', async () => {
await delay(3000);
return HttpResponse.json([]);
})
);
// Assert a loading spinner is shown while the request is pending
});
});
6. Dynamic behavior: inspecting request body and query
An often overlooked benefit of mocking fetch and network calls in tests with MSW is the ability to inspect the outgoing request itself, not just control the response. Inside a handler, the full request object is available, including headers, body and URL. This allows assertions about whether the application actually sends the correct data, for instance the right Authorization header or the correct JSON payload when placing an order.
This capability turns network mocking from a pure response simulation into a full-fledged tool for contract-like checks: the test confirms not only that the component handles a given response correctly, but also that it makes the right request in the first place. Combined with Vitest spies on the handler itself, you can additionally check how often an endpoint was called, relevant for detecting unnecessary duplicate requests.
7. Integration into Vitest suites and setup files
For mocking fetch and network calls in tests to work consistently across the whole test suite, the MSW server is started, stopped and reset between tests in a global setup file. Resetting with server.resetHandlers() after every test is crucial, because otherwise a handler overridden in one test accidentally stays active in subsequent tests and leads to hard-to-track failures.
// vitest.setup.js
import { beforeAll, afterEach, afterAll } from 'vitest';
import { server } from './mocks/server.js';
beforeAll(() => {
// Fail loudly on any request without a matching handler
server.listen({ onUnhandledRequest: 'error' });
});
afterEach(() => {
server.resetHandlers(); // discard per-test handler overrides
});
afterAll(() => {
server.close();
});
// vitest.config.js
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
setupFiles: ['./vitest.setup.js'],
environment: 'jsdom',
},
});
The option onUnhandledRequest: 'error' is particularly valuable for mocking fetch and network calls in tests: it fails a test immediately if a request goes to a URL without a matching handler, instead of silently letting it through to the real network. Without this safeguard, tests could unknowingly fire real HTTP calls, undermining the whole point of network mocking.
8. Limits: when network mocking is not enough
Network mocking with MSW checks whether the application handles an assumed API response correctly, not whether that assumption matches the real API structure. If the backend changes a field in the JSON response without the handler definition being updated, all tests stay green while the application breaks in production. This risk can only be mitigated with additional contract tests or integration tests against a real or staged API, not through network mocking alone.
A second limit concerns genuine network behavior such as CORS restrictions, TLS certificate problems, or actual latency under load. These aspects cannot be simulated with MSW at all, or only incompletely, because MSW operates at a level of abstraction above the actual network stack. For such cases, dedicated end-to-end tests against a real staging environment remain necessary; network mocking does not replace them, it complements them with fast, deterministic unit and component tests.
9. Network mocking approaches compared
The table below compares the common approaches for mocking fetch and network traffic in JavaScript tests.
| Approach | Abstraction level | Browser & Node.js | Best for |
|---|---|---|---|
| vi.fn() fetch stub | Function level, tightly coupled | Only where fetch is called directly | Very simple, isolated single cases |
| Nock | HTTP client level | Node.js only | Pure backend/Node.js tests |
| MSW | Network level, realistic | Both, identical handlers | Unit tests, component tests, Storybook |
| Real test backend | Fully realistic | Both, but slow | E2E and contract tests |
For most JavaScript projects, MSW is the most pragmatic compromise between realism and test speed: it exercises real fetch code, supports browser and Node.js with the same handler definitions, and can be used both in isolated unit tests and in Storybook for development.
Mironsoft
Resilient JavaScript test suites for Magento and Hyvä frontends
Fast, deterministic tests without real API calls?
We introduce Mock Service Worker into your existing Vitest or Jest suites, model success and error cases cleanly, and fully decouple your frontend tests from real backend calls.
MSW rollout
Set up handler structure, server setup and CI integration from scratch
Error case coverage
Simulate and guard against timeouts, 500s and rate limits deliberately
Migrate existing tests
Replace fragile fetch stubs step by step with robust MSW handlers
10. Summary
Mocking fetch and network calls in tests means intercepting network requests at a realistic level instead of building fragile function stand-ins for fetch. Mock Service Worker achieves this through handlers that react to HTTP method and URL, usable identically in the browser and in Node.js. Error cases such as 500 responses, genuine network failures via HttpResponse.error(), and delayed responses can be simulated precisely and reproducibly, something barely possible against a real backend.
Consistent integration through server.resetHandlers() between tests and onUnhandledRequest: 'error' prevents unnoticed real network calls from corrupting the test suite. Network mocking does not replace contract tests or real integration tests against a staging environment, but it complements them with fast, deterministic, and deliberately provokable error scenarios that are essential for robust application code.
Mocking fetch and network calls in tests — the essentials at a glance
Abstraction level
MSW intercepts requests at the network level, not the function level, exercising real fetch code.
Handler structure
http.get()/http.post() with path and query parameter matching, organized clearly by domain.
Error cases
HttpResponse.error() for genuine network failures, status codes for HTTP error responses, delay() for timeouts.
CI safety
resetHandlers() after every test, onUnhandledRequest: 'error' against unnoticed real requests.