Using MSW to mock GraphQL endpoints locally

A deterministic local and CI test run requires strict isolation from live GraphQL backends. By intercepting operations at the network layer with Mock Service Worker (MSW) v2, frontend and full-stack teams eliminate flaky suites caused by external service volatility, schema drift, and shared-environment state. This guide targets engineers running Vitest or Jest in Node who consume a GraphQL API through Apollo, Relay, urql, or a hand-rolled client, and it uses the MSW v2 resolver API throughout — the removed (req, res, ctx) signature does not apply.

Root Cause Analysis

Unmocked GraphQL dependencies introduce three failure vectors that quietly degrade reliability. The first is schema drift: as soon as a hand-written payload diverges from the production schema, implicit type coercion failures slip through compilation and surface as runtime errors that look like application bugs. The second is latency and availability variance — a suite that hits a live endpoint inherits its outages, rate limits, and tail latency, turning a deterministic assertion into a coin flip. The third is cross-test state poisoning: GraphQL clients cache aggressively, so a query resolved in one test can return stale data to the next when caches are not reset between runs.

These vectors compound under parallel execution. Multiple workers hitting the same backend race on shared records, and an unhandled resolver timeout in one shard cascades into spurious failures across the run. The fix is to move interception in-process, where external service simulation gives every test a private, deterministic copy of the API surface instead of a contended live one.

A subtler reason GraphQL specifically benefits from network-layer interception is that a single endpoint multiplexes every operation. A REST suite can stub /users and /orders as distinct paths, but a GraphQL client posts every query and mutation to one URL — usually /graphql — distinguished only by the operation name and variables in the request body. That means a naive URL-based stub either catches everything indiscriminately or nothing at all. MSW resolves this by parsing the GraphQL document and dispatching on operation type and name, so each interaction is addressed precisely without you having to inspect raw POST bodies. The diagram below traces where the interception sits relative to your client and the network.

MSW GraphQL interception flowA test calls the GraphQL client, MSW intercepts the outbound operation by name and returns a mocked payload before it reaches the live network.Test +GraphQL clientMSW interceptormatch by op namegraphql.query handlerHttpResponse.jsonLive GraphQL APIblocked in testsdashed = path never taken when handler matches

Reproducible Setup

Install MSW v2 as a dev dependency:

npm install msw --save-dev

Define GraphQL handlers keyed by operation name. The v2 resolver receives a context object — destructure variables, query, or request from it:

// src/mocks/handlers.ts
import { graphql, HttpResponse } from 'msw';

export const handlers = [
  graphql.query('GetUser', ({ variables }) => {
    // Strict operation matching prevents fallback to the live network
    return HttpResponse.json({
      data: {
        user: { id: variables.id ?? '1', name: 'Test User', __typename: 'User' },
      },
    });
  }),
];

Bind a Node server — use setupServer from msw/node, never setupWorker (the browser Service Worker variant throws at import time in Node):

// src/mocks/server.ts
import { setupServer } from 'msw/node';
import { handlers } from './handlers';

export const server = setupServer(...handlers);
// vitest.setup.ts (or jest.setup.ts)
import { server } from './src/mocks/server';

beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

The onUnhandledRequest: 'error' option turns any unmocked operation into a hard failure rather than a silent passthrough, so adding a new query without a handler fails loudly during development instead of during a release.

One caveat worth wiring up early: MSW’s onUnhandledRequest callback can be a function instead of a string, which lets you allow non-GraphQL traffic (static assets, telemetry beacons) through while still failing on unhandled API operations. This avoids the common frustration where a perfectly correct GraphQL handler suite breaks because an analytics ping had no stub:

beforeAll(() =>
  server.listen({
    onUnhandledRequest(request, print) {
      if (new URL(request.url).pathname !== '/graphql') return; // ignore non-API noise
      print.error(); // fail only on unmocked GraphQL
    },
  }),
);

If you point all operations at a single endpoint, declare it once with graphql.link so handlers are scoped to that URL and never accidentally match a second GraphQL service running in the same suite:

// src/mocks/handlers.ts
import { graphql, HttpResponse } from 'msw';

const api = graphql.link('https://api.example.com/graphql');

export const handlers = [
  api.query('GetUser', ({ variables }) =>
    HttpResponse.json({ data: { user: { id: variables.id, name: 'Ada' } } }),
  ),
];

Implementation

1. Match by operation name and validate variables. Inspect variables inside the resolver before responding, and return a GraphQL error for malformed input so client-side error handling is exercised:

// src/mocks/handlers.ts
import { graphql, HttpResponse } from 'msw';

export const userHandler = graphql.query('GetUser', ({ variables }) => {
  if (!variables.id) {
    return HttpResponse.json({
      errors: [{ message: 'id is required', extensions: { code: 'BAD_USER_INPUT' } }],
    });
  }
  return HttpResponse.json({ data: { user: { id: variables.id, name: 'Ada' } } });
});

2. Mock pagination explicitly. Return concrete pageInfo and edges so cursor logic terminates instead of looping:

graphql.query('GetItems', () =>
  HttpResponse.json({
    data: {
      items: {
        pageInfo: { hasNextPage: false, endCursor: 'cursor-1' },
        edges: [{ node: { id: '1', title: 'Item A' }, cursor: 'cursor-1' }],
      },
    },
  }),
);

3. Simulate transport and application errors separately. Use HttpResponse.error() for network-level failures (dropped connection, ECONNREFUSED) and a 200 with an errors array for application-level GraphQL errors:

import { graphql, HttpResponse } from 'msw';

export const networkFailure = graphql.query('FetchData', () => HttpResponse.error());

export const appError = graphql.query('FetchDashboard', () =>
  HttpResponse.json({
    data: null,
    errors: [{ message: 'Internal server error', path: ['dashboard'], extensions: { code: 'INTERNAL_SERVER_ERROR' } }],
  }),
);

4. Model stateful mutations with a per-test store. Read-only handlers are stateless, but a realistic suite needs a mutation to affect a subsequent query — creating an item then listing it. Keep the state in a module-level variable that you reset in afterEach, and have both handlers read and write it:

// src/mocks/todos.ts
import { graphql, HttpResponse } from 'msw';

interface Todo { id: string; text: string; done: boolean; }
let store: Todo[] = [];

export function resetTodos() {
  store = [{ id: '1', text: 'Seed', done: false }];
}

export const todoHandlers = [
  graphql.query('ListTodos', () => HttpResponse.json({ data: { todos: store } })),
  graphql.mutation('AddTodo', ({ variables }) => {
    const todo: Todo = { id: String(store.length + 1), text: variables.text, done: false };
    store.push(todo);
    return HttpResponse.json({ data: { addTodo: todo } });
  }),
];

Call resetTodos() in afterEach alongside server.resetHandlers() so no mutation leaks into the next test. Without the reset, a parallel run will see todos accumulate non-deterministically and produce intermittent count assertions — exactly the cross-test poisoning the root cause section warned about.

5. Simulate latency to exercise loading states. Wrap the response in a delay so spinner and suspense branches are covered rather than skipped because the mock resolved instantly:

import { graphql, HttpResponse, delay } from 'msw';

export const slowQuery = graphql.query('GetReport', async () => {
  await delay(200); // ms; or delay('infinite') to test timeout handling
  return HttpResponse.json({ data: { report: { id: 'r1', ready: true } } });
});

6. Override a handler for one test only. Use server.use(...) inside a test to layer a one-off response on top of the default handlers; resetHandlers() in afterEach strips it again so the override never bleeds:

it('renders an empty state when the user has no orders', async () => {
  server.use(
    graphql.query('GetOrders', () => HttpResponse.json({ data: { orders: [] } })),
  );
  // ...assert the empty-state UI...
});

7. Generate types from the schema. Wire graphql-codegen to your schema and build mock payloads from the generated interfaces, so a divergence between mock and contract becomes a compile error rather than a runtime surprise. Pairing this with a contract check at the boundary — see the consumer-driven contract flow — catches the cases that a hand-maintained mock cannot, because codegen only guarantees your mock matches the schema you fetched, not the schema currently deployed.

Verification

Confirm both the response shape and the request your code issued, and reset the client cache between tests so state cannot bleed:

// user.test.ts
import { expect, it, afterEach } from 'vitest';
import { client } from './src/apollo';
import { GET_USER } from './src/queries';

afterEach(() => {
  client.cache.reset();
});

it('resolves the mocked user', async () => {
  const { data } = await client.query({ query: GET_USER, variables: { id: '7' } });
  expect(data.user).toEqual({ id: '7', name: 'Test User', __typename: 'User' });
});

A green run while onUnhandledRequest: 'error' is active is itself the proof that every operation was intercepted — any escaped request would have failed the suite. For stateful mutation suites, run them sequentially (--sequence.concurrent=false in Vitest, or --pool=forks --singleFork) so shared mock state cannot race.

Go one step further and assert on the request your client actually sent, not just the response it received. A handler can capture variables into a spy so you verify the client serialized the operation correctly — a common source of silent bugs when a variable is misnamed and the server quietly ignores it:

import { vi, expect, it } from 'vitest';
import { graphql, HttpResponse } from 'msw';
import { server } from './src/mocks/server';

it('sends the id the caller passed', async () => {
  const seen = vi.fn();
  server.use(
    graphql.query('GetUser', ({ variables }) => {
      seen(variables);
      return HttpResponse.json({ data: { user: { id: variables.id, name: 'Ada' } } });
    }),
  );
  await client.query({ query: GET_USER, variables: { id: '7' } });
  expect(seen).toHaveBeenCalledWith({ id: '7' });
});

This closes the loop: the response assertion proves your code parses the payload, and the request assertion proves it builds the operation. Together they verify both directions of the contract, which a response-only test leaves half-checked.

Troubleshooting

  • setupWorker throws on import. You imported the browser variant in a Node test. Switch to setupServer from msw/node; reserve setupWorker for browser Service Worker runs only.
  • A resolver is never hit. MSW v2 matches by operation name, so an anonymous query (query { ... } with no name) will not match graphql.query('GetUser', ...). Name every operation, or fall back to graphql.operation(...) for a catch-all.
  • Stale data across tests. The client cache survived teardown. Call client.cache.reset() (Apollo) or the equivalent store reset in afterEach, and reset MSW handlers in the same hook.
  • Persisted queries return an unexpected error. Clients using automatic persisted queries (APQ) send only a SHA-256 hash on the first attempt and the full query on a PersistedQueryNotFound retry. If your handler matches on operation name, the hash-only request still carries the operation name in most setups, but if it does not, return a GraphQL error with extensions.code: 'PERSISTED_QUERY_NOT_FOUND' so the client falls back to sending the full document, which your handler then matches.
  • Fragments or aliases change the response shape. MSW does not execute the query against a schema — it returns whatever you hand it. If the client uses field aliases (renamed: name), your mocked payload must use the aliased key, or the parsed result will have undefined where the component expects a value. Mirror the exact selection set the operation requests.
  • Subscriptions are not intercepted. MSW v2 handles queries and mutations over HTTP but does not mock WebSocket-based GraphQL subscriptions through graphql.query/mutation. For subscription transport, mock the socket directly — see simulating WebSocket connections.
  • Batched operations only match the first. If your link batches multiple operations into one HTTP POST, MSW resolves them as a single request and your per-operation handlers may not fire as expected. Disable batching in the test client or use a single handler that returns an array of results matching the batch.

FAQ

Does this work with Jest as well as Vitest?

Yes. The lifecycle hooks are identical — beforeAll/afterEach/afterAll calling server.listen/resetHandlers/close — and only the setup-file wiring differs. Point your setupFilesAfterEach (Jest) or setupFiles (Vitest) at the same module and the handlers behave the same way in both runners.

How do I intercept both queries and mutations on one endpoint?

Register separate handlers with graphql.query('Name', ...) and graphql.mutation('Name', ...); MSW dispatches by operation type and name, so the two never collide even when they target the same URL. Keep mutation handlers in a stateful suite that runs sequentially if they mutate shared mock data.

Why use HttpResponse.error() instead of a 500 status?

HttpResponse.error() simulates a transport-level failure with no response at all (a dropped connection), which is the branch your client’s network-error handling guards. A 500 status is a real HTTP response and exercises a different code path — your status-code handling — so use whichever matches the failure you are testing.

How do I share handlers between component tests and a local dev server?

Define handlers in one module and import them in both setupServer for Node tests and setupWorker for an in-browser mock during development. The handler array is identical; only the binding differs, which means a fix to a mock payload propagates to both your test suite and the running app without duplication. This is the same portability that makes the contract testable end to end.

Should I mock at the GraphQL layer or the HTTP layer?

Use graphql.query/mutation when you want MSW to parse the operation and dispatch by name, which is clearer and survives query reformatting. Drop to http.post('/graphql', ...) only when you need to assert on the raw request body or simulate a malformed response that a valid GraphQL handler cannot produce. For everything else the GraphQL handlers are more expressive and less brittle.