GraphQL Contract Testing: Keeping Clients and Servers in Sync
GraphQL schema evolution is a double-edged sword. The schema gives you a formal contract between clients and servers. But that contract is only useful if you enforce it — if you can detect when a server-side change would break existing clients before it reaches production.
This guide covers the tools and strategies for keeping GraphQL clients and servers in sync: schema change detection, client operation validation, schema registries, and how to use Pact's bi-directional contract testing for GraphQL.
Why GraphQL Breaking Changes Happen
GraphQL's type system makes breaking changes unambiguous:
Breaking changes:
- Removing a field from a type
- Renaming a field
- Changing a field's type (e.g.,
StringtoInt) - Adding a required argument
- Changing a nullable field to non-null in a way that removes values clients relied on
Non-breaking changes:
- Adding new optional fields
- Adding new types
- Adding optional arguments with defaults
- Making a non-null field nullable
- Adding values to a union or interface
The problem is that "non-breaking to the schema" doesn't mean "safe for all clients." A client might be checking for specific field names, union members, or enum values that don't exist yet in its type definitions.
Tool 1: graphql-inspector
@graphql-inspector/cli compares two schema versions and reports breaking changes.
npm install --save-dev @graphql-inspector/cliCompare schemas:
# Compare current schema to the version on main branch
npx graphql-inspector diff \
<span class="hljs-string">'git:main:./schema.graphql' \
<span class="hljs-string">'./schema.graphql'Output:
✖ Field 'User.username' was removed
Criticality: BREAKING
⚠ Field 'User.displayName' was added
Criticality: NON_BREAKING
✔ 12 unchanged fieldsIn CI, fail the build on breaking changes:
# .github/workflows/schema-check.yml
- name: Check for breaking schema changes
run: |
npx graphql-inspector diff \
'git:main:./schema.graphql' \
'./schema.graphql' \
--fail-on-breakingTool 2: graphql-inspector Coverage
Check whether your test operations cover all fields in the schema:
npx graphql-inspector coverage \
'./schema.graphql' \
<span class="hljs-string">'./tests/**/*.graphql' <span class="hljs-comment"># your test operation filesOutput shows which fields are covered by tests and which aren't. Fields with zero coverage are the ones most likely to have undetected breaking changes.
Tool 3: Validating Client Operations Against Schema
Validate that all queries your client sends are valid against the current schema:
npx graphql-inspector validate \
'./schema.graphql' \
<span class="hljs-string">'./src/**/*.graphql' <span class="hljs-comment"># client operation filesThis catches client-side errors: queries that reference fields that don't exist, wrong variable types, missing required arguments.
In CI, run this on every PR that changes either the schema or client operations:
- name: Validate client operations
run: |
npx graphql-inspector validate \
'./backend/schema.graphql' \
'./frontend/src/**/*.graphql'Persisted Queries as Contract Enforcement
Persisted queries lock the set of operations a client can send. Instead of sending arbitrary query strings, the client sends a hash ID that references a pre-registered operation.
The server only accepts registered operations. This means:
- Clients can't send queries that break with schema changes (the registration would fail)
- The server knows exactly which operations clients use (no guessing)
- You can safely remove fields not referenced by any persisted operation
Apollo Client Persisted Queries Setup
npm install --save-dev @apollo/generate-persisted-query-manifest# Generate the manifest from client operations
npx generate-persisted-query-manifest
<span class="hljs-comment"># Creates: persisted-query-manifest.jsonRegister with Apollo Router:
# router.yaml
persisted_queries:
enabled: true
safelist:
enabled: true
require_id: true # only accept persisted queriesNow the router rejects any non-registered query — effectively making the persisted query manifest the contract between client and server.
Schema Registry
For organizations with multiple GraphQL clients and federated schemas, a schema registry centralizes schema management.
Apollo Schema Registry (part of Apollo Studio):
- Stores schema versions with history
- Runs schema checks on every publish
- Shows which client operations each schema change would break
- Tracks field usage from production traffic
Publish your schema:
npx rover graph publish my-graph@current \
--schema ./schema.graphqlRun schema checks before merging:
npx rover graph check my-graph@current \
--schema ./proposed-schema.graphqlOutput lists operations that the proposed schema would break, with the operation names and the fields they rely on.
Pact Bi-Directional Contract Testing for GraphQL
Pact's BDCT mode supports GraphQL. The consumer side uses Pact consumer tests; the server side publishes a GraphQL SDL instead of running Pact verification.
Consumer Side
Write Pact consumer tests that use GraphQL operations:
// tests/pact/graphql-consumer.test.ts
import { PactV4, MatchersV4 } from '@pact-foundation/pact';
const { like, string, integer } = MatchersV4;
const provider = new PactV4({
consumer: 'react-app',
provider: 'graphql-api',
dir: './pacts',
});
describe('GraphQL consumer pacts', () => {
it('GetUser operation', () => {
provider
.addInteraction()
.given('user 1 exists')
.uponReceiving('GetUser query')
.withRequest('POST', '/graphql')
.withRequestHeaders({ 'Content-Type': 'application/json' })
.withRequestBody({
operationName: 'GetUser',
query: `query GetUser($id: ID!) { user(id: $id) { id name email } }`,
variables: { id: '1' },
})
.willRespondWith()
.withStatus(200)
.withResponseHeaders({ 'Content-Type': 'application/json' })
.withResponseBody({
data: {
user: {
id: string('1'),
name: string('Alice'),
email: string('alice@example.com'),
},
},
});
return provider.executeTest(async (mockProvider) => {
const response = await fetch(`${mockProvider.url}/graphql`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
operationName: 'GetUser',
query: `query GetUser($id: ID!) { user(id: $id) { id name email } }`,
variables: { id: '1' },
}),
});
const data = await response.json();
expect(data.data.user.id).toBe('1');
});
});
});Provider Side (GraphQL Schema as Contract)
Instead of running Pact verification code, publish the GraphQL SDL to PactFlow:
# Convert SDL to JSON for BDCT
npx pact-broker publish-provider-contract schema.graphql \
--provider graphql-api \
--provider-app-version <span class="hljs-variable">$GIT_SHA \
--content-type application/graphql \
--verification-success \
--broker-base-url <span class="hljs-variable">$PACTFLOW_URL \
--broker-token <span class="hljs-variable">$PACTFLOW_API_TOKENPactFlow compares the consumer's pact (what fields/types it uses) against the SDL to determine compatibility — no additional provider-side test code needed.
Detecting Breaking Changes in the Type System
For TypeScript + GraphQL clients, use graphql-code-generator to generate types from your schema, then break the build when types change incompatibly:
npm install --save-dev @graphql-codegen/cli @graphql-codegen/typescript# codegen.yml
schema: './schema.graphql'
documents: './src/**/*.graphql'
generates:
src/generated/graphql.ts:
plugins:
- typescript
- typescript-operationsRun codegen:
npx graphql-codegenIn CI, run codegen and check for TypeScript errors:
- name: Generate GraphQL types
run: npx graphql-codegen
- name: TypeScript check
run: npx tsc --noEmitIf a schema change removes a field the client uses, codegen generates types with that field missing, TypeScript compilation fails, and the CI build breaks — before any code reaches production.
Putting It Together: A Full CI Strategy
# .github/workflows/graphql-contracts.yml
name: GraphQL Contract Validation
on: [push, pull_request]
jobs:
schema-check:
name: Schema Compatibility Check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 }
- run: npm ci
- name: Check for breaking schema changes
run: |
npx graphql-inspector diff \
'git:main:./backend/schema.graphql' \
'./backend/schema.graphql' \
--fail-on-breaking
- name: Validate client operations against schema
run: |
npx graphql-inspector validate \
'./backend/schema.graphql' \
'./frontend/src/**/*.graphql'
- name: Generate TypeScript types
run: npx graphql-codegen
- name: TypeScript compilation check
run: npx tsc --noEmit
- name: Publish schema to registry
if: github.ref == 'refs/heads/main'
run: |
npx rover graph publish my-graph@current \
--schema ./backend/schema.graphql
env:
APOLLO_KEY: ${{ secrets.APOLLO_KEY }}Deprecation as a Migration Strategy
Instead of breaking fields immediately, use @deprecated:
type User {
username: String @deprecated(reason: "Use displayName instead. Remove after 2025-06-01.")
displayName: String!
}Then:
- Mark the field deprecated
- Monitor field usage in production (Apollo Studio shows usage by client)
- Notify client teams
- Remove the field only after all clients have migrated (confirm with schema registry data)
This is the safest path for evolving shared GraphQL schemas: never remove without deprecating first, never remove without verifying zero production usage.
Summary
The tools that matter most, in order of impact:
- graphql-inspector in CI: catch breaking changes before merge
- Client operation validation: ensure client queries are valid against the schema
- TypeScript codegen: make breaking changes fail at compile time
- Schema registry: track what fields clients actually use in production
- Persisted queries: lock the client-server contract in production
Start with graphql-inspector — it's free, fast to set up, and catches the most common class of breaking change. Add the rest as your schema and team size grow.