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:
- Structural validation — is the SDL itself valid? Are all types resolvable? Do interfaces have all required fields?
- Breaking change detection — does this schema change break existing clients?
- 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:
- Schema changes require a PR — no direct commits to
schema.graphql - Breaking change check runs on every PR — automated, not manual review
- Consumer contracts are committed in the repo — not in a wiki or Slack thread
- Deprecation policy is enforced —
@deprecatedrequires a reason and a migration guide - 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.