Contract Testing GraphQL Schemas With Schema Checks

GraphQL comes with something REST APIs have to build separately: a machine-readable contract. The schema states every type, field and argument the server offers, and every client operation states exactly which of those it uses. That makes contract testing for GraphQL less about recording interactions and more about two cheap static checks — does every client operation still validate against the server’s schema, and does a proposed schema change break any operation in use — plus resolver tests that run the real operations against the real schema. This guide covers extracting operations from client code, validating them in CI, detecting breaking changes before they merge, and keeping MSW mocks honest against the same schema. It sits under contract testing.

Root Cause Analysis

GraphQL breakages are usually one of three shapes. A field a client queries is removed or renamed on the server. A field’s type changes in a way that breaks client code — nullable becoming non-null in an argument, a scalar becoming an object. Or an enum gains a value the client’s exhaustive switch does not handle. The first two make the operation invalid against the new schema; the third is valid but surprises the client at runtime.

All three are detectable before deployment, because both halves of the contract are text. The schema is a file or can be introspected; the client’s operations are string literals or .graphql files in the repository. Validating one against the other is a function call. The reason teams still ship these breakages is that the check lives nowhere: the server’s CI knows its schema but not the client’s operations, and the client’s CI knows its operations but tests them against mocks that return whatever they were told to.

That last point is the quiet danger. A client test using a hand-written mock of a GraphQL response will pass forever, even after the server removes the field, because the mock was never checked against the schema. Mocks generated from, or validated against, the schema close that gap.

Both halves of a GraphQL contract are text The server's schema and the client's operations are both static artifacts, so validating every operation against the schema, and diffing schema versions for breaking changes, can run in CI without starting either system. server schema types, fields, arguments schema.graphql client operations queries and mutations *.graphql, gql tags validate() every operation against the schema no server, no client, no network — a function call in CI
The cheapest contract test in this section: two text files and a validator that already ships with graphql-js.

Reproducible Setup

Keep operations in .graphql files, or extract them from tagged templates, so they are available as data.

npm install -D graphql @graphql-inspector/cli @graphql-tools/load @graphql-tools/graphql-file-loader
# src/features/orders/queries/order-summary.graphql
query OrderSummary($id: ID!) {
  order(id: $id) {
    id
    status
    total { amountPence currency }
    lines { sku quantity }
  }
}
# the server publishes its schema as a build artifact the client can fetch
curl -sf "$SCHEMA_REGISTRY_URL/orders-api/main/schema.graphql" -o schema/orders-api.graphql

Implementation

Step 1 — Validate every client operation against the server schema. graphql-js’s validate applies the same rules the server does, so an operation that passes here will be accepted at runtime.

// test/contract/operations.test.ts
import { test, expect } from 'vitest';
import { buildSchema, parse, validate } from 'graphql';
import { readFileSync } from 'node:fs';
import { globSync } from 'glob';

const schema = buildSchema(readFileSync('schema/orders-api.graphql', 'utf8'));
const files = globSync('src/**/*.graphql');

test.each(files)('%s is valid against the orders-api schema', (file) => {
  const errors = validate(schema, parse(readFileSync(file, 'utf8')));
  expect(errors.map((e) => e.message)).toEqual([]);
});

Step 2 — Run the same check on the server side, against the clients’ operations. The server’s CI fetches the operations its known clients publish, and fails a schema change that would invalidate any of them.

npx graphql-inspector validate \
  "clients/**/*.graphql" \
  "src/schema/**/*.graphql"
# ✔ All documents are valid

Step 3 — Diff schema versions for breaking changes. graphql-inspector classifies every change as breaking, dangerous or safe, which gives the pull request a precise, reviewable summary.

# .github/workflows/schema.yml
      - run: git fetch origin main
      - run: git show origin/main:schema.graphql > /tmp/schema-main.graphql
      - run: npx graphql-inspector diff /tmp/schema-main.graphql schema.graphql
# ✖ Field 'Order.total' changed type from 'Money!' to 'Money'   (breaking)
# ⚠ Enum value 'REFUNDED' was added to enum 'OrderStatus'      (dangerous)

Step 4 — Validate MSW mock responses against the schema. A mock that returns fields the schema does not have, or omits non-null ones, is a contract violation hiding in the test suite; executing the operation against a schema with mocked resolvers produces responses that cannot drift.

// test/msw/graphql.ts
import { graphql, HttpResponse } from 'msw';
import { buildSchema, graphql as execute } from 'graphql';
import { addMocksToSchema } from '@graphql-tools/mock';
import { readFileSync } from 'node:fs';

const schema = addMocksToSchema({
  schema: buildSchema(readFileSync('schema/orders-api.graphql', 'utf8')),
  mocks: { ID: () => 'ord_1', Int: () => 4999 },
});

export const handlers = [
  graphql.query('OrderSummary', async ({ query, variables }) =>
    HttpResponse.json(await execute({ schema, source: query, variableValues: variables })),
  ),
];
How schema changes are classified Removing a field or making an argument required is breaking and should block the merge; adding an enum value or making a field nullable is dangerous and needs review; adding a field or type is safe. breaking field removed or renamed argument made required type changed block unless unused dangerous enum value added field made nullable default value changed review client handling safe field added type added optional argument added merge freely
Dangerous changes are the interesting column: valid for every operation, yet able to break client code at runtime.

The dangerous category deserves particular attention in review, because it is where static checks run out. Adding a value to an enum is valid for every operation, yet a client with an exhaustive switch over that enum — common in TypeScript with generated types — will hit its default branch or throw. The schema diff flags it; a human then checks whether each client handles unknown values. Making a field nullable is similar: every operation still validates, but code that assumed a value will now meet null at runtime.

Step 5 — Allow breaking changes that no operation uses. A field no client queries can be removed safely; combining the diff with the validation from Step 2 turns “breaking” into “breaking for someone”, which is the question that matters.

Step 6 — Test resolvers with the operations clients actually send. The server’s own tests should execute the published client operations against the real schema and resolvers, so a resolver that returns null for a non-null field fails there rather than in a client.

test('the OrderSummary operation resolves against seeded data', async () => {
  const source = readFileSync('clients/web/order-summary.graphql', 'utf8');
  const result = await execute({ schema: realSchema, source, variableValues: { id: seeded.order.id }, contextValue: testContext() });
  expect(result.errors).toBeUndefined();
  expect(result.data?.order.total).toEqual({ amountPence: 4999, currency: 'GBP' });
});

Taken together, these steps put a check on each side of the boundary. The client’s CI proves its operations are valid against the server it will talk to; the server’s CI proves its next schema is compatible with the operations clients actually send; and the resolver tests prove that valid operations also return valid data. None of the three needs both systems running at once, which is what keeps them fast enough to run on every change rather than in a nightly job nobody reads.

Verification

Confirm the client-side check catches a real break. Remove a field the client queries from the local copy of the schema and run the validation — the failure should name the operation and the missing field.

npx vitest run test/contract
# FAIL src/features/orders/queries/order-summary.graphql is valid against the orders-api schema
#   Cannot query field "total" on type "Order".

Then confirm the mocks cannot drift. Rename a field in the schema and run a component test that uses the MSW handler; the executed response should change shape accordingly and the component test should fail, proving the mock follows the schema rather than a hand-written fixture.

Hand-written mocks versus schema-executed mocks A hand-written mock keeps returning a removed field and the client test keeps passing; a mock produced by executing the operation against the schema stops returning it the moment the schema changes, so the client test fails where it should. hand-written mock returns whatever it was told field removed: test still passes schema-executed mock shape follows the schema field removed: test fails
A mock that cannot drift from the schema turns every client test into a small contract check.

Troubleshooting

Symptom: validation passes but the server rejects the operation. Diagnosis: the client validated against a stale schema file. Fix: fetch the schema from the registry or the server’s build artifact in CI rather than committing a copy that drifts, and record which schema version was used.

Symptom: operations embedded in gql template literals are not checked. Diagnosis: the validation only reads .graphql files. Fix: use graphql-inspector’s or graphql-codegen’s document loaders, which extract tagged templates from source files, or move operations into .graphql files.

Symptom: the diff flags breaking changes nobody depends on. Diagnosis: the diff does not know which fields are used. Fix: combine it with operation validation as in Step 5, or feed usage data from the server’s operation logs into the diff, so only changes that break a real operation block the merge.

Symptom: mocked responses contain unrealistic values. Diagnosis: default mocks generate random strings and numbers. Fix: supply mocks per scalar and per type for the values your UI cares about, while keeping the shape schema-driven.

FAQ

Is Pact still useful for GraphQL?

It can be, particularly where consumers need to pin specific response values for specific inputs. For most teams, schema validation plus resolver tests give broader protection for less effort, because the schema already expresses the contract that Pact would otherwise have to record.

Where should the schema live?

Wherever the server publishes it as an artifact — a schema registry, a package, or a file in a release. What matters is that clients fetch the version the server actually runs, not a copy that someone updated by hand months ago.

How do federated schemas change this?

Validate operations against the composed supergraph rather than individual subgraphs, since that is what clients query. Each subgraph’s CI should additionally check that its changes still compose, which federation tooling provides directly.

Do persisted queries help?

Yes — a registry of persisted operations is exactly the list of operations in use, which makes the server-side validation in Step 2 precise. It also makes “unused field” a fact rather than a guess, so safe removals become easy to identify.