Using MSW to mock GraphQL endpoints locally

Establishing a deterministic local testing environment requires strict isolation from live backend dependencies. By intercepting GraphQL operations at the network layer, teams eliminate flaky test suites caused by external service volatility. This architecture aligns local development with CI/CD pipeline stability objectives, enforcing fast resolution times and strict schema validation before code reaches staging. The following patterns provide a production-ready blueprint for intercepting, mocking, and validating GraphQL traffic using Mock Service Worker (MSW) v2.

Root Cause Analysis: Why Unmocked GraphQL Breaks Local & CI Environments

Uncontrolled GraphQL dependencies introduce three primary failure vectors that degrade test reliability: schema drift, network latency variance, and race conditions during parallel test execution. When test suites hit live endpoints, implicit type coercion failures occur as soon as mock payloads diverge from the production GraphQL schema. Unhandled resolver timeouts and cross-test cache poisoning further compound these issues, making External Service Simulation unreliable in modern CI pipelines. Without strict interception, test runners inherit backend state, leading to non-deterministic outcomes that block merges and waste engineering cycles.

Reproducible MSW Setup for GraphQL Interception

Achieving deterministic mocking requires exact server initialization with GraphQL-specific handlers. MSW v2 uses a resolver-based API — the old (req, res, ctx) signature was removed in v2. The following configuration guarantees consistent behavior in Node test environments (Vitest, Jest).

1. Install MSW v2

npm install msw --save-dev

2. Define GraphQL handlers (MSW v2 API)

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

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

3. Bind server lifecycle to the test runner

For Node-based runners (Vitest, Jest), use setupServer — not setupWorker. setupWorker is the browser Service Worker variant and does not work 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 is critical: it turns unmocked GraphQL operations into hard failures rather than silent passthroughs, giving you an early warning when a new query is added without a corresponding handler.

Pipeline Stability: Deterministic Response Generation & Cache Control

Parallel test execution frequently triggers cache poisoning and stale resolver states. Integrating with Advanced Mocking & Service Isolation Patterns requires strict request matching and explicit cache invalidation to prevent cross-test pollution.

Mitigation Protocol:

  1. Validate Variables Early: Inspect variables inside the resolver before returning a response. Return a GraphQL error for malformed payloads.
  2. Avoid Implicit Latency: Never rely on backend latency in tests. Use HttpResponse.json(...) directly for synchronous responses.
  3. Reset Client Caches: Clear Apollo/Relay caches in afterEach hooks to guarantee state isolation between tests.
  4. Serialize Stateful Mutations: When testing GraphQL mutations that alter shared mock state, run those suites sequentially (--pool=forks --singleFork or --sequence.concurrent=false in Vitest).
// afterEach hook example for Apollo Client cache reset
afterEach(() => {
  client.cache.reset();
});

Exact Mitigation Steps for Edge Cases & Type Safety

GraphQL mocking introduces unique edge cases that require precise architectural patterns to resolve.

Type-Safe Payload Generation

Integrate graphql-codegen to generate TypeScript interfaces directly from your schema. Use these types to construct mock payloads, eliminating implicit type coercion failures at compile time.

Deterministic Pagination

Mock pageInfo and edges arrays explicitly to prevent infinite scroll or cursor-based logic from hanging:

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

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

Network Error Simulation

Simulate DNS failures or dropped connections using MSW v2’s HttpResponse.error():

import { graphql, HttpResponse } from 'msw';

graphql.query('FetchData', () => {
  return HttpResponse.error(); // Simulates a network-level failure (ECONNREFUSED)
});

GraphQL Error Responses

Return application-level GraphQL errors (status 200 with an errors array) to test client-side error boundaries:

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

Validation & Teardown Protocol

Clean state management between test suites prevents memory leaks and ensures deterministic execution across CI runs.

1. Global Teardown

Always invoke server.close() in afterAll. This releases internal Node.js interceptors and closes any underlying connections MSW holds.

2. Catch Unhandled Resolver Exceptions

Convert console errors into hard test failures to surface unexpected GraphQL errors early:

beforeEach(() => {
  vi.spyOn(console, 'error').mockImplementation((...args) => {
    throw new Error(`Unexpected console.error: ${args.join(' ')}`);
  });
});

afterEach(() => {
  vi.restoreAllMocks();
});

3. Node Environment Integration

For all integration tests running in Node (Vitest, Jest), always use setupServer from msw/node. The setupWorker export is exclusively for browser Service Worker environments and will throw at import time in Node.

4. Enforce Full Handler Coverage

Use onUnhandledRequest: 'error' in server.listen() to fail any test that issues an unmocked GraphQL operation. This forces developers to explicitly define mock contracts rather than relying on implicit network fallbacks.

By enforcing these patterns, teams achieve zero-flake local GraphQL testing, deterministic CI pipelines, and strict contract validation between frontend and backend services.