GraphQL Schema Validation Testing: Catch Breaking Changes Before They Ship

GraphQL Schema Validation Testing: Catch Breaking Changes Before They Ship

GraphQL schemas are contracts between the server and every client. A breaking change — removing a field, changing a type, making a nullable field required — silently breaks clients until they fail in production. Schema validation testing catches these changes before they ship, using static analysis, schema diffing tools, and automated contract tests.

Key Takeaways

Schema changes are breaking changes. Removing a field or changing its type breaks every client that uses it. Test before merging.

Use graphql-inspector for automated schema diffing. It compares two schema versions and flags breaking vs. dangerous vs. safe changes.

Test custom scalars explicitly. GraphQL doesn't validate scalar values by default — test your custom scalar serialization and parsing logic separately.

Schema-first means schema tests first. If you define your schema before resolvers (SDL-first development), write schema validation tests as your first test layer.

Run schema checks in CI. Schema diffing should be a required CI step, not a manual review. Automate it.

Why Schema Validation Matters

Your GraphQL schema is a public API contract. Every client — React app, mobile app, third-party integration — depends on it. When you remove a field named userEmail and rename it email, the mobile app that still queries userEmail breaks silently at runtime. The 200 OK response now contains null instead of a value, and errors surface in production.

Schema validation testing is the safety layer between schema changes and broken clients.

Parsing and Validating Schema Syntax

The simplest test: does the schema parse without errors?

const { buildSchema, validateSchema, parse } = require('graphql');
const fs = require('fs');

describe('Schema syntax', () => {
  it('parses without errors', () => {
    const sdl = fs.readFileSync('./schema.graphql', 'utf8');
    
    expect(() => buildSchema(sdl)).not.toThrow();
  });

  it('passes GraphQL validation rules', () => {
    const sdl = fs.readFileSync('./schema.graphql', 'utf8');
    const schema = buildSchema(sdl);
    const errors = validateSchema(schema);

    expect(errors).toHaveLength(0);
  });
});

Type-Level Schema Tests

Test that your schema contains the types, fields, and arguments you expect:

const { buildSchema, GraphQLObjectType, GraphQLNonNull, GraphQLString } = require('graphql');

describe('Schema structure', () => {
  let schema;

  beforeAll(() => {
    schema = buildSchema(fs.readFileSync('./schema.graphql', 'utf8'));
  });

  describe('User type', () => {
    it('exists', () => {
      expect(schema.getType('User')).toBeDefined();
    });

    it('has required fields', () => {
      const userType = schema.getType('User');
      const fields = userType.getFields();

      expect(fields.id).toBeDefined();
      expect(fields.name).toBeDefined();
      expect(fields.email).toBeDefined();
    });

    it('id field is non-null', () => {
      const userType = schema.getType('User');
      const idField = userType.getFields().id;

      expect(idField.type.toString()).toBe('ID!');
    });
  });

  describe('Query type', () => {
    it('has user query with id argument', () => {
      const queryType = schema.getQueryType();
      const userQuery = queryType.getFields().user;

      expect(userQuery).toBeDefined();
      expect(userQuery.args.find(a => a.name === 'id')).toBeDefined();
    });
  });
});

Schema Diffing with graphql-inspector

graphql-inspector compares two schema versions and classifies every change:

npm install --save-dev @graphql-inspector/core
const { diff, CriticalityLevel } = require('@graphql-inspector/core');
const { buildSchema } = require('graphql');
const fs = require('fs');

describe('Schema breaking change detection', () => {
  it('has no breaking changes against previous version', async () => {
    const oldSchema = buildSchema(
      fs.readFileSync('./schema.previous.graphql', 'utf8')
    );
    const newSchema = buildSchema(
      fs.readFileSync('./schema.graphql', 'utf8')
    );

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

    if (breakingChanges.length > 0) {
      const messages = breakingChanges.map(c => `  - ${c.message}`).join('\n');
      throw new Error(`Breaking schema changes detected:\n${messages}`);
    }

    expect(breakingChanges).toHaveLength(0);
  });

  it('documents dangerous changes', async () => {
    const oldSchema = buildSchema(fs.readFileSync('./schema.previous.graphql', 'utf8'));
    const newSchema = buildSchema(fs.readFileSync('./schema.graphql', 'utf8'));

    const changes = await diff(oldSchema, newSchema);
    const dangerousChanges = changes.filter(
      c => c.criticality.level === CriticalityLevel.Dangerous
    );

    // Log but don't fail — dangerous changes need human review
    if (dangerousChanges.length > 0) {
      console.warn('Dangerous schema changes requiring review:');
      dangerousChanges.forEach(c => console.warn(`  ${c.message}`));
    }
  });
});

Testing Custom Scalars

Custom scalars (Date, DateTime, JSON, Email) need explicit testing because GraphQL doesn't validate them:

// scalars.js
const { GraphQLScalarType, Kind } = require('graphql');

const DateScalar = new GraphQLScalarType({
  name: 'Date',
  description: 'Date in ISO 8601 format (YYYY-MM-DD)',

  serialize(value) {
    if (value instanceof Date) {
      return value.toISOString().split('T')[0];
    }
    throw new Error('Date scalar can only serialize Date objects');
  },

  parseValue(value) {
    const date = new Date(value);
    if (isNaN(date.getTime())) {
      throw new Error(`Invalid date string: ${value}`);
    }
    return date;
  },

  parseLiteral(ast) {
    if (ast.kind !== Kind.STRING) {
      throw new Error('Date scalar expects a string literal');
    }
    const date = new Date(ast.value);
    if (isNaN(date.getTime())) {
      throw new Error(`Invalid date string: ${ast.value}`);
    }
    return date;
  },
});
const { DateScalar } = require('./scalars');

describe('DateScalar', () => {
  describe('serialize', () => {
    it('serializes Date object to ISO string', () => {
      const date = new Date('2026-05-31');
      expect(DateScalar.serialize(date)).toBe('2026-05-31');
    });

    it('throws for non-Date values', () => {
      expect(() => DateScalar.serialize('2026-05-31')).toThrow();
      expect(() => DateScalar.serialize(12345)).toThrow();
    });
  });

  describe('parseValue', () => {
    it('parses valid date strings from variables', () => {
      const result = DateScalar.parseValue('2026-05-31');
      expect(result).toBeInstanceOf(Date);
      expect(result.getFullYear()).toBe(2026);
    });

    it('throws for invalid date strings', () => {
      expect(() => DateScalar.parseValue('not-a-date')).toThrow(/Invalid date/);
      expect(() => DateScalar.parseValue('')).toThrow();
    });
  });

  describe('parseLiteral', () => {
    it('parses string literals in query documents', () => {
      const ast = { kind: 'StringValue', value: '2026-05-31' };
      const result = DateScalar.parseLiteral(ast);
      expect(result).toBeInstanceOf(Date);
    });

    it('throws for non-string literals', () => {
      const ast = { kind: 'IntValue', value: '20260531' };
      expect(() => DateScalar.parseLiteral(ast)).toThrow();
    });
  });
});

Enum Validation Testing

describe('Status enum', () => {
  it('contains expected values', () => {
    const statusEnum = schema.getType('UserStatus');
    const values = Object.keys(statusEnum.getValues());

    expect(values).toContain('ACTIVE');
    expect(values).toContain('INACTIVE');
    expect(values).toContain('BANNED');
    expect(values).not.toContain('DELETED'); // Removed field should not reappear
  });
});

CI Integration with graphql-inspector CLI

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

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

      - name: Fetch base schema
        run: git show origin/main:schema.graphql > schema.previous.graphql

      - name: Run schema diff
        run: |
          npx @graphql-inspector/cli diff schema.previous.graphql schema.graphql

This blocks PRs that introduce breaking changes without explicit override.

Contract Testing with Persisted Queries

If clients use persisted queries (hashed operation documents stored server-side), test that stored queries remain valid against the current schema:

const { validate, parse } = require('graphql');
const persistedQueries = require('./persisted-queries.json');

describe('Persisted query compatibility', () => {
  Object.entries(persistedQueries).forEach(([hash, queryDoc]) => {
    it(`persisted query ${hash.slice(0, 8)} is valid against current schema`, () => {
      const document = parse(queryDoc);
      const errors = validate(schema, document);

      expect(errors).toHaveLength(0);
    });
  });
});

Monitoring Schema Health

HelpMeTest can monitor your GraphQL endpoint continuously — not just syntax validity but real operation success rates, latency per query type, and error rates by field.

Monitor your GraphQL API health →

Read more

Start now free