GraphQL Schema Testing and Validation: Breaking Changes, Contracts, and SDL Governance

GraphQL Schema Testing and Validation: Breaking Changes, Contracts, and SDL Governance

A GraphQL schema is a contract. Every field, type, and directive you publish becomes an implicit promise to every client that depends on it. When you break that contract — rename a field, change a type, remove an argument — clients break silently or loudly depending on how aggressively they validate.

Schema testing is how you honor that contract deliberately instead of by accident.

What Schema Testing Covers

Schema testing operates at three levels:

  1. Structural validation — is the SDL itself valid? Are all types resolvable? Do interfaces have all required fields?
  2. Breaking change detection — does this schema change break existing clients?
  3. Contract compliance — do consumers' declared expectations match the current schema?

Each level requires different tooling. Don't conflate them.

Structural Validation

The graphql package's buildSchema function validates SDL syntax. But syntax validity doesn't mean semantic correctness — you can have a valid SDL that references undefined types.

import { buildSchema, validateSchema } from 'graphql'

describe('schema structural validity', () => {
  it('builds without errors', () => {
    // If buildSchema throws, the SDL is syntactically invalid
    expect(() => buildSchema(typeDefs)).not.toThrow()
  })

  it('passes graphql validation rules', () => {
    const schema = buildSchema(typeDefs)
    const errors = validateSchema(schema)
    expect(errors).toHaveLength(0)
  })
})

For schemas built with makeExecutableSchema or Apollo Server:

import { makeExecutableSchema } from '@graphql-tools/schema'

describe('executable schema', () => {
  it('compiles with all resolvers present', () => {
    expect(() => makeExecutableSchema({ typeDefs, resolvers })).not.toThrow()
  })

  it('has no missing resolver warnings', () => {
    const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(() => {})
    makeExecutableSchema({
      typeDefs,
      resolvers,
      resolverValidationOptions: { requireResolversForNonScalar: 'warn' }
    })
    expect(consoleSpy).not.toHaveBeenCalledWith(expect.stringMatching(/No resolver/))
    consoleSpy.mockRestore()
  })
})

Testing Schema Invariants

Beyond validity, assert business rules about the schema structure itself:

import { buildSchema, isObjectType, isNonNullType } from 'graphql'

describe('schema invariants', () => {
  const schema = buildSchema(typeDefs)

  it('every object type has an id field', () => {
    const objectTypes = Object.values(schema.getTypeMap())
      .filter(type => isObjectType(type) && !type.name.startsWith('__'))

    const typesWithoutId = objectTypes.filter(type => !type.getFields().id)
    expect(typesWithoutId.map(t => t.name)).toEqual([])
  })

  it('id fields are non-null', () => {
    const objectTypes = Object.values(schema.getTypeMap())
      .filter(type => isObjectType(type) && !type.name.startsWith('__'))

    const typesWithNullableId = objectTypes.filter(type => {
      const idField = type.getFields().id
      return idField && !isNonNullType(idField.type)
    })
    expect(typesWithNullableId.map(t => t.name)).toEqual([])
  })

  it('mutation inputs use Input suffix', () => {
    const mutationType = schema.getMutationType()
    if (!mutationType) return

    Object.values(mutationType.getFields()).forEach(field => {
      field.args.forEach(arg => {
        if (isObjectType(arg.type)) {
          expect(arg.type.name).toMatch(/Input$/)
        }
      })
    })
  })

  it('all deprecated fields have a deprecation reason', () => {
    Object.values(schema.getTypeMap())
      .filter(type => isObjectType(type) && !type.name.startsWith('__'))
      .forEach(type => {
        Object.values(type.getFields()).forEach(field => {
          if (field.deprecationReason !== undefined) {
            expect(field.deprecationReason).not.toBe('')
            expect(field.deprecationReason.length).toBeGreaterThan(10)
          }
        })
      })
  })
})

Breaking Change Detection

The graphql package includes findBreakingChanges and findDangerousChanges utilities that compare two schema versions:

import { buildSchema, findBreakingChanges, findDangerousChanges } from 'graphql'
import { readFileSync } from 'fs'

describe('schema backward compatibility', () => {
  const previousSchema = buildSchema(
    readFileSync('./schema-baseline.graphql', 'utf8')
  )
  const currentSchema = buildSchema(
    readFileSync('./schema.graphql', 'utf8')
  )

  it('introduces no breaking changes', () => {
    const breaking = findBreakingChanges(previousSchema, currentSchema)
    expect(breaking).toHaveLength(0)
  })

  it('dangerous changes are reviewed', () => {
    const dangerous = findDangerousChanges(previousSchema, currentSchema)
    // Dangerous changes don't break clients but can affect behavior
    // Log them for review rather than failing
    if (dangerous.length > 0) {
      console.warn('Dangerous schema changes detected:', dangerous)
    }
  })
})

Breaking changes flagged by findBreakingChanges:

  • Removing a type or field
  • Changing a field type to an incompatible type
  • Removing an argument
  • Adding a non-null argument without a default value
  • Changing an input field type from nullable to non-null
  • Removing an enum value

Dangerous changes (not breaking but risky):

  • Adding an enum value (can break exhaustive switch statements)
  • Adding a nullable argument
  • Changing a field to nullable (usually fine, but clients may not expect it)

Automating Breaking Change Detection in CI

Store the baseline schema as a committed file and compare on every PR:

// In your CI test suite:
import { execSync } from 'child_process'
import { buildSchema, findBreakingChanges } from 'graphql'
import { readFileSync } from 'fs'

it('no breaking changes vs main branch', () => {
  // Get the schema from the main branch
  const baselineSDL = execSync('git show main:schema.graphql').toString()
  const currentSDL = readFileSync('./schema.graphql', 'utf8')

  const baselineSchema = buildSchema(baselineSDL)
  const currentSchema = buildSchema(currentSDL)

  const breaking = findBreakingChanges(baselineSchema, currentSchema)

  if (breaking.length > 0) {
    const details = breaking.map(c => `${c.type}: ${c.description}`).join('\n')
    throw new Error(`Breaking schema changes detected:\n${details}`)
  }
})

Contract Testing with Schema Registry

When multiple teams or services consume a single GraphQL API, contract testing prevents the API team from silently breaking consumers.

The consumer declares what fields they need; the provider validates those declarations against the current schema.

// Consumer contract declaration (e.g., from a frontend team)
const consumerContract = {
  serviceName: 'frontend-app',
  operations: [
    {
      name: 'GetUserProfile',
      query: gql`
        query GetUserProfile($id: ID!) {
          user(id: $id) {
            id
            name
            email
            avatar
          }
        }
      `,
    },
    {
      name: 'UpdateUserName',
      mutation: gql`
        mutation UpdateUserName($id: ID!, $name: String!) {
          updateUser(id: $id, input: { name: $name }) {
            id
            name
          }
        }
      `,
    },
  ],
}
// Provider validation (runs in the API team's CI)
import { validate, buildSchema } from 'graphql'
import { readFileSync } from 'fs'

describe('consumer contract compliance', () => {
  const schema = buildSchema(readFileSync('./schema.graphql', 'utf8'))

  consumerContract.operations.forEach(({ name, query, mutation }) => {
    it(`satisfies ${consumerContract.serviceName} operation: ${name}`, () => {
      const document = query || mutation
      const errors = validate(schema, document)

      if (errors.length > 0) {
        const details = errors.map(e => e.message).join('\n')
        throw new Error(
          `Consumer contract broken for ${name} (${consumerContract.serviceName}):\n${details}`
        )
      }
    })
  })
})

Persisted Query Validation

If you use persisted queries (APQ or whitelisted query IDs), validate that all registered queries still work against the current schema:

import { validate, parse, buildSchema } from 'graphql'
import persistedQueries from './persisted-queries.json'

describe('persisted query compatibility', () => {
  const schema = buildSchema(readFileSync('./schema.graphql', 'utf8'))

  Object.entries(persistedQueries).forEach(([id, queryString]) => {
    it(`persisted query ${id} is still valid`, () => {
      const document = parse(queryString)
      const errors = validate(schema, document)
      expect(errors).toHaveLength(0)
    })
  })
})

SDL Governance: Linting the Schema

Schema linting enforces style and design conventions before breaking changes even become possible. graphql-schema-linter and @graphql-eslint/eslint-plugin are the main tools.

// graphql-schema-linter configuration (.graphql-schema-linterrc)
{
  "rules": [
    "defined-types-are-used",
    "deprecations-have-a-reason",
    "descriptions-are-capitalized",
    "enum-values-all-caps",
    "fields-are-camel-cased",
    "input-object-values-are-camel-cased",
    "relay-connection-types-spec",
    "relay-connection-arguments-spec",
    "type-fields-sorted-alphabetically"
  ]
}

For programmatic integration in Jest:

import { GraphQLSchema } from 'graphql'
import { SchemaLinter } from 'graphql-schema-linter'

describe('schema linting', () => {
  it('passes all lint rules', () => {
    const linter = new SchemaLinter()
    const errors = linter.lint(typeDefs, {
      rules: ['defined-types-are-used', 'deprecations-have-a-reason', 'fields-are-camel-cased']
    })
    expect(errors).toHaveLength(0)
  })
})

Testing Nullable vs Non-Null Decisions

One of the most common schema design mistakes is making fields non-null when they could legitimately be absent. This is a breaking change to fix later. Test that your nullability decisions are intentional.

import { buildSchema, isNonNullType, isObjectType } from 'graphql'

describe('nullability conventions', () => {
  const schema = buildSchema(typeDefs)

  // Convention: timestamps are always present on persisted objects
  it('persisted types have non-null createdAt and updatedAt', () => {
    const persistedTypes = ['User', 'Post', 'Comment', 'Order']

    persistedTypes.forEach(typeName => {
      const type = schema.getType(typeName)
      if (!type || !isObjectType(type)) return

      const fields = type.getFields()
      expect(isNonNullType(fields.createdAt?.type)).toBe(true)
      expect(isNonNullType(fields.updatedAt?.type)).toBe(true)
    })
  })

  // Convention: computed/derived fields can be null
  it('profile picture is nullable (user may not have one)', () => {
    const userType = schema.getType('User')
    const avatarField = isObjectType(userType) && userType.getFields().avatar
    expect(isNonNullType(avatarField?.type)).toBe(false)
  })
})

Testing @deprecated Directive

Track deprecated field usage to know when it's safe to remove fields:

import { buildSchema, isObjectType } from 'graphql'

describe('deprecation policy', () => {
  const schema = buildSchema(typeDefs)

  it('deprecated fields still resolve (not removed prematurely)', async () => {
    // Run a query that uses deprecated fields against the server
    const data = await client.request(gql`
      query {
        user(id: "1") {
          username  # @deprecated, use name instead
        }
      }
    `)
    // Deprecated but still present
    expect(data.user.username).toBeDefined()
  })

  it('deprecated fields list is tracked', () => {
    const deprecated = []
    Object.values(schema.getTypeMap())
      .filter(type => isObjectType(type) && !type.name.startsWith('__'))
      .forEach(type => {
        Object.entries(type.getFields()).forEach(([fieldName, field]) => {
          if (field.deprecationReason) {
            deprecated.push(`${type.name}.${fieldName}: ${field.deprecationReason}`)
          }
        })
      })

    // Snapshot test — if this list grows, review it
    expect(deprecated).toMatchSnapshot()
  })
})

Schema Diffing in CI with GitHub Actions

# .github/workflows/schema-check.yml
name: Schema Compatibility Check

on:
  pull_request:
    paths:
      - 'schema.graphql'
      - 'src/**/*.graphql'

jobs:
  schema-check:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - name: Check for breaking changes
        run: |
          npm run test:schema
        env:
          SCHEMA_BASELINE_REF: origin/main
// In package.json scripts:
// "test:schema": "jest --testPathPattern=schema.test.js"

// schema.test.js
import { execSync } from 'child_process'
import { buildSchema, findBreakingChanges } from 'graphql'

test('no breaking schema changes on this PR', () => {
  const baseRef = process.env.SCHEMA_BASELINE_REF || 'HEAD~1'
  let baselineSDL
  try {
    baselineSDL = execSync(`git show ${baseRef}:schema.graphql`).toString()
  } catch {
    // Schema file is new — no baseline to compare against
    return
  }

  const currentSDL = readFileSync('./schema.graphql', 'utf8')
  const breaking = findBreakingChanges(buildSchema(baselineSDL), buildSchema(currentSDL))

  expect(breaking).toHaveLength(0)
})

What Good Schema Governance Looks Like

The teams that avoid schema chaos have a few practices in common:

  1. Schema changes require a PR — no direct commits to schema.graphql
  2. Breaking change check runs on every PR — automated, not manual review
  3. Consumer contracts are committed in the repo — not in a wiki or Slack thread
  4. Deprecation policy is enforced — @deprecated requires a reason and a migration guide
  5. Schema lint runs in CI — naming conventions and design rules are not optional

Schema testing isn't just about catching bugs — it's about making implicit contracts explicit and enforcing them automatically. The discipline pays off the first time you catch a breaking change before it ships.

Read more

Start now free