GraphQL Contract Testing: Keeping Clients and Servers in Sync

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., String to Int)
  • 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/cli

Compare 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 fields

In 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-breaking

Tool 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 files

Output 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 files

This 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.json

Register with Apollo Router:

# router.yaml
persisted_queries:
  enabled: true
  safelist:
    enabled: true
    require_id: true  # only accept persisted queries

Now 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.graphql

Run schema checks before merging:

npx rover graph check my-graph@current \
  --schema ./proposed-schema.graphql

Output 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_TOKEN

PactFlow 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-operations

Run codegen:

npx graphql-codegen

In CI, run codegen and check for TypeScript errors:

- name: Generate GraphQL types
  run: npx graphql-codegen
- name: TypeScript check
  run: npx tsc --noEmit

If 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:

  1. Mark the field deprecated
  2. Monitor field usage in production (Apollo Studio shows usage by client)
  3. Notify client teams
  4. 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:

  1. graphql-inspector in CI: catch breaking changes before merge
  2. Client operation validation: ensure client queries are valid against the schema
  3. TypeScript codegen: make breaking changes fail at compile time
  4. Schema registry: track what fields clients actually use in production
  5. 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.

Read more