Skip to content
Codeloom
GraphQL

Testing Strategies for GraphQL APIs

Learn how to test GraphQL resolvers, queries, and mutations with unit tests, integration tests, and end-to-end schema validation.

·8 min read · By Codeloom
Intermediate 12 min read

What you'll learn

  • How to unit test individual resolvers in isolation
  • How to write integration tests that execute full GraphQL operations
  • How to validate schema changes and test error handling paths

Prerequisites

  • Basic GraphQL knowledge
  • Familiarity with Jest or a similar test framework

Why Testing GraphQL Is Different

In REST APIs, testing a single endpoint is straightforward: send a request, check the response. GraphQL is more nuanced because a single endpoint serves unlimited query shapes. The same resolver might be called from dozens of different queries. You need a testing strategy that covers resolvers, operations, schema correctness, and error handling.

Testing Layers

A solid GraphQL testing strategy has three layers:

  1. Unit tests for individual resolver functions.
  2. Integration tests that execute full GraphQL operations against your schema.
  3. Schema validation tests that catch breaking changes.

Unit Testing Resolvers

Resolvers are plain functions. Test them in isolation by calling them directly with mocked arguments and context:

// resolvers/user.js
export const userResolvers = {
  Query: {
    user: async (_, { id }, context) => {
      const user = await context.db.users.findById(id);
      if (!user) {
        throw new GraphQLError('User not found', {
          extensions: { code: 'NOT_FOUND' },
        });
      }
      return user;
    },
  },
  User: {
    posts: async (parent, _, context) => {
      return context.db.posts.findByAuthorId(parent.id);
    },
  },
};
// __tests__/resolvers/user.test.js
import { userResolvers } from '../resolvers/user';

describe('User resolvers', () => {
  describe('Query.user', () => {
    it('returns a user by id', async () => {
      const mockUser = { id: '1', name: 'Alice', email: 'alice@example.com' };
      const context = {
        db: {
          users: {
            findById: jest.fn().mockResolvedValue(mockUser),
          },
        },
      };

      const result = await userResolvers.Query.user(
        null,
        { id: '1' },
        context
      );

      expect(result).toEqual(mockUser);
      expect(context.db.users.findById).toHaveBeenCalledWith('1');
    });

    it('throws NOT_FOUND when user does not exist', async () => {
      const context = {
        db: {
          users: {
            findById: jest.fn().mockResolvedValue(null),
          },
        },
      };

      await expect(
        userResolvers.Query.user(null, { id: '999' }, context)
      ).rejects.toThrow('User not found');
    });
  });

  describe('User.posts', () => {
    it('returns posts for a given user', async () => {
      const mockPosts = [
        { id: '1', title: 'Post 1' },
        { id: '2', title: 'Post 2' },
      ];
      const context = {
        db: {
          posts: {
            findByAuthorId: jest.fn().mockResolvedValue(mockPosts),
          },
        },
      };

      const parent = { id: 'user-1' };
      const result = await userResolvers.User.posts(parent, {}, context);

      expect(result).toEqual(mockPosts);
      expect(context.db.posts.findByAuthorId).toHaveBeenCalledWith('user-1');
    });
  });
});

Unit tests are fast and verify business logic, but they do not validate that your resolvers integrate correctly with the schema.

Integration Testing with Apollo Server

Integration tests execute actual GraphQL operations against your fully constructed server. This validates that types, resolvers, and the schema work together:

import { ApolloServer } from '@apollo/server';
import { makeExecutableSchema } from '@graphql-tools/schema';
import assert from 'assert';

// Build the complete schema
const schema = makeExecutableSchema({ typeDefs, resolvers });

describe('GraphQL Integration Tests', () => {
  let server;

  beforeAll(() => {
    server = new ApolloServer({ schema });
  });

  it('fetches a user with their posts', async () => {
    const response = await server.executeOperation({
      query: `
        query GetUser($id: ID!) {
          user(id: $id) {
            id
            name
            posts {
              title
            }
          }
        }
      `,
      variables: { id: '1' },
    });

    assert(response.body.kind === 'single');
    expect(response.body.singleResult.errors).toBeUndefined();
    expect(response.body.singleResult.data?.user).toEqual({
      id: '1',
      name: 'Alice',
      posts: [
        { title: 'First Post' },
        { title: 'Second Post' },
      ],
    });
  });

  it('returns an error for non-existent user', async () => {
    const response = await server.executeOperation({
      query: `
        query GetUser($id: ID!) {
          user(id: $id) {
            id
            name
          }
        }
      `,
      variables: { id: '999' },
    });

    assert(response.body.kind === 'single');
    const errors = response.body.singleResult.errors;
    expect(errors).toHaveLength(1);
    expect(errors[0].extensions.code).toBe('NOT_FOUND');
  });
});

Mocking the Database Layer

For integration tests, mock the database so tests are deterministic and fast:

import { ApolloServer } from '@apollo/server';

function createTestServer(mocks = {}) {
  const defaultMocks = {
    users: {
      findById: jest.fn().mockResolvedValue({
        id: '1',
        name: 'Alice',
        email: 'alice@example.com',
      }),
      findAll: jest.fn().mockResolvedValue([]),
    },
    posts: {
      findByAuthorId: jest.fn().mockResolvedValue([]),
    },
  };

  const db = { ...defaultMocks, ...mocks };

  return new ApolloServer({
    schema,
    context: () => ({ db, user: { id: '1', role: 'USER' } }),
  });
}

// Usage
it('handles empty post list', async () => {
  const server = createTestServer({
    posts: {
      findByAuthorId: jest.fn().mockResolvedValue([]),
    },
  });

  const response = await server.executeOperation({
    query: `query { user(id: "1") { posts { title } } }`,
  });

  assert(response.body.kind === 'single');
  expect(response.body.singleResult.data?.user.posts).toEqual([]);
});

Testing Mutations

Mutations require verifying both the response and the side effects:

describe('Mutations', () => {
  it('creates a new post', async () => {
    const mockCreate = jest.fn().mockResolvedValue({
      id: '10',
      title: 'New Post',
      content: 'Content here',
    });

    const server = createTestServer({
      posts: { create: mockCreate },
    });

    const response = await server.executeOperation({
      query: `
        mutation CreatePost($input: CreatePostInput!) {
          createPost(input: $input) {
            id
            title
            content
          }
        }
      `,
      variables: {
        input: {
          title: 'New Post',
          content: 'Content here',
        },
      },
    });

    assert(response.body.kind === 'single');
    expect(response.body.singleResult.data?.createPost).toEqual({
      id: '10',
      title: 'New Post',
      content: 'Content here',
    });

    // Verify the side effect
    expect(mockCreate).toHaveBeenCalledWith({
      title: 'New Post',
      content: 'Content here',
      authorId: '1', // from context.user.id
    });
  });
});

Testing Authentication and Authorization

Test that protected fields and operations enforce auth requirements:

describe('Authorization', () => {
  it('rejects unauthenticated requests to protected fields', async () => {
    const server = new ApolloServer({ schema });

    const response = await server.executeOperation(
      {
        query: `query { me { name email } }`,
      },
      {
        contextValue: { user: null },
      }
    );

    assert(response.body.kind === 'single');
    const errors = response.body.singleResult.errors;
    expect(errors[0].extensions.code).toBe('UNAUTHENTICATED');
  });

  it('rejects non-admin users from admin queries', async () => {
    const server = new ApolloServer({ schema });

    const response = await server.executeOperation(
      {
        query: `query { users { id name } }`,
      },
      {
        contextValue: { user: { id: '1', role: 'USER' } },
      }
    );

    assert(response.body.kind === 'single');
    const errors = response.body.singleResult.errors;
    expect(errors[0].extensions.code).toBe('FORBIDDEN');
  });

  it('allows admin users to access admin queries', async () => {
    const server = new ApolloServer({ schema });

    const response = await server.executeOperation(
      {
        query: `query { users { id name } }`,
      },
      {
        contextValue: {
          user: { id: '1', role: 'ADMIN' },
          db: mockDb,
        },
      }
    );

    assert(response.body.kind === 'single');
    expect(response.body.singleResult.errors).toBeUndefined();
  });
});

Schema Validation Tests

Catch accidental breaking changes by testing schema structure:

import { buildSchema, findBreakingChanges } from 'graphql';
import fs from 'fs';

describe('Schema validation', () => {
  it('has no breaking changes from the previous version', () => {
    const previousSchema = buildSchema(
      fs.readFileSync('./schema-snapshot.graphql', 'utf-8')
    );
    const currentSchema = buildSchema(
      fs.readFileSync('./schema.graphql', 'utf-8')
    );

    const breakingChanges = findBreakingChanges(previousSchema, currentSchema);

    if (breakingChanges.length > 0) {
      const messages = breakingChanges
        .map(c => `${c.type}: ${c.description}`)
        .join('\n');
      fail(`Breaking schema changes detected:\n${messages}`);
    }
  });
});

Use findBreakingChanges from the graphql package to compare the current schema against a snapshot. Update the snapshot intentionally when breaking changes are expected.

Testing Subscriptions

Subscriptions are trickier to test because they involve asynchronous event streams:

import { createServer } from 'http';
import { WebSocketServer } from 'ws';
import { createClient } from 'graphql-ws';

describe('Subscriptions', () => {
  let httpServer, wsServer, client;

  beforeAll(async () => {
    httpServer = createServer();
    wsServer = new WebSocketServer({ server: httpServer, path: '/graphql' });
    // Set up graphql-ws server...

    await new Promise(resolve => httpServer.listen(0, resolve));
    const port = httpServer.address().port;

    client = createClient({
      url: `ws://localhost:${port}/graphql`,
    });
  });

  afterAll(() => {
    client.dispose();
    httpServer.close();
  });

  it('receives new messages', async () => {
    const messages = [];

    const subscription = new Promise((resolve, reject) => {
      client.subscribe(
        {
          query: `
            subscription {
              messageSent(channelId: "general") {
                text
                sender { name }
              }
            }
          `,
        },
        {
          next: (data) => {
            messages.push(data);
            if (messages.length === 1) resolve(messages);
          },
          error: reject,
          complete: () => {},
        }
      );
    });

    // Trigger the event
    await pubsub.publish('MESSAGE_SENT', {
      messageSent: { text: 'Hello', sender: { name: 'Alice' } },
      channelId: 'general',
    });

    const result = await subscription;
    expect(result[0].data.messageSent.text).toBe('Hello');
  });
});

Test Organization

Structure your test files to mirror your source code:

src/
  resolvers/
    user.js
    post.js
  __tests__/
    unit/
      user.test.js
      post.test.js
    integration/
      queries.test.js
      mutations.test.js
      auth.test.js
    schema/
      validation.test.js

Run unit tests frequently during development. Run integration tests before pushing. Run schema validation tests in CI to catch breaking changes.

Summary

Test GraphQL APIs at multiple levels. Unit test resolvers as plain functions with mocked context and arguments. Integration test full operations using Apollo Server’s executeOperation to verify types, resolvers, and schema work together. Test auth by varying the context user. Validate schema changes against snapshots to catch breaking changes before deployment. This layered approach gives you fast feedback from unit tests and confidence from integration tests that your entire GraphQL stack works correctly.