| |

Node.js 74 🟢 Mocking External APIs and Services with MSW

⏱️ Estimated read: 13 min | |

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

StepCode
Installnpm install msw --save-dev
Handlershttp.get(url, resolver)
ServersetupServer(...handlers)
Startserver.listen({ onUnhandledRequest: 'error' })
Resetserver.resetHandlers()
Closeserver.close()

Handler Methods

MethodPurpose
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

HelperPurpose
HttpResponse.json(data)JSON response
HttpResponse.text(text)Plain text
HttpResponse.json(body, { status })Custom status
delay(ms)Simulated latency

Lifecycle Hooks

HookMethodPurpose
beforeAllserver.listen()Start interception
afterEachserver.resetHandlers()Remove per-test overrides
afterAllserver.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

PitfallWhy It HappensFix
Test hits real APIonUnhandledRequest not setAdd { onUnhandledRequest: 'error' }
Mock leaks to next testNo resetHandlers() in afterEachReset after every test
Handler does not matchQuery params in predicateRemove query params, use request.url
fetch is not definedNode.js version below 22Upgrade Node.js
Jest environment errorjest-environment-jsdom missing globalsUse 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

ItemValue
Librarymsw
InterceptionNetwork level
Node.js serversetupServer from msw/node
Handler namespacehttp, graphql, ws
Response classHttpResponse
Lifecyclelisten, resetHandlers, close
Per-test overrideserver.use()
Query parametersStripped from predicate, read via URL
Unhandled requestonUnhandledRequest: 'error'
Node.js requirementv22+ 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 /users matches /users?page=2. Read the parameters through new 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 by resetHandlers() .
  • MSW supports REST, GraphQL, and WebSocket. The http, graphql, and ws namespaces 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!