GraphQL Mocking Setup

This page covers mocking a GraphQL API on your own machine so a frontend can develop and test against realistic responses before the backend exists. It uses MSW’s graphql handlers for the operations you care about and @graphql-tools/mock for whole-schema coverage.

Prerequisites

  • Node.js 20 or later (node --version) — the auto-mock schema tooling targets modern Node
  • MSW 2.x already installed and its worker registered, per the Mock Service Worker (MSW) setup guide
  • A GraphQL client that sends operations to a single endpoint (Apollo Client, urql, graphql-request, or a plain fetch)
  • Your API’s schema as SDL (schema.graphql) or a set of named operations you can copy — auto-mocking needs the type definitions
  • @graphql-tools/mock and @graphql-tools/schema installed for Phase 2 (npm install --save-dev @graphql-tools/mock @graphql-tools/schema graphql)
  • curl available for the verification step

What GraphQL mocking changes versus REST

If you have already read the request interception patterns that MSW is built on, the interception mechanism here is unchanged — the Service Worker (or the Node http patch) still catches the outbound request. What changes is the matching and the response envelope.

A REST mock keys on the HTTP method and the URL path: GET /api/users/1. A GraphQL client sends almost every operation as a POST to a single endpoint such as /graphql, and the meaningful identity of the request lives inside the body — the operation type (query, mutation, subscription), the operation name, and the variables. MSW exposes a dedicated graphql namespace precisely so you match on the operation name rather than trying to parse the POST body yourself.

The response envelope is also fixed by the GraphQL spec. Instead of returning any JSON you like, you return an object with a data key, an optional errors array, or both. MSW’s HttpResponse.json still does the serialisation, but the shape is prescribed.

GraphQL mocking flow with MSW and schema auto-mock A GraphQL client POSTs a query or mutation to /graphql. The MSW graphql interception layer reads the operation name. Named operations route to an explicit resolver that returns typed JSON. Anything unmatched falls through to a schema auto-mock built with addMocksToSchema, which executes against the SDL and returns placeholder JSON. Both paths converge on a data-and-errors envelope sent back to the client. GraphQL client POST /graphql query / mutation MSW graphql layer reads operationName + variables named match Explicit resolver graphql.query / graphql.mutation unmatched Schema auto-mock addMocksToSchema + SDL { data, errors } returned to client Explicit resolvers win; the auto-mock is the fallback for everything else in the schema

The rest of this page builds that picture in three phases: explicit operation handlers first, whole-schema auto-mocking second, then wiring both into the worker, the test server, and CI.


Phase 1 — Explicit graphql.query and graphql.mutation handlers

Import the graphql namespace from msw alongside the http namespace you already use. Each handler names the operation it answers, and the resolver receives the parsed variables and returns a data object through HttpResponse.json.

Create the handler list at src/mocks/graphql-handlers.ts:

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

interface User {
  id: string
  displayName: string
  email: string
  role: 'ADMIN' | 'EDITOR' | 'VIEWER'
}

interface GetUserVariables {
  id: string
}

interface GetUserData {
  user: User | null
}

interface CreateProjectVariables {
  input: { name: string; ownerId: string }
}

interface CreateProjectData {
  createProject: {
    id: string
    name: string
    ownerId: string
    createdAt: string
  }
}

// A tiny in-memory table so the mock behaves consistently within a session.
const users: Record<string, User> = {
  'u_1': { id: 'u_1', displayName: 'Ada Lovelace', email: '[email protected]', role: 'ADMIN' },
  'u_2': { id: 'u_2', displayName: 'Grace Hopper', email: '[email protected]', role: 'EDITOR' },
}

let projectSeq = 100

export const graphqlHandlers = [
  // query GetUser($id: ID!) { user(id: $id) { id displayName email role } }
  graphql.query<GetUserData, GetUserVariables>('GetUser', ({ variables }) => {
    const user = users[variables.id] ?? null
    return HttpResponse.json({ data: { user } })
  }),

  // mutation CreateProject($input: CreateProjectInput!) { createProject { id name ownerId createdAt } }
  graphql.mutation<CreateProjectData, CreateProjectVariables>(
    'CreateProject',
    ({ variables }) => {
      const { name, ownerId } = variables.input

      if (!users[ownerId]) {
        return HttpResponse.json({
          errors: [
            {
              message: `Owner ${ownerId} does not exist`,
              extensions: { code: 'BAD_USER_INPUT' },
            },
          ],
        })
      }

      projectSeq += 1
      return HttpResponse.json({
        data: {
          createProject: {
            id: `p_${projectSeq}`,
            name,
            ownerId,
            createdAt: new Date('2025-02-18T09:00:00.000Z').toISOString(),
          },
        },
      })
    },
  ),
]

Three details make this reliable:

  • The string argument is the operation name, not the endpoint. graphql.query('GetUser') matches any document whose operation is declared query GetUser. It does not care about the URL beyond the default POST GraphQL request shape, which keeps the handler stable if your endpoint path changes.
  • Variables are already parsed. MSW reads the JSON body, extracts the variables object, and types it through the second generic. You never touch request.json() for the standard case.
  • Errors are data, not HTTP status. A GraphQL error is returned as an errors[] entry with a 200 status, because transport succeeded even though the field resolution failed. The dedicated guide to mocking GraphQL queries and mutations walks through partial data plus errors responses in more depth.

Scoping to a specific endpoint

If your app talks to more than one GraphQL server (for example a public API and an internal admin API), scope handlers with graphql.link so an operation name collision between the two does not cross-match:

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

const adminApi = graphql.link('https://admin.internal/graphql')

interface AuditLogData {
  auditLog: { entries: Array<{ id: string; actor: string; action: string }> }
}

export const adminGraphqlHandlers = [
  adminApi.query<AuditLogData>('AuditLog', () => {
    return HttpResponse.json({
      data: {
        auditLog: {
          entries: [
            { id: 'a_1', actor: 'u_1', action: 'project.create' },
            { id: 'a_2', actor: 'u_2', action: 'project.rename' },
          ],
        },
      },
    })
  }),
]

graphql.link(url) restricts matching to operations sent to that exact endpoint, so AuditLog on the admin API and any same-named operation on your primary API stay independent.


Phase 2 — Whole-schema auto-mocking with @graphql-tools/mock

Hand-written handlers cover the operations a test asserts against, but a real client fires many more queries — navigation menus, feature flags, the current user badge — that you do not want to stub one by one. @graphql-tools/mock builds an executable schema from your SDL and fills every field with plausible placeholder values, so any operation resolves instead of throwing.

Build the mocked schema once at src/mocks/graphql-schema.ts:

// src/mocks/graphql-schema.ts
import { makeExecutableSchema } from '@graphql-tools/schema'
import { addMocksToSchema } from '@graphql-tools/mock'
import { graphql as executeGraphQL, print } from 'graphql'
import type { DocumentNode } from 'graphql'

// In a real project, read this from schema.graphql with fs.readFileSync.
const typeDefs = /* GraphQL */ `
  type User {
    id: ID!
    displayName: String!
    email: String!
    role: Role!
  }

  enum Role {
    ADMIN
    EDITOR
    VIEWER
  }

  type Project {
    id: ID!
    name: String!
    owner: User!
    createdAt: String!
  }

  type Query {
    user(id: ID!): User
    projects: [Project!]!
  }

  type Mutation {
    createProject(input: CreateProjectInput!): Project!
  }

  input CreateProjectInput {
    name: String!
    ownerId: ID!
  }
`

const baseSchema = makeExecutableSchema({ typeDefs })

// Deterministic-ish mock resolvers keep values readable rather than random.
export const mockedSchema = addMocksToSchema({
  schema: baseSchema,
  mocks: {
    ID: () => `mock_${Math.floor(Math.random() * 1_000)}`,
    String: () => 'placeholder',
    Role: () => 'VIEWER',
    User: () => ({
      displayName: 'Mock User',
      email: '[email protected]',
    }),
  },
})

// Execute an operation against the mocked schema and return the raw result.
export async function runAgainstMockedSchema(
  document: DocumentNode,
  variables: Record<string, unknown>,
) {
  return executeGraphQL({
    schema: mockedSchema,
    source: print(document),
    variableValues: variables,
  })
}

Now expose the auto-mock as a single catch-all MSW handler that runs after your explicit handlers. Because MSW evaluates handlers in order and uses the first match, list the explicit handlers first and the auto-mock last.

// src/mocks/graphql-automock-handler.ts
import { graphql, HttpResponse } from 'msw'
import { runAgainstMockedSchema } from './graphql-schema'

// graphql.operation() matches ANY GraphQL operation on the default endpoint,
// so keep it last in the array — it is the fallback, not an override.
export const graphqlAutoMock = graphql.operation(async ({ query, variables }) => {
  const result = await runAgainstMockedSchema(query, variables ?? {})
  return HttpResponse.json(result)
})

graphql.operation() hands you the parsed query document and the variables, which is exactly what the executable schema needs. Anything your explicit handlers did not claim is answered by the schema instead of falling through to the network.

For projects that already lean on advanced MSW handler patterns — stateful sequences, per-test overrides, dynamic status codes — the auto-mock composes cleanly: it is just one more handler at the bottom of the list, and server.use() overrides still win because they are prepended at runtime.

When to reach for auto-mocking

Situation Explicit handler Schema auto-mock
A test asserts on exact field values Yes — you control the data No — values are placeholders
Rendering a page that fires many peripheral queries Tedious to stub each one Yes — one handler covers all
Simulating a specific GraphQL error Yes — return errors[] No — auto-mock always succeeds
Keeping mocks aligned as the schema grows Manual edits per operation Automatic from SDL
Deterministic snapshot tests Yes — fixed values Only with seeded mock functions

Most teams run both layers: the auto-mock as a baseline so nothing 404s, and explicit handlers as overrides for the handful of operations under assertion.


Phase 3 — Registering the handlers and CI wiring

The GraphQL handlers are runtime-agnostic, exactly like the http handlers described in the MSW setup guide. You combine them with your existing REST handlers in one array and feed that array to both setupWorker (browser) and setupServer (Node).

Combine everything in src/mocks/handlers.ts:

// src/mocks/handlers.ts
import { restHandlers } from './rest-handlers'
import { graphqlHandlers } from './graphql-handlers'
import { adminGraphqlHandlers } from './admin-graphql-handlers'
import { graphqlAutoMock } from './graphql-automock-handler'

// Order matters: explicit REST + GraphQL handlers first, auto-mock LAST.
export const handlers = [
  ...restHandlers,
  ...graphqlHandlers,
  ...adminGraphqlHandlers,
  graphqlAutoMock,
]

The browser and Node entry points do not change from the base MSW setup — they simply spread the combined list:

// src/mocks/browser.ts
import { setupWorker } from 'msw/browser'
import { handlers } from './handlers'

export const worker = setupWorker(...handlers)
// src/mocks/node.ts
import { setupServer } from 'msw/node'
import { handlers } from './handlers'

export const server = setupServer(...handlers)

Wire the Node server into your test setup with onUnhandledRequest: 'error' so a mistyped operation name fails the test rather than silently escaping to the real API:

// src/setupTests.ts
import { afterAll, afterEach, beforeAll } from 'vitest'
import { server } from './mocks/node'

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

CI pipeline

Because the Node interceptor needs no browser and no separate process, GraphQL mocking runs in CI exactly like the rest of your MSW suite. The broader patterns for keeping a mock stack healthy across a pipeline live in the guide to running mock servers in CI pipelines; the minimal job is:

# .github/workflows/test.yml
name: Test
on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: npm
      - run: npm ci
      - run: npm test -- --run
        env:
          NODE_ENV: test

No environment variable toggles the mocks on for tests — the Node interceptor is always active inside the suite. For local development in the browser, gate worker.start() behind your dev flag exactly as the MSW setup page describes.


Verification steps

Start your dev server (or a small script that calls server.listen()), then confirm each layer independently.

  • An explicit query returns the fixed value:
    curl -s http://localhost:5173/graphql \
      -H 'Content-Type: application/json' \
      -d '{"query":"query GetUser($id: ID!){ user(id:$id){ id displayName role } }","variables":{"id":"u_1"},"operationName":"GetUser"}'
    Expected output:
    {"data":{"user":{"id":"u_1","displayName":"Ada Lovelace","role":"ADMIN"}}}
  • A GraphQL error surfaces in errors[], not as an HTTP failure:
    curl -s -o /dev/null -w "%{http_code}\n" http://localhost:5173/graphql \
      -H 'Content-Type: application/json' \
      -d '{"query":"mutation CreateProject($input: CreateProjectInput!){ createProject(input:$input){ id } }","variables":{"input":{"name":"X","ownerId":"nope"}},"operationName":"CreateProject"}'
    # Expected: 200  (the error is inside the body, not the status line)
  • An operation you never hand-wrote is still answered by the auto-mock:
    curl -s http://localhost:5173/graphql \
      -H 'Content-Type: application/json' \
      -d '{"query":"query Projects { projects { id name } }","operationName":"Projects"}' | head -c 120
    # Expected: a JSON body with data.projects populated by placeholder values
  • The test suite reports MSW is active:
    npm test -- --run 2>&1 | grep '\[MSW\]'
    # Expected: [MSW] Mocking enabled.

Troubleshooting

Operation not matched — the request escapes to the real API

Cause: graphql.query('GetUser') matches only a document that declares query GetUser. An anonymous operation (query { user { id } }), a differently-named operation, or a client that sends the wrong operationName never matches.

Fix: Name every operation on the client, and make the string in the handler identical. Turn on onUnhandledRequest: 'error' so the mismatch throws during tests instead of hitting the network. In the browser, the MSW console warning prints the operation name it saw, which you can diff against your handler.

__typename is missing and the client cache breaks

Cause: Normalising clients such as Apollo Client add __typename to every selection set and rely on it to key the cache. A hand-written resolver that omits __typename returns data the cache cannot index, causing repeated refetches or a blank UI.

Fix: Include __typename on every object you return from an explicit handler:

return HttpResponse.json({
  data: {
    user: { __typename: 'User', id: 'u_1', displayName: 'Ada Lovelace', role: 'ADMIN' },
  },
})

The @graphql-tools/mock auto-mock adds __typename automatically because it executes against the real schema, so this only affects hand-written resolvers.

variables is undefined inside the resolver

Cause: The client sent the query but no variables object (common with graphql-request when a query has no arguments, or when a client inlines values instead of using variables). Destructuring variables.id then throws.

Fix: Default the object and read defensively: const { id } = variables ?? {}. For operations that genuinely require an argument, return a BAD_USER_INPUT error entry when the value is absent, mirroring how the real server would respond.

Batched queries only return the first result

Cause: Some Apollo Client setups use BatchHttpLink, which packs several operations into a single JSON array body. MSW’s graphql handlers match one operation per request and see only the first entry of the array.

Fix: Disable batching in development (new HttpLink() instead of BatchHttpLink) so each operation is its own request and each matches its own handler. If you must keep batching, add a graphql.operation() handler that reads await request.json(), detects the array, executes each entry against the mocked schema, and returns the array of results.

Auto-mock answers an operation you meant to assert on

Cause: The explicit handler is listed after graphql.operation(), so the catch-all matches first and your fixed values never run.

Fix: Keep graphqlAutoMock last in the handlers array. MSW uses the first matching handler, so overrides must precede the fallback. In a single test, a server.use(graphql.query('GetUser', resolver)) call prepends the override and always wins.


When to advance

The GraphQL mock layer is solid when:

  • Every operation your app fires resolves — explicit handlers for asserted operations, the schema auto-mock for the rest — with no request reaching the real API in tests
  • onUnhandledRequest: 'error' is set and the suite fails when an operation name is mistyped
  • Hand-written resolvers include __typename and your normalising client caches results without refetch loops
  • The same handlers array drives both the browser worker and the Node test server, so local development and CI see identical responses
  • GraphQL errors are returned through errors[] with a 200 status, and your UI’s error boundaries handle them

Once those hold, move on to the two focused guides below: one for the exact mechanics of query, mutation, and error resolvers, and one for pushing events over a subscription.


FAQ

How is mocking a GraphQL API different from mocking REST endpoints?

REST mocking keys on method plus URL path. GraphQL sends almost everything to one endpoint as a POST, so MSW matches on the operation name inside the query document instead of the path. You also return only the data and errors envelope, not an arbitrary body, and the shape you return should follow the fields the client selected. The interception layer itself is the same one described under request interception patterns; only the matching key differs.

Should I hand-write graphql handlers or auto-mock from the schema?

Hand-write handlers for the operations a test asserts against, because you control the exact values. Use @graphql-tools/mock to fill in every other field in the schema so unrelated queries still resolve with plausible placeholder data instead of throwing. Most projects run both: auto-mock as the baseline, explicit handlers as overrides. Keep the auto-mock handler last in the list so the explicit ones win.

Why does my query fall through to the network even though I registered a handler?

The most common cause is an operation-name mismatch. graphql.query('GetUser') only matches a document that literally declares query GetUser. Anonymous queries and typos never match. Enable onUnhandledRequest: 'error' so an unmatched GraphQL operation fails loudly instead of hitting the real API, and check the operation name MSW prints in its warning against the string in your handler.

Can the same GraphQL handlers run in the browser and in Node tests?

Yes. The graphql handlers are runtime-agnostic, exactly like http handlers. You export one handlers array and feed it to setupWorker in the browser and setupServer in Node, so a query mocked for local development is identical to the one your Vitest or Jest suite sees. This is the same dual-context model covered in the base MSW setup guide.


← Back to Tool-Specific Implementation & Setup