Node.js 74 🟢 Mocking External APIs and Services with MSW
Mocking an external API by stubbing fetch or axios is a trap. The stub intercepts the function call, not the network request. The application’s HTTP client—with its interceptors, retry logic, serialization, and status-code handling—never runs. The test passes, but it is testing a code path that does not exist in production. Mock Service Worker (MSW) solves this by intercepting requests at the network level. In Node.js, it uses @mswjs/interceptors to hook into the socket layer before the request reaches the actual network code. In the browser, it uses a Service Worker. The same request handlers work in both environments, and the application’s real HTTP client runs exactly as it would in production .
The library’s architecture rests on a single contract: the request handlers. A handler describes a request (method, URL pattern) and a response (status, headers, body). The handler is not aware of whether it is running in a test, in a browser, or in Storybook. The integration point is the setupServer function for Node.js, which takes the handlers and controls the interception lifecycle. The test suite starts the server before tests, resets handlers between tests, and closes the server after all tests. This lifecycle is what keeps tests isolated and prevents one test’s mock from leaking into the next .
Key point: MSW intercepts requests at the network level, not the client level; the same request handlers work in Node.js tests (via setupServer) and in the browser (via setupWorker), and the beforeAll/afterEach/afterAll lifecycle with server.resetHandlers() is mandatory for test isolation.
Why network-level interception changes the test
The client stack stays under test. When you stub fetch, the application’s HTTP client code is bypassed. If the client has a retry interceptor that catches 5xx responses, a stub that returns a 500 error does not exercise that retry logic because the interceptor never runs. MSW intercepts the request after the client has sent it, so the client’s interceptors, serialization, and status handling all execute. The test verifies the integration between the client and the network contract, not just the function call .
The handler is the contract. A handler describes the API’s behavior: GET /users/:id returns a user with an id, name, and email. The handler is a single source of truth that can be shared between unit tests, integration tests, Storybook stories, and local development. When the real API changes, you update the handler, and every consumer of the mock sees the change. This is the difference between a mock that is scattered across test files and a mock that is a maintained artifact .
The query parameter rule. MSW strips query parameters from the handler’s URL predicate during matching. A handler for /users matches /users?page=2, and the resolver accesses the page number through new URL(request.url).searchParams.get('page'). This mirrors server-side routing: the route is the resource path, and the query parameters are additional data attached to the request. A handler that includes ?page=2 in its predicate will not match any request because MSW removes the query string before comparing .
The unhandled request policy. By default, MSW allows unhandled requests to pass through to the real network. In a test suite, this is dangerous: a test that forgets to define a handler for an endpoint will hit the real API, possibly in CI, possibly from a developer’s machine. The onUnhandledRequest: 'error' option makes any unhandled request throw, which turns a missing handler into a loud failure instead of a silent network call .
a. Defining handlers and setting up the server
The handler uses the http namespace and HttpResponse class. The http.get method takes a URL predicate and a resolver. The resolver receives the request, parameters, and cookies, and returns a response.
// handlers.js
import { http, HttpResponse } from 'msw';
export const handlers = [
http.get('https://api.example.com/users/:id', ({ params }) => {
return HttpResponse.json({
id: params.id,
name: 'Alice',
email: 'alice@example.com',
});
}),
http.post('https://api.example.com/users', async ({ request }) => {
const body = await request.json();
if (!body.email) {
return HttpResponse.json(
{ error: 'Email is required' },
{ status: 400 }
);
}
return HttpResponse.json(
{ id: 'new-user', ...body },
{ status: 201 }
);
}),
];
The HttpResponse.json() static method sets the Content-Type to application/json automatically. The status option in the second argument sets the status code. For error responses, the resolver returns a response with the appropriate status and body, and the application’s error handling code runs exactly as it would against the real API .
The server setup imports setupServer from msw/node and passes the handlers:
// mocks/node.js
import { setupServer } from 'msw/node';
import { handlers } from './handlers.js';
export const server = setupServer(...handlers);
The setupServer function creates a server instance that controls interception in the current Node.js process. The handlers are registered when the server starts, and every outgoing request that matches a handler is intercepted .
b. The test lifecycle and per-test overrides
The test setup file is where the lifecycle hooks live. server.listen() starts interception before any test runs. server.resetHandlers() removes any handlers added by individual tests. server.close() stops interception and restores the native request modules .
// vitest.setup.js
import { beforeAll, afterEach, afterAll } from 'vitest';
import { server } from './mocks/node.js';
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
The onUnhandledRequest: 'error' option is not optional in a production test suite. Without it, a missing handler results in a real network call, which can hit a production API, fail due to network restrictions in CI, or return data that makes the test pass for the wrong reason .
Per-test overrides use server.use() to prepend a handler for a specific test. The override is removed by resetHandlers() after the test, so the default handler is restored for the next test. This is the pattern for testing error paths: the happy path lives in the global handlers, and the 500 or timeout handler lives in the test that needs it .
import { http, HttpResponse } from 'msw';
import { server } from './mocks/node.js';
import { fetchUser } from './user-service.js';
test('handles a 500 error from the API', async () => {
server.use(
http.get('https://api.example.com/users/:id', () => {
return HttpResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
})
);
await expect(fetchUser('1')).rejects.toThrow('Internal server error');
});
The server.use() call takes effect immediately. The handler is placed at the front of the handler stack, so it takes precedence over the global handler. After the test, resetHandlers() removes it, and the next test uses the default handler .
c. GraphQL and WebSocket handlers
MSW’s handler namespace is not limited to REST. The graphql namespace defines handlers for GraphQL operations. The graphql.query and graphql.mutation methods match by operation name, and the resolver returns the data or errors payload .
import { graphql, HttpResponse } from 'msw';
export const handlers = [
graphql.query('GetUser', ({ variables }) => {
return HttpResponse.json({
data: {
user: { id: variables.id, name: 'Alice' },
},
});
}),
];
The response must be wrapped in a data property for successful queries and an errors property for failures. This mirrors the GraphQL response format. The HttpResponse.json() method handles the serialization .
WebSocket mocking uses the ws namespace. The ws.link() method creates a link to a WebSocket endpoint, and the connection event fires when a client connects. The client.send() method sends a message to the connected client, and client.addEventListener('message') handles incoming messages .
import { ws } from 'msw';
const chat = ws.link('wss://chat.example.com');
export const handlers = [
chat.addEventListener('connection', ({ client }) => {
client.addEventListener('message', (event) => {
client.send(`echo: ${event.data}`);
});
}),
];
The WebSocket handler runs in both Node.js and the browser, which means the same mock definition covers unit tests, integration tests, and local development. This is the core value proposition of MSW: one network contract, every environment .
Complete Example Session
The following session builds a user service that calls an external API, defines MSW handlers for the API, and tests both the success path and an error path.
// ============================================
// PART 1: THE SERVICE UNDER TEST
// ============================================
// user-service.js
export async function fetchUser(id) {
const response = await fetch(`https://api.example.com/users/${id}`);
if (!response.ok) {
const error = await response.json();
throw new Error(error.message);
}
return response.json();
}
// ============================================
// PART 2: THE HANDLERS
// ============================================
// mocks/handlers.js
import { http, HttpResponse } from 'msw';
export const handlers = [
http.get('https://api.example.com/users/:id', ({ params }) => {
return HttpResponse.json({
id: params.id,
name: 'Alice',
email: 'alice@example.com',
});
}),
];
// ============================================
// PART 3: THE SERVER
// ============================================
// mocks/node.js
import { setupServer } from 'msw/node';
import { handlers } from './handlers.js';
export const server = setupServer(...handlers);
// ============================================
// PART 4: THE TEST SETUP
// ============================================
// vitest.setup.js
import { beforeAll, afterEach, afterAll } from 'vitest';
import { server } from './mocks/node.js';
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
// ============================================
// PART 5: THE SUCCESS TEST
// ============================================
// user-service.test.js
import { test, expect } from 'vitest';
import { fetchUser } from './user-service.js';
test('fetches a user by id', async () => {
const user = await fetchUser('1');
expect(user.name).toBe('Alice');
});
// ============================================
// PART 6: THE ERROR TEST WITH OVERRIDE
// ============================================
import { http, HttpResponse } from 'msw';
import { server } from './mocks/node.js';
test('throws when the API returns a 500', async () => {
server.use(
http.get('https://api.example.com/users/:id', () => {
return HttpResponse.json(
{ message: 'Server error' },
{ status: 500 }
);
})
);
await expect(fetchUser('1')).rejects.toThrow('Server error');
});
The six parts cover the service that makes the real fetch call, the handlers that define the API contract, the server that registers them, the lifecycle hooks that control interception, a success test that uses the global handler, and an error test that overrides it with server.use(). What the session demonstrates is that the service code does not change between tests—the same fetch call runs against a different network response, and the test verifies the service’s handling of that response.
Quick Reference
Setup Commands
| Step | Code |
|---|---|
| Install | npm install msw --save-dev |
| Handlers | http.get(url, resolver) |
| Server | setupServer(...handlers) |
| Start | server.listen({ onUnhandledRequest: 'error' }) |
| Reset | server.resetHandlers() |
| Close | server.close() |
Handler Methods
| Method | Purpose |
|---|---|
http.get(url, resolver) | Intercept GET |
http.post(url, resolver) | Intercept POST |
http.put(url, resolver) | Intercept PUT |
http.delete(url, resolver) | Intercept DELETE |
graphql.query(name, resolver) | Intercept GraphQL query |
ws.link(url) | Intercept WebSocket |
Response Helpers
| Helper | Purpose |
|---|---|
HttpResponse.json(data) | JSON response |
HttpResponse.text(text) | Plain text |
HttpResponse.json(body, { status }) | Custom status |
delay(ms) | Simulated latency |
Lifecycle Hooks
| Hook | Method | Purpose |
|---|---|---|
beforeAll | server.listen() | Start interception |
afterEach | server.resetHandlers() | Remove per-test overrides |
afterAll | server.close() | Stop interception |
Best Practices
✅ Do This:
server.listen({ onUnhandledRequest: 'error' }); // ✅ fail loudly
afterEach(() => server.resetHandlers()); // ✅ isolate tests
server.use(http.get(url, () => HttpResponse.json({}, { status: 500 }))); // ✅ per-test override
❌ Don’t Do This:
server.listen(); // ❌ passthrough to real API
// no resetHandlers // ❌ leaks between tests
http.get('/users?page=2', ...) // ❌ query params stripped
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Test hits real API | onUnhandledRequest not set | Add { onUnhandledRequest: 'error' } |
| Mock leaks to next test | No resetHandlers() in afterEach | Reset after every test |
| Handler does not match | Query params in predicate | Remove query params, use request.url |
fetch is not defined | Node.js version below 22 | Upgrade Node.js |
| Jest environment error | jest-environment-jsdom missing globals | Use jest-fixed-jsdom or Vitest |
Real-World Examples
1. REST GET handler
http.get('https://api.example.com/users/:id', ({ params }) => {
return HttpResponse.json({ id: params.id, name: 'Alice' });
});
2. POST with body validation
http.post('/users', async ({ request }) => {
const body = await request.json();
if (!body.email) return HttpResponse.json({ error: 'Required' }, { status: 400 });
return HttpResponse.json({ id: '1', ...body }, { status: 201 });
});
3. Error override in a test
server.use(
http.get('/users/:id', () => HttpResponse.json({ error: 'Not found' }, { status: 404 }))
);
4. GraphQL query handler
graphql.query('GetUser', ({ variables }) => {
return HttpResponse.json({ data: { user: { id: variables.id } } });
});
5. Simulated latency
import { delay } from 'msw';
http.get('/slow', async () => {
await delay(500);
return HttpResponse.json({ ok: true });
});
6. WebSocket echo
ws.link('wss://chat.example.com').addEventListener('connection', ({ client }) => {
client.addEventListener('message', (e) => client.send(`echo: ${e.data}`));
});
7. Query parameter access
http.get('/search', ({ request }) => {
const query = new URL(request.url).searchParams.get('q');
return HttpResponse.json({ results: [query] });
});
8. Per-test handler
server.use(http.get('/health', () => HttpResponse.json({ ok: true })));
9. Jest setup
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterAll(() => server.close());
afterEach(() => server.resetHandlers());
10. Vitest setup file
// vitest.setup.js
import { server } from './mocks/node.js';
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
Visual
Network-Level Interception
┌──────────────────────────────────────────────┐
│ Application Code │
│ fetch('https://api.example.com/users/1') │
│ │ │
│ ▼ │
│ HTTP Client (interceptors, serialization) │
│ │ │
│ ▼ │
│ MSW INTERCEPTOR (network level) │
│ │ │
│ ┌────┴────┐ │
│ │ │ │
│ handler no handler │
│ matches (error) │
│ │ │ │
│ ▼ ▼ │
│ Mocked Throw / passthrough │
│ response │
└──────────────────────────────────────────────┘
Test Lifecycle
┌──────────────────────────────────────────────┐
│ beforeAll: server.listen() │
│ │ │
│ ▼ │
│ test 1 runs │
│ │ │
│ ▼ │
│ afterEach: server.resetHandlers() │
│ │ │
│ ▼ │
│ test 2 runs (clean state) │
│ │ │
│ ▼ │
│ afterAll: server.close() │
└──────────────────────────────────────────────┘
Handler Override
┌──────────────────────────────────────────────┐
│ Global handler: GET /users/:id → 200 │
│ │ │
│ ▼ │
│ Test: server.use(GET /users/:id → 500) │
│ │ │
│ ▼ │
│ Request → 500 (override takes precedence) │
│ │ │
│ ▼ │
│ afterEach: resetHandlers() │
│ │ │
│ ▼ │
│ Next test → 200 (global handler restored) │
└──────────────────────────────────────────────┘
Handler as Contract
┌──────────────────────────────────────────────┐
│ handlers.js │
│ │ │
│ ┌────┼────┬──────────┐ │
│ │ │ │ │ │
│ ▼ ▼ ▼ ▼ │
│ Unit E2E Storybook Local dev │
│ test test server │
│ │
│ One definition, every environment │
└──────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| Library | msw |
| Interception | Network level |
| Node.js server | setupServer from msw/node |
| Handler namespace | http, graphql, ws |
| Response class | HttpResponse |
| Lifecycle | listen, resetHandlers, close |
| Per-test override | server.use() |
| Query parameters | Stripped from predicate, read via URL |
| Unhandled request | onUnhandledRequest: 'error' |
| Node.js requirement | v22+ for fetch |
Key takeaways:
- MSW intercepts at the network level, not the client level. The application’s real HTTP client runs, including interceptors and serialization, so the test verifies the full request path .
- The handler is the contract. One set of handlers works in Node.js tests, browser tests, Storybook, and local development. The handler is a maintained artifact, not a scattered stub .
- The lifecycle is mandatory.
server.listen()before tests,server.resetHandlers()after each test,server.close()after all tests. Without the reset, overrides leak between tests . onUnhandledRequest: 'error'is not optional. It turns a missing handler into a loud failure instead of a silent real network call .- Query parameters are stripped from predicates. A handler for
/usersmatches/users?page=2. Read the parameters throughnew URL(request.url).searchParams. - Per-test overrides use
server.use(). The happy path lives in global handlers; error paths are declared in the test that needs them. The override is removed byresetHandlers(). - MSW supports REST, GraphQL, and WebSocket. The
http,graphql, andwsnamespaces cover the three protocols with the same lifecycle and handler contract .
Remember: Mocking an external API is not about making a function return a value. It is about defining the network contract and verifying that the application’s HTTP client interacts with that contract correctly. MSW provides the mechanism: network-level interception, reusable handlers, and a lifecycle that keeps tests isolated. The discipline is in the lifecycle—listen with onUnhandledRequest: 'error', resetHandlers after every test, close at the end. Without it, the mock leaks, the test suite becomes order-dependent, and the real API gets called from CI. With it, the mock is a contract that every test can trust.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!