Siddhant Deval
Siddhant Deval
backend19 min read

Testing GraphQL APIs: Resolvers, Integration & Schema Contracts

The correct test boundary for a GraphQL API is the execution layer — schema + resolvers + context — not the HTTP transport. Testing resolvers in isolation misses the execution chain, and testing via HTTP is too slow and coupled to transport.

Series·Part 8 of 8

GraphQL Backend & API Design

Testing GraphQL APIs: Resolvers, Integration & Schema Contracts

You own the schema and the resolvers — the schema is a public contract you can never silently break, and every resolver is a performance commitment you make on every query. Testing verifies both halves of that statement: that the resolver implementation is correct (unit and integration tests), and that the schema has not changed in a way that breaks existing client contracts (schema contract tests). The most dangerous test architecture for GraphQL is the one that tests each resolver in isolation: your resolver passes every unit test and still fails in integration because the parent resolver returned an unexpected shape, because the DataLoader was called with the wrong key type, or because a schema change silently changed the type of a field the resolver assumed was non-null.

Architectural Note

This is Part 8 (final) of the GraphQL Backend & API Design series. It applies the schema design from Part 1, the DataLoader context from Part 2, and the breaking change detection from Part 7 to build a testing strategy that catches real failures.


1. The GraphQL Testing Pyramid

The key principle: unit tests alone are insufficient for GraphQL. The resolver chain failure mode — where individual resolvers pass in isolation but the execution chain fails — is only caught by integration testing at the graphql() execute level.


2. Unit Testing Resolvers with Mocked Context

Unit tests validate the logic inside a single resolver function with all dependencies mocked.

TYPESCRIPT
// ❌ The test boundary problem — this test proves nothing about the full execution
// The parent resolver may return a different shape than { id: string }
import { resolvers } from './resolvers';

describe('User.orders resolver (unit)', () => {
  it('loads orders for a user', async () => {
    const mockContext = {
      ordersLoader: { load: jest.fn().mockResolvedValue([{ id: 'o1', total: 99 }]) },
    };
    const result = await resolvers.User.orders({ id: 'u1' }, {}, mockContext, {} as any);
    expect(result).toHaveLength(1);
    // ✅ This test passes. But what if the actual parent resolver returns { userId: 'u1' }
    // instead of { id: 'u1' }? The DataLoader gets 'undefined' as the key — silent failure.
  });
});
TYPESCRIPT
// ✅ Correct resolver unit test — verify the exact shape contract with the parent
import { resolvers } from './resolvers';
import { GraphQLResolveInfo } from 'graphql';

describe('User.orders resolver (unit)', () => {
  const mockInfo = {} as GraphQLResolveInfo;

  it('calls ordersLoader with user.id (string, not number)', async () => {
    const mockLoad = jest.fn().mockResolvedValue([{ id: 'o1', total: 99.00 }]);
    const ctx = { ordersLoader: { load: mockLoad } };

    // The 'parent' object is exactly what the parent resolver returns
    // Test the exact shape your schema produces, not a generic object
    await resolvers.User.orders({ id: 'u1', name: 'Alice', email: 'alice@example.com' }, {}, ctx, mockInfo);

    // Verify the key type is string, not number — DataLoader is type-sensitive
    expect(mockLoad).toHaveBeenCalledWith('u1');
    expect(typeof mockLoad.mock.calls[0][0]).toBe('string');
  });

  it('returns empty array (not null) when user has no orders', async () => {
    const ctx = { ordersLoader: { load: jest.fn().mockResolvedValue([]) } };
    const result = await resolvers.User.orders({ id: 'u2', name: 'Bob', email: '' }, {}, ctx, mockInfo);

    // ✅ Verify [] not null — the client schema says [Order!]!, null propagation
    expect(result).toEqual([]);
    expect(result).not.toBeNull();
  });
});

3. Integration Testing via graphql() Execute

Integration tests execute the full resolver chain through the GraphQL engine without HTTP overhead. This is the tier that catches resolver chain failures.

TYPESCRIPT
// ✅ Integration test using graphql() execute — no HTTP, full resolver chain
import { graphql, buildSchema } from 'graphql';
import { makeExecutableSchema } from '@graphql-tools/schema';
import { resolvers } from './resolvers';
import { typeDefs } from './schema';

// Build the schema once for the test suite
const schema = makeExecutableSchema({ typeDefs, resolvers });

describe('GetUserWithOrders (integration)', () => {
  // Create fresh DataLoader mocks per test — same as per-request context
  function createTestContext() {
    return {
      user: { id: 'u1', canViewOrder: () => true },
      ordersLoader: {
        load: jest.fn().mockResolvedValue([
          { id: 'o1', total: 99.00, status: 'SHIPPED', items: [] },
        ]),
      },
      db: {
        users: {
          findById: jest.fn().mockResolvedValue({ id: 'u1', name: 'Alice', email: 'alice@example.com' }),
        },
      },
    };
  }

  it('returns user name and orders in a single execution', async () => {
    const ctx = createTestContext();

    // graphql() executes the full resolver chain:
    // Query.user → User.name → User.orders → Order.total
    // No HTTP, no network — but the complete execution graph is exercised
    const result = await graphql({
      schema,
      source: `
        query GetUserWithOrders($id: ID!) {
          user(id: $id) {
            name
            orders {
              total
              status
            }
          }
        }
      `,
      contextValue: ctx,
      variableValues: { id: 'u1' },
    });

    // Verify no errors in the execution
    expect(result.errors).toBeUndefined();
    expect(result.data?.user).toEqual({
      name: 'Alice',
      orders: [{ total: 99.00, status: 'SHIPPED' }],
    });

    // Verify DataLoader was called with the correct key type
    expect(ctx.ordersLoader.load).toHaveBeenCalledWith('u1');
  });

  it('returns null for user field when user does not exist', async () => {
    const ctx = createTestContext();
    ctx.db.users.findById = jest.fn().mockResolvedValue(null);

    const result = await graphql({
      schema,
      source: `query { user(id: "nonexistent") { name } }`,
      contextValue: ctx,
    });

    // Null user → null propagation, no errors array entry
    expect(result.errors).toBeUndefined();
    expect(result.data?.user).toBeNull();
  });

  it('returns errors array when authentication fails', async () => {
    const ctx = { ...createTestContext(), user: null }; // unauthenticated

    const result = await graphql({
      schema,
      source: `query GetUser($id: ID!) { user(id: $id) { name } }`,
      contextValue: ctx,
      variableValues: { id: 'u1' },
    });

    expect(result.errors).toHaveLength(1);
    expect(result.errors?.[0].extensions?.code).toBe('UNAUTHENTICATED');
  });
});
Crucial Requirement

What graphql() execute adds versus unit testing: the GraphQL execution engine handles field resolution order, parent object passing, null propagation, and error collection. A resolver that returns the wrong type for a non-null field produces an error in graphql() execute that a unit test would never catch. This tier catches the "individual parts work, system is broken" failure class.


4. Schema Snapshot & Contract Testing

Schema contract tests validate that your schema has not changed in a breaking way relative to client operation documents.

TYPESCRIPT
// ✅ Schema snapshot test — fails if the SDL changes unexpectedly
import { printSchema, buildSchema } from 'graphql';
import { schema } from './schema';

describe('Schema Contract (snapshot)', () => {
  it('schema SDL has not changed unexpectedly', () => {
    // Any change to the schema — adding, removing, or modifying a type or field —
    // will cause this test to fail, forcing a conscious review and snapshot update
    expect(printSchema(schema)).toMatchSnapshot();
  });
});
TYPESCRIPT
// ✅ Operation document validation — client operation documents must be valid against schema
import { parse, validate } from 'graphql';
import { schema } from './schema';
import { readFileSync, readdirSync } from 'fs';
import { join } from 'path';

describe('Client Operation Contracts', () => {
  const operationsDir = join(__dirname, '../operations');

  it('all client operation documents are valid against the current schema', () => {
    const files = readdirSync(operationsDir).filter(f => f.endsWith('.graphql'));

    for (const file of files) {
      const source = readFileSync(join(operationsDir, file), 'utf8');
      const document = parse(source);
      const errors = validate(schema, document);

      // Each file is expected to have zero validation errors
      expect(errors).toHaveLength(0);
      // If this fails: a schema change broke a client operation document —
      // the same class of error that rover graph check catches in CI
    }
  });
});

5. Testing Subscriptions with Mocked AsyncIterators

TYPESCRIPT
// ✅ Subscription resolver testing with mocked AsyncIterator
import { createAsyncIterator } from 'iterall';

describe('orderStatusChanged subscription', () => {
  it('delivers the resolved order when the PubSub emits', async () => {
    const mockOrder = { id: 'o1', status: 'SHIPPED', total: 99 };

    // Create a mock AsyncIterator that yields one event and then completes
    const mockPubSub = {
      subscribe: jest.fn().mockReturnValue(
        createAsyncIterator([{ orderStatusChanged: mockOrder }])
      ),
    };

    const ctx = { pubsub: mockPubSub, user: { canViewOrder: () => true } };

    // Test the subscribe function returns the expected iterator
    const iterator = await resolvers.Subscription.orderStatusChanged.subscribe(
      undefined,
      { orderId: 'o1' },
      ctx,
      {} as any
    );

    // Consume one value from the iterator
    const { value, done } = await iterator.next();
    expect(value).toEqual({ orderStatusChanged: mockOrder });

    // Test the resolve function transforms the payload
    const resolved = resolvers.Subscription.orderStatusChanged.resolve(value, {}, ctx, {} as any);
    expect(resolved).toEqual(mockOrder);
  });
});

6. graphql-yoga v5 Test Utilities

graphql-yoga v5 provides buildHTTPExecutor — a test utility that creates a fetch-based executor against the yoga instance without starting a real HTTP server:

TYPESCRIPT
// ✅ graphql-yoga v5 buildHTTPExecutor — full HTTP stack without a running server
import { createYoga } from 'graphql-yoga';
import { buildHTTPExecutor } from '@graphql-tools/executor-http';
import { schema } from './schema';

describe('HTTP integration (graphql-yoga)', () => {
  const yoga = createYoga({ schema });
  const executor = buildHTTPExecutor({ fetch: yoga.fetch });

  it('returns user data via HTTP executor', async () => {
    const result = await executor({
      document: parse(`query GetUser($id: ID!) { user(id: $id) { name } }`),
      variables: { id: 'u1' },
    });

    // The executor handles the full yoga request/response cycle
    // including content-type negotiation and error serialization
    expect(result.data?.user?.name).toBe('Alice');
  });
});

Testing pyramid: E2E HTTP at top, execution integration in middle, schema contracts as CI gate, resolver unit tests at base with annotations of what each tier catches
Testing pyramid: E2E HTTP at top, execution integration in middle, schema contracts as CI gate, resolver unit tests at base with annotations of what each tie…

The four-tier testing pyramid: resolver unit tests catch logic errors in isolation, execution integration tests via graphql() catch resolver chain failures and null propagation, schema contract tests catch breaking SDL changes, and E2E HTTP tests catch transport-layer issues.

Flow Trace: graphql(schema, query, null, context, variables) integration test setup with annotations of what HTTP adds and why it's not needed at this tier
Flow Trace: graphql(schema, query, null, context, variables) integration test setup with annotations of what HTTP adds and why it's not needed at this tier

The graphql() execute function runs the complete resolver chain — field ordering, null propagation, error collection — without HTTP overhead. Add HTTP only when testing transport-specific behavior like multipart uploads, persisted operation IDs, or SSE.


Summary

Concept Rule
Resolver unit tests Test one resolver in isolation with a mocked context. Verify the exact key type passed to DataLoader (string, not number) and the return shape contract with the parent resolver.
Integration via graphql() Execute the full resolver chain through the GraphQL engine — no HTTP. This tier catches null propagation chains, DataLoader key mismatches, and resolver ordering failures that unit tests miss.
Schema snapshot tests expect(printSchema(schema)).toMatchSnapshot() fails on any SDL change, forcing a conscious review of every schema modification before it can be merged.
Operation document validation validate(schema, parse(operationDocument)) against all client .graphql files catches breaking SDL changes before CI — the same class of error as rover graph check.
Subscription testing Test subscribe() returns an AsyncIterator and resolve() transforms the payload correctly using mocked AsyncIterator values — no live PubSub broker required.

This concludes the GraphQL Backend & API Design series. The eight parts form a complete contract: the SDL is a domain contract (Part 1), every resolver is a performance commitment implemented correctly via DataLoader (Part 2), the subgraph boundary is a team boundary (Part 3), the subscription system scales horizontally (Part 4), every operation is measurable (Part 5), the schema has a defined attack surface defense (Part 6), every field has a lifecycle (Part 7), and every contract is verified by tests (Part 8).

Research & Synthesis Note

This article was developed with AI-assisted deep search, specification cross-referencing, and technical research synthesis.

#GraphQL#Testing#Integration Testing#Contract Testing#TypeScript
Siddhant Deval

Written by Siddhant Deval

Senior Full-Stack Engineer building high-scale architectures, browser performance engineering systems, and SaaS platforms.