GraphQL Schema Validation Testing: SDL Linting, Breaking Change Detection, and Runtime Guards

GraphQL Schema Validation Testing: SDL Linting, Breaking Change Detection, and Runtime Guards

GraphQL schemas are contracts. Break the contract and you break every client consuming it — mobile apps, third-party integrations, internal services. Unlike REST where a missing field is a runtime surprise, GraphQL gives you the tools to validate that contract at every stage: during development, in CI, and at runtime. Most teams use none of them.

This post covers the full testing stack for GraphQL schemas: SDL linting, breaking change detection in CI pipelines, custom scalar validation, enum and union type testing, and the tradeoffs between schema-first and code-first validation.

Why Schema Testing Is Not Optional

A GraphQL schema has three failure modes that tests should catch before production:

  1. Structural violations — fields with wrong types, missing non-null annotations, circular type definitions that break introspection
  2. Breaking changes — removing fields, changing argument types, adding required arguments to existing mutations
  3. Semantic drift — enum values that no longer match business logic, custom scalars that accept invalid formats, deprecation notices ignored by clients

The cost of catching these in production versus CI is orders of magnitude different. A removed field caught by graphql-inspector in a PR takes 30 seconds to fix. The same field removed and deployed breaks every iOS client that hasn't updated — and mobile app update cycles are weeks, not hours.

SDL Linting with graphql-eslint

graphql-eslint integrates ESLint with your GraphQL SDL files and operations. It parses .graphql files and applies rules against the AST — the same way ESLint works on JavaScript.

Install it:

npm install --save-dev @graphql-eslint/eslint-plugin graphql

Create .eslintrc.graphql.js for your schema files:

module.exports = {
  overrides: [
    {
      files: ['**/*.graphql'],
      parser: '@graphql-eslint/eslint-plugin',
      plugins: ['@graphql-eslint'],
      rules: {
        // Require all types, fields, and arguments to have descriptions
        '@graphql-eslint/require-description': [
          'error',
          {
            types: true,
            FieldDefinition: true,
            InputValueDefinition: true,
          },
        ],
        // Enforce naming conventions
        '@graphql-eslint/naming-convention': [
          'error',
          {
            types: 'PascalCase',
            FieldDefinition: 'camelCase',
            InputValueDefinition: 'camelCase',
            EnumValueDefinition: 'UPPER_CASE',
            DirectiveDefinition: 'camelCase',
          },
        ],
        // Prevent duplicate field names
        '@graphql-eslint/no-duplicate-fields': 'error',
        // Require deprecation reason when using @deprecated
        '@graphql-eslint/require-deprecation-reason': 'error',
        // Catch unused fragments in operations
        '@graphql-eslint/no-unused-fragments': 'error',
        // Enforce relay-style pagination
        '@graphql-eslint/relay-arguments': 'warn',
        // Prevent anonymous operations
        '@graphql-eslint/no-anonymous-operations': 'error',
      },
    },
  ],
};

Run it as part of your lint step:

eslint --config .eslintrc.graphql.js '**/*.graphql'

For monorepos where operations live in one package and the schema in another, configure the parserOptions to point at your schema:

parserOptions: {
  schema: './packages/api/schema.graphql',
  operations: ['./packages/web/src/**/*.graphql'],
}

This enables rules that validate operations against the schema — catching references to non-existent fields, wrong argument types, and missing required arguments at lint time, not runtime.

Rules Worth Enabling Immediately

  • @graphql-eslint/fields-on-correct-type — catches field references that don't exist on the type
  • @graphql-eslint/known-argument-names — validates argument names in operations
  • @graphql-eslint/no-deprecated — errors on usage of @deprecated fields, forcing migration
  • @graphql-eslint/selection-set-depth — limits query depth to prevent expensive nested queries from slipping through code review

Breaking Change Detection with @graphql-inspector/core

Breaking changes are schema changes that will cause existing clients to fail. graphql-inspector compares two schema versions and reports every change, categorized by severity.

npm install --save-dev @graphql-inspector/core graphql

Programmatic usage for testing:

import { buildSchema } from 'graphql';
import { diff, CriticalityLevel } from '@graphql-inspector/core';

const oldSchema = buildSchema(`
  type User {
    id: ID!
    name: String!
    email: String!
  }

  type Query {
    user(id: ID!): User
  }
`);

const newSchema = buildSchema(`
  type User {
    id: ID!
    name: String!
    # email removed
  }

  type Query {
    user(id: ID!): User
    users(limit: Int!): [User!]!
  }
`);

const changes = diff(oldSchema, newSchema);

// Fail CI on any breaking change
const breaking = changes.filter(
  (c) => c.criticality.level === CriticalityLevel.Breaking
);

if (breaking.length > 0) {
  console.error('Breaking schema changes detected:');
  breaking.forEach((c) => console.error(`  - ${c.message}`));
  process.exit(1);
}

// Warn on dangerous changes (non-breaking but risky)
const dangerous = changes.filter(
  (c) => c.criticality.level === CriticalityLevel.Dangerous
);
dangerous.forEach((c) => console.warn(`  WARNING: ${c.message}`));

Running this against email: String! removed from User produces:

Breaking schema changes detected:
  - Field 'email' was removed from object type 'User'

CI Integration

Pull the previous schema from your schema registry or git history, compare against the current, and gate the PR:

# .github/workflows/schema-check.yml
name: Schema Validation
on: [pull_request]

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

      - name: Get base schema
        run: |
          git show origin/${{ github.base_ref }}:schema.graphql > /tmp/schema-base.graphql

      - name: Check for breaking changes
        run: |
          node scripts/check-schema-changes.js \
            --old /tmp/schema-base.graphql \
            --new schema.graphql
// scripts/check-schema-changes.js
import { buildSchema } from 'graphql';
import { diff, CriticalityLevel } from '@graphql-inspector/core';
import { readFileSync } from 'fs';
import { parseArgs } from 'util';

const { values } = parseArgs({
  options: {
    old: { type: 'string' },
    new: { type: 'string' },
  },
});

const oldSchema = buildSchema(readFileSync(values.old, 'utf8'));
const newSchema = buildSchema(readFileSync(values.new, 'utf8'));

const changes = diff(oldSchema, newSchema);
const breaking = changes.filter(
  (c) => c.criticality.level === CriticalityLevel.Breaking
);

if (breaking.length > 0) {
  process.exit(1);
}

For teams using a schema registry (Apollo Studio, Cosmo, Hive), the registry handles this comparison and blocks schema publication. The programmatic approach above is for teams without a registry.

Runtime Schema Validation

Building schema from SDL at startup gives you a validation pass before serving a single request:

import { buildSchema, validateSchema } from 'graphql';
import { readFileSync } from 'fs';

const typeDefs = readFileSync('./schema.graphql', 'utf8');

// buildSchema throws on parse errors
let schema;
try {
  schema = buildSchema(typeDefs);
} catch (err) {
  console.error('Schema parse error:', err.message);
  process.exit(1);
}

// validateSchema returns an array of errors, does not throw
const errors = validateSchema(schema);
if (errors.length > 0) {
  errors.forEach((e) => console.error('Schema validation error:', e.message));
  process.exit(1);
}

validateSchema catches things buildSchema misses: references to undefined types, interface implementations that are missing required fields, and union members that aren't object types.

Testing Schema Validity in Jest

import { buildSchema, validateSchema } from 'graphql';
import { readFileSync } from 'fs';
import { join } from 'path';

describe('GraphQL Schema', () => {
  let schema: ReturnType<typeof buildSchema>;

  beforeAll(() => {
    const sdl = readFileSync(join(__dirname, '../schema.graphql'), 'utf8');
    schema = buildSchema(sdl);
  });

  test('schema has no validation errors', () => {
    const errors = validateSchema(schema);
    expect(errors).toHaveLength(0);
  });

  test('Query type exists', () => {
    const queryType = schema.getQueryType();
    expect(queryType).toBeDefined();
    expect(queryType?.name).toBe('Query');
  });

  test('Mutation type exists', () => {
    const mutationType = schema.getMutationType();
    expect(mutationType).toBeDefined();
  });

  test('all types have descriptions', () => {
    const typeMap = schema.getTypeMap();
    const userTypes = Object.values(typeMap).filter(
      (t) => !t.name.startsWith('__') && !['String', 'Int', 'Float', 'Boolean', 'ID'].includes(t.name)
    );

    const undocumented = userTypes.filter((t) => !t.description);
    expect(undocumented.map((t) => t.name)).toEqual([]);
  });
});

Testing Custom Scalars

Custom scalars are the most undertested part of a GraphQL schema. The SDL declares them but the serialization, parsing, and literal parsing logic lives in code — and that code needs unit tests independent of the GraphQL layer.

// scalars/DateTime.ts
import { GraphQLScalarType, GraphQLError, Kind } from 'graphql';

export const DateTimeScalar = new GraphQLScalarType({
  name: 'DateTime',
  description: 'ISO 8601 date-time string',

  serialize(value: unknown): string {
    if (value instanceof Date) {
      if (isNaN(value.getTime())) {
        throw new GraphQLError('DateTime cannot represent an invalid Date');
      }
      return value.toISOString();
    }
    if (typeof value === 'string') {
      const date = new Date(value);
      if (isNaN(date.getTime())) {
        throw new GraphQLError(`DateTime cannot represent value: ${value}`);
      }
      return date.toISOString();
    }
    throw new GraphQLError(`DateTime cannot represent value: ${value}`);
  },

  parseValue(value: unknown): Date {
    if (typeof value !== 'string') {
      throw new GraphQLError('DateTime must be a string');
    }
    const date = new Date(value);
    if (isNaN(date.getTime())) {
      throw new GraphQLError(`DateTime cannot parse value: ${value}`);
    }
    return date;
  },

  parseLiteral(ast): Date {
    if (ast.kind !== Kind.STRING) {
      throw new GraphQLError(
        `DateTime can only parse string literals, got: ${ast.kind}`
      );
    }
    const date = new Date(ast.value);
    if (isNaN(date.getTime())) {
      throw new GraphQLError(`DateTime cannot parse literal: ${ast.value}`);
    }
    return date;
  },
});

Tests for the scalar:

import { DateTimeScalar } from './DateTime';

describe('DateTimeScalar', () => {
  describe('serialize', () => {
    test('serializes Date object to ISO string', () => {
      const date = new Date('2024-01-15T10:30:00.000Z');
      expect(DateTimeScalar.serialize(date)).toBe('2024-01-15T10:30:00.000Z');
    });

    test('serializes valid ISO string', () => {
      expect(DateTimeScalar.serialize('2024-01-15T10:30:00Z')).toBe(
        '2024-01-15T10:30:00.000Z'
      );
    });

    test('throws on invalid Date object', () => {
      expect(() => DateTimeScalar.serialize(new Date('invalid'))).toThrow(
        'DateTime cannot represent an invalid Date'
      );
    });

    test('throws on non-date, non-string value', () => {
      expect(() => DateTimeScalar.serialize(12345)).toThrow();
      expect(() => DateTimeScalar.serialize(null)).toThrow();
    });
  });

  describe('parseValue', () => {
    test('parses valid ISO string to Date', () => {
      const result = DateTimeScalar.parseValue('2024-01-15T10:30:00.000Z');
      expect(result).toBeInstanceOf(Date);
      expect(result.toISOString()).toBe('2024-01-15T10:30:00.000Z');
    });

    test('throws on non-string input', () => {
      expect(() => DateTimeScalar.parseValue(12345)).toThrow(
        'DateTime must be a string'
      );
    });

    test('throws on invalid date string', () => {
      expect(() => DateTimeScalar.parseValue('not-a-date')).toThrow();
    });
  });
});

Test every edge case: timezone handling, leap seconds, dates before Unix epoch, dates beyond 2038 (32-bit timestamp overflow). Custom scalars are where subtle bugs hide.

Testing Enum and Union Types

Enums and unions need tests that verify the schema structure and that resolvers handle every possible value.

import { buildSchema, GraphQLEnumType, GraphQLUnionType } from 'graphql';
import { readFileSync } from 'fs';

const schema = buildSchema(readFileSync('./schema.graphql', 'utf8'));

describe('Enum types', () => {
  test('OrderStatus has all required values', () => {
    const orderStatus = schema.getType('OrderStatus') as GraphQLEnumType;
    expect(orderStatus).toBeDefined();

    const values = orderStatus.getValues().map((v) => v.name);
    // Exact match — adding values without updating tests is a deliberate decision
    expect(values).toEqual(
      expect.arrayContaining(['PENDING', 'CONFIRMED', 'SHIPPED', 'DELIVERED', 'CANCELLED'])
    );
  });

  test('OrderStatus CANCELLED is deprecated', () => {
    const orderStatus = schema.getType('OrderStatus') as GraphQLEnumType;
    const cancelled = orderStatus.getValue('CANCELLED');
    expect(cancelled?.deprecationReason).toBeTruthy();
  });
});

describe('Union types', () => {
  test('SearchResult union includes all expected types', () => {
    const searchResult = schema.getType('SearchResult') as GraphQLUnionType;
    expect(searchResult).toBeDefined();

    const typeNames = searchResult.getTypes().map((t) => t.name);
    expect(typeNames).toEqual(
      expect.arrayContaining(['Product', 'User', 'Article'])
    );
  });

  test('all SearchResult members implement Node interface', () => {
    const searchResult = schema.getType('SearchResult') as GraphQLUnionType;
    const nodeInterface = schema.getType('Node');

    searchResult.getTypes().forEach((type) => {
      const interfaces = type.getInterfaces().map((i) => i.name);
      expect(interfaces).toContain('Node');
    });
  });
});

The exact-match approach on enum values is intentional. If someone adds REFUNDED to the enum, tests fail. That forces a conversation: is the resolver handling REFUNDED? Is the client? Adding enum values silently is how production bugs appear.

Schema-First vs Code-First Validation

Schema-first (SDL defines the contract, resolvers implement it): Validation is straightforward. The SDL file is the source of truth. graphql-eslint, graphql-inspector, and validateSchema all operate against it directly.

Code-first (TypeScript/Python code generates the schema): The schema is a build artifact. You need an extra step to catch drift between the generated schema and what you expect.

For code-first schemas with TypeGraphQL or Pothos:

// Generate schema and compare against committed SDL
import { buildSchemaSync } from 'type-graphql';
import { printSchema, lexicographicSortSchema } from 'graphql';
import { readFileSync, writeFileSync } from 'fs';

const schema = buildSchemaSync({
  resolvers: [UserResolver, ProductResolver, OrderResolver],
});

const generated = printSchema(lexicographicSortSchema(schema));
const committed = readFileSync('./schema.generated.graphql', 'utf8');

if (generated !== committed) {
  // In CI: fail. Locally: update the file.
  if (process.env.CI) {
    console.error('Generated schema differs from committed schema.');
    console.error('Run `npm run generate:schema` and commit the result.');
    process.exit(1);
  } else {
    writeFileSync('./schema.generated.graphql', generated);
    console.log('Schema updated.');
  }
}

This pattern — generate, compare, fail on diff — ensures the committed SDL is always in sync with the code that generates it. It also means the committed SDL can be used by graphql-inspector for breaking change detection, giving you the same CI guarantees as schema-first teams.

lexicographicSortSchema is important here: without it, field ordering differences in the generated output cause false positives on every run.

Putting It Together in CI

A complete schema validation pipeline runs in order:

  1. Lint (fast, catches structural issues) — graphql-eslint
  2. Build and validate — buildSchema + validateSchema
  3. Breaking change check — graphql-inspector against base branch
  4. Operation validation — validate all client operations against the new schema
# schema-validate job in CI
steps:
  - name: Lint SDL
    run: npx eslint --config .eslintrc.graphql.js '**/*.graphql'

  - name: Validate schema
    run: node scripts/validate-schema.js

  - name: Check breaking changes
    run: node scripts/check-schema-changes.js --old /tmp/base.graphql --new schema.graphql

  - name: Validate client operations
    run: node scripts/validate-operations.js

Each step is a hard gate. A breaking change does not get merged because someone forgot to check — the pipeline blocks it.

Key Takeaways

  • Schema linting is table stakes. graphql-eslint with naming conventions, description requirements, and field validation catches the majority of schema quality issues before review.
  • Breaking change detection must run in CI, not as a manual step. @graphql-inspector/core gives you programmatic access to change severity. Gate merges on CriticalityLevel.Breaking.
  • Custom scalars need unit tests separate from the GraphQL layer. Test serialize, parseValue, and parseLiteral independently with invalid inputs, boundary values, and type coercion edge cases.
  • Enum value changes are breaking changes that graphql-inspector won't catch for clients. Adding a value is non-breaking for the schema but breaking for exhaustive switch statements in client code. Document this explicitly.
  • Code-first teams need schema snapshot testing. Generate the SDL, commit it, diff it in CI. Without this, the committed schema diverges from reality and none of your SDL-based tooling is trustworthy.
  • validateSchema is not the same as buildSchema. buildSchema catches parse errors. validateSchema catches semantic errors — undefined type references, broken interface implementations, invalid union members. Run both.

The GraphQL type system gives you more static guarantees than REST ever will. The tooling exists to enforce those guarantees automatically. The only reason schema bugs reach production is that teams haven't wired up the checks.

Read more

Start now free