GraphQL Testing Guide: Unit, Integration, and E2E Strategies

GraphQL Testing Guide: Unit, Integration, and E2E Strategies

Testing GraphQL APIs is fundamentally different from testing REST. With REST, each endpoint has a specific contract. With GraphQL, a single endpoint handles arbitrary queries, and the surface area is determined by your schema. A change to a resolver can affect dozens of queries the client might send.

This guide covers the full testing pyramid for GraphQL: what to test at each level, what tools to use, and how to structure your test suite so it stays maintainable as your schema grows.

Why GraphQL Testing Is Different

REST testing maps naturally to HTTP verbs and endpoints. GraphQL has one endpoint. The variation is in the query structure, not the URL.

This creates unique challenges:

Schema changes affect many queries: Adding a field is safe; removing or renaming one is a breaking change for any client using it. You need to test across the schema, not just individual endpoints.

Resolvers can be deeply nested: A query that fetches user { orders { products { reviews } } } touches four resolvers. Testing the full chain requires careful fixture setup.

N+1 queries: Without DataLoader, resolvers naively fire one query per item. This is a performance bug that only surfaces under load — easy to miss in unit tests.

Partial errors: GraphQL returns 200 even when parts of the response fail. A resolver that throws returns an error in the errors array alongside a partially-filled data object. Tests that only check the HTTP status miss this.

The GraphQL Testing Pyramid

The pyramid works for GraphQL, with some adjustments:

Layer 1: Unit Tests (Resolver Logic)

Test each resolver in isolation. Mock the data sources (database, external APIs). Verify that the resolver returns the right shape of data given specific inputs.

These tests are fast and catch logic bugs. They don't verify schema wiring or resolver composition.

Coverage target: All resolvers with non-trivial logic. Pure passthrough resolvers (field resolvers that just return parent.field) don't need unit tests.

Layer 2: Integration Tests (Full Operation)

Execute real GraphQL operations against an in-memory Apollo Server instance (or equivalent). Use a real database (in-memory or test database) or realistic fakes.

These tests verify that your schema, resolvers, and context work together correctly. They catch field mapping errors, context injection issues, and resolver composition bugs.

Coverage target: Every query and mutation type, key error paths, authorization rules.

Layer 3: E2E Tests (Client Perspective)

Execute queries from a real client against the full running API. For web apps, use Playwright or Cypress to intercept or execute GraphQL queries. For mobile apps, use Detox.

These tests verify the complete stack: HTTP transport, authentication middleware, resolvers, database, and response parsing.

Coverage target: Critical user journeys only. Keep this layer thin.

Unit Testing Resolvers

A resolver is a function. Test it like any other function.

// resolvers/User.js
export const UserResolvers = {
  Query: {
    user: async (_, { id }, { dataSources }) => {
      const user = await dataSources.users.findById(id);
      if (!user) throw new GraphQLError('User not found', { extensions: { code: 'NOT_FOUND' } });
      return user;
    },
    users: async (_, { limit = 10, offset = 0 }, { dataSources }) => {
      return dataSources.users.findAll({ limit, offset });
    },
  },
  User: {
    fullName: ({ firstName, lastName }) => `${firstName} ${lastName}`,
    orders: async ({ id }, _, { dataSources }) => {
      return dataSources.orders.findByUserId(id);
    },
  },
};
// resolvers/User.test.js
import { GraphQLError } from 'graphql';
import { UserResolvers } from './User';

describe('Query.user', () => {
  const resolver = UserResolvers.Query.user;
  const mockDataSources = {
    users: {
      findById: jest.fn(),
    },
  };

  beforeEach(() => jest.clearAllMocks());

  it('returns user when found', async () => {
    const mockUser = { id: '1', firstName: 'Alice', lastName: 'Smith' };
    mockDataSources.users.findById.mockResolvedValue(mockUser);

    const result = await resolver(null, { id: '1' }, { dataSources: mockDataSources });

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

  it('throws NOT_FOUND when user does not exist', async () => {
    mockDataSources.users.findById.mockResolvedValue(null);

    await expect(resolver(null, { id: '999' }, { dataSources: mockDataSources }))
      .rejects.toMatchObject({
        extensions: { code: 'NOT_FOUND' },
      });
  });
});

describe('User.fullName', () => {
  it('concatenates first and last name', () => {
    const result = UserResolvers.User.fullName({ firstName: 'Alice', lastName: 'Smith' });
    expect(result).toBe('Alice Smith');
  });
});

Integration Testing with Apollo Server

Integration tests execute real operations against a fully wired server:

// test-helpers/createTestServer.js
import { ApolloServer } from '@apollo/server';
import { typeDefs } from '../schema';
import { resolvers } from '../resolvers';
import { createDataSources } from '../dataSources';

export async function createTestServer(contextOverrides = {}) {
  const server = new ApolloServer({ typeDefs, resolvers });
  await server.start();
  return server;
}

export async function executeOperation(server, query, variables = {}, contextOverrides = {}) {
  const context = {
    user: null,
    dataSources: createDataSources(testDb),
    ...contextOverrides,
  };

  return server.executeOperation({ query, variables }, { contextValue: context });
}
// tests/integration/user.test.js
import { createTestServer, executeOperation } from '../test-helpers/createTestServer';
import { testDb } from '../test-helpers/testDb';

let server;

beforeAll(async () => {
  server = await createTestServer();
  await testDb.migrate();
});

afterAll(async () => {
  await server.stop();
  await testDb.close();
});

beforeEach(async () => {
  await testDb.seed();
});

afterEach(async () => {
  await testDb.cleanup();
});

describe('Query.user', () => {
  it('returns user fields correctly', async () => {
    const result = await executeOperation(
      server,
      `query GetUser($id: ID!) {
        user(id: $id) {
          id
          firstName
          lastName
          email
        }
      }`,
      { id: '1' },
      { user: { id: '1', role: 'admin' } } // authenticated context
    );

    expect(result.body.kind).toBe('single');
    expect(result.body.singleResult.errors).toBeUndefined();
    expect(result.body.singleResult.data.user).toEqual({
      id: '1',
      firstName: 'Alice',
      lastName: 'Smith',
      email: 'alice@example.com',
    });
  });

  it('returns null with errors for non-existent user', async () => {
    const result = await executeOperation(
      server,
      `query { user(id: "999") { id firstName } }`,
      {},
      { user: { role: 'admin' } }
    );

    expect(result.body.singleResult.data.user).toBeNull();
    expect(result.body.singleResult.errors).toContainEqual(
      expect.objectContaining({
        extensions: { code: 'NOT_FOUND' },
      })
    );
  });

  it('requires authentication', async () => {
    const result = await executeOperation(
      server,
      `query { user(id: "1") { id } }`,
      {},
      { user: null } // no authenticated user
    );

    expect(result.body.singleResult.errors).toContainEqual(
      expect.objectContaining({
        extensions: { code: 'UNAUTHENTICATED' },
      })
    );
  });
});

describe('Mutation.createUser', () => {
  it('creates and returns a new user', async () => {
    const result = await executeOperation(
      server,
      `mutation CreateUser($input: CreateUserInput!) {
        createUser(input: $input) {
          id
          email
          firstName
        }
      }`,
      {
        input: {
          email: 'bob@example.com',
          firstName: 'Bob',
          lastName: 'Jones',
          password: 'password123',
        },
      },
      { user: { role: 'admin' } }
    );

    const created = result.body.singleResult.data.createUser;
    expect(created.email).toBe('bob@example.com');
    expect(created.id).toBeDefined();

    // Verify it's persisted
    const verify = await executeOperation(
      server,
      `query { user(id: "${created.id}") { email } }`,
      {},
      { user: { role: 'admin' } }
    );
    expect(verify.body.singleResult.data.user.email).toBe('bob@example.com');
  });
});

Testing GraphQL Errors

GraphQL error handling is nuanced. Always test error responses explicitly:

it('returns FORBIDDEN for unauthorized access', async () => {
  const result = await executeOperation(
    server,
    `query { adminStats { totalUsers } }`,
    {},
    { user: { role: 'user' } } // non-admin user
  );

  expect(result.body.singleResult.errors[0]).toMatchObject({
    message: expect.stringContaining('forbidden'),
    extensions: {
      code: 'FORBIDDEN',
    },
    locations: expect.any(Array),
    path: ['adminStats'],
  });
});

Testing Subscriptions

Subscriptions require a slightly different test approach:

import { createTestServer } from '../test-helpers/createTestServer';

it('emits events when user is created', async () => {
  const server = await createTestServer();
  
  const subscription = server.executeOperation({
    query: `subscription { userCreated { id email } }`,
  });
  
  // Trigger the mutation
  await executeOperation(server, `mutation { createUser(input: {...}) { id } }`);
  
  // Check the subscription received the event
  const { value } = await subscription[Symbol.asyncIterator]().next();
  expect(value.data.userCreated.email).toBeDefined();
});

Schema Testing

Test that your schema is valid and doesn't have breaking changes:

import { buildSchema, validateSchema } from 'graphql';
import { typeDefs } from '../schema';

it('schema is valid', () => {
  const schema = buildSchema(typeDefs.join('\n'));
  const errors = validateSchema(schema);
  expect(errors).toHaveLength(0);
});

For preventing breaking changes, use @graphql-inspector/core:

npm install --save-dev @graphql-inspector/core
import { diff } from '@graphql-inspector/core';

it('has no breaking changes from previous schema version', () => {
  const oldSchema = buildSchema(previousSchemaString);
  const newSchema = buildSchema(currentSchemaString);
  const changes = diff(oldSchema, newSchema);
  const breaking = changes.filter(c => c.criticality.level === 'BREAKING');
  expect(breaking).toHaveLength(0);
});

What to Prioritize

  1. Integration tests over unit tests: The resolver logic is rarely complex enough to warrant extensive isolation. The interesting bugs are in how resolvers compose and how context flows.
  2. Test the query surface your clients actually use: If your frontend always requests user { id name email orders { id status } }, test that exact query shape.
  3. Always test error cases: Authentication failures, missing resources, validation errors. GraphQL's 200-always behavior makes these easy to miss.
  4. Test authorization rules exhaustively: Which roles can access which fields? These rules are security-critical and often have subtle bugs at the edges.
  5. Test pagination: Off-by-one errors in cursor-based pagination are common. Test first/last, before/after, and edge cases (empty lists, single-item lists).

Read more