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 plainfetch) - 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/mockand@graphql-tools/schemainstalled for Phase 2 (npm install --save-dev @graphql-tools/mock @graphql-tools/schema graphql) -
curlavailable 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.
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 declaredquery GetUser. It does not care about the URL beyond the defaultPOSTGraphQL request shape, which keeps the handler stable if your endpoint path changes. - Variables are already parsed. MSW reads the JSON body, extracts the
variablesobject, and types it through the second generic. You never touchrequest.json()for the standard case. - Errors are data, not HTTP status. A GraphQL error is returned as an
errors[]entry with a200status, because transport succeeded even though the field resolution failed. The dedicated guide to mocking GraphQL queries and mutations walks through partialdatapluserrorsresponses 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:
Expected output: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"}'{"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
__typenameand your normalising client caches results without refetch loops - The same
handlersarray 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 a200status, 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.
Related
- Mocking GraphQL Queries and Mutations with MSW — reading variables, typed data, and simulating
errors[] - Stubbing GraphQL Subscriptions Locally — pushing events over a local WebSocket
- Mock Service Worker (MSW) Setup — installing and registering the worker these handlers plug into
- Advanced MSW Handler Patterns — stateful sequences, overrides, and dynamic responses that compose with GraphQL handlers
- Request Interception Patterns — the interception model GraphQL matching is built on
← Back to Tool-Specific Implementation & Setup