Testing GraphQL APIs with Jest and Apollo Server

Testing GraphQL APIs with Jest and Apollo Server

Apollo Server is the most popular GraphQL server for Node.js. Its testing story is surprisingly good: executeOperation lets you run queries against an in-memory server without HTTP overhead, making integration tests fast and deterministic.

This guide focuses on the practical patterns you need for testing Apollo Server 4 applications with Jest.

Setup

Install dependencies:

npm install @apollo/server graphql
npm install --save-dev jest @jest/globals

For TypeScript:

npm install --save-dev @types/jest ts-jest

Basic Apollo Server Test Setup

A minimal test helper that creates a server and executes operations:

// tests/helpers/testServer.ts
import { ApolloServer, BaseContext } from '@apollo/server';
import { DocumentNode } from 'graphql';

export interface TestContext extends BaseContext {
  user: { id: string; role: string } | null;
  db: Database;
}

export async function createTestServer() {
  const server = new ApolloServer<TestContext>({
    typeDefs,
    resolvers,
    // Disable error masking in tests to see full errors
    includeStacktraceInErrorResponses: true,
  });

  await server.start();
  return server;
}

export async function gql(
  server: ApolloServer<TestContext>,
  query: string | DocumentNode,
  {
    variables = {},
    user = null,
    db = testDb,
  }: {
    variables?: Record<string, unknown>;
    user?: TestContext['user'];
    db?: Database;
  } = {}
) {
  const result = await server.executeOperation(
    { query: typeof query === 'string' ? query : print(query), variables },
    { contextValue: { user, db } }
  );

  if (result.body.kind !== 'single') throw new Error('Unexpected incremental result');
  return result.body.singleResult;
}

Your First Tests

// tests/resolvers/query.test.ts
import { createTestServer, gql } from '../helpers/testServer';
import { testDb } from '../helpers/testDb';
import type { ApolloServer } from '@apollo/server';

describe('Query resolvers', () => {
  let server: ApolloServer;

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

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

  beforeEach(() => testDb.seed());
  afterEach(() => testDb.cleanup());

  describe('Query.me', () => {
    it('returns the authenticated user', async () => {
      const result = await gql(
        server,
        `query { me { id email role } }`,
        { user: { id: '1', role: 'user' } }
      );

      expect(result.errors).toBeUndefined();
      expect(result.data?.me).toEqual({
        id: '1',
        email: 'alice@example.com',
        role: 'user',
      });
    });

    it('returns null when not authenticated', async () => {
      const result = await gql(server, `query { me { id } }`, { user: null });

      expect(result.errors).toBeUndefined();
      expect(result.data?.me).toBeNull();
    });
  });

  describe('Query.products', () => {
    it('returns paginated products', async () => {
      await testDb.products.bulkCreate(20);

      const result = await gql(
        server,
        `query Products($first: Int!, $after: String) {
          products(first: $first, after: $after) {
            edges {
              node { id name price }
              cursor
            }
            pageInfo {
              hasNextPage
              endCursor
            }
          }
        }`,
        { variables: { first: 10 }, user: { id: '1', role: 'user' } }
      );

      expect(result.errors).toBeUndefined();
      const { edges, pageInfo } = result.data?.products;
      expect(edges).toHaveLength(10);
      expect(pageInfo.hasNextPage).toBe(true);
      expect(pageInfo.endCursor).toBeDefined();
    });

    it('returns empty list when no products', async () => {
      const result = await gql(
        server,
        `query { products(first: 10) { edges { node { id } } } }`,
        { user: { id: '1', role: 'user' } }
      );

      expect(result.data?.products.edges).toHaveLength(0);
    });
  });
});

Testing Mutations

describe('Mutation.createProduct', () => {
  const CREATE_PRODUCT = `
    mutation CreateProduct($input: CreateProductInput!) {
      createProduct(input: $input) {
        id
        name
        price
        slug
      }
    }
  `;

  it('creates product and returns it', async () => {
    const result = await gql(
      server,
      CREATE_PRODUCT,
      {
        variables: {
          input: { name: 'Test Widget', price: 9.99, description: 'A widget' },
        },
        user: { id: '1', role: 'admin' },
      }
    );

    expect(result.errors).toBeUndefined();
    const product = result.data?.createProduct;
    expect(product.id).toBeDefined();
    expect(product.name).toBe('Test Widget');
    expect(product.price).toBe(9.99);
    expect(product.slug).toBe('test-widget');
  });

  it('requires admin role', async () => {
    const result = await gql(
      server,
      CREATE_PRODUCT,
      {
        variables: { input: { name: 'Widget', price: 9.99 } },
        user: { id: '1', role: 'user' }, // non-admin
      }
    );

    expect(result.errors).toContainEqual(
      expect.objectContaining({
        extensions: { code: 'FORBIDDEN' },
        path: ['createProduct'],
      })
    );
    expect(result.data?.createProduct).toBeNull();
  });

  it('validates required fields', async () => {
    const result = await gql(
      server,
      CREATE_PRODUCT,
      {
        variables: { input: { price: 9.99 } }, // missing name
        user: { id: '1', role: 'admin' },
      }
    );

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

  it('returns CONFLICT on duplicate slug', async () => {
    await testDb.products.create({ name: 'Test Widget', slug: 'test-widget' });

    const result = await gql(
      server,
      CREATE_PRODUCT,
      {
        variables: { input: { name: 'Test Widget', price: 9.99 } },
        user: { id: '1', role: 'admin' },
      }
    );

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

Mocking Context

The context is the primary extension point in Apollo Server. Test different context states to cover authentication and authorization paths:

// Test different user states
const adminUser = { id: '1', role: 'admin' };
const regularUser = { id: '2', role: 'user' };
const unauthenticatedUser = null;

// Admin can access everything
it('admin accesses admin-only query', async () => {
  const result = await gql(server, `query { adminDashboard { userCount } }`, { user: adminUser });
  expect(result.errors).toBeUndefined();
});

// Regular user gets FORBIDDEN
it('regular user cannot access admin query', async () => {
  const result = await gql(server, `query { adminDashboard { userCount } }`, { user: regularUser });
  expect(result.errors?.[0]?.extensions?.code).toBe('FORBIDDEN');
});

// Unauthenticated gets UNAUTHENTICATED
it('unauthenticated user gets UNAUTHENTICATED error', async () => {
  const result = await gql(server, `query { adminDashboard { userCount } }`, { user: null });
  expect(result.errors?.[0]?.extensions?.code).toBe('UNAUTHENTICATED');
});

Mocking External Services

When resolvers call external APIs or services, mock them in tests:

// Mock the payment service
jest.mock('../services/payments', () => ({
  PaymentService: jest.fn().mockImplementation(() => ({
    charge: jest.fn().mockResolvedValue({
      id: 'ch_test_123',
      status: 'succeeded',
      amount: 9999,
    }),
    refund: jest.fn().mockResolvedValue({ id: 'rf_test_123' }),
  })),
}));

import { PaymentService } from '../services/payments';

describe('Mutation.purchaseProduct', () => {
  let mockPaymentService: jest.Mocked<InstanceType<typeof PaymentService>>;

  beforeEach(() => {
    mockPaymentService = new PaymentService() as jest.Mocked<InstanceType<typeof PaymentService>>;
  });

  it('charges the correct amount', async () => {
    const result = await gql(
      server,
      `mutation { purchaseProduct(productId: "1", paymentMethodId: "pm_test") { orderId } }`,
      { user: { id: '2', role: 'user' } }
    );

    expect(mockPaymentService.charge).toHaveBeenCalledWith(
      expect.objectContaining({ amount: 999 }) // $9.99 in cents
    );
  });

  it('handles payment failure gracefully', async () => {
    mockPaymentService.charge.mockRejectedValueOnce(
      new Error('Card declined')
    );

    const result = await gql(
      server,
      `mutation { purchaseProduct(productId: "1", paymentMethodId: "pm_fail") { orderId } }`,
      { user: { id: '2', role: 'user' } }
    );

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

Testing DataLoader

DataLoader is the solution to N+1 queries. Test that it batches correctly:

// resolvers/Product.ts
import DataLoader from 'dataloader';

export const createCategoryLoader = (db: Database) =>
  new DataLoader<string, Category>(async (categoryIds) => {
    const categories = await db.categories.findByIds([...categoryIds]);
    const categoryMap = new Map(categories.map(c => [c.id, c]));
    return categoryIds.map(id => categoryMap.get(id) ?? new Error(`Category ${id} not found`));
  });
// tests/resolvers/dataloader.test.ts
import DataLoader from 'dataloader';
import { createCategoryLoader } from '../../resolvers/Product';

describe('createCategoryLoader', () => {
  it('batches multiple category lookups into one query', async () => {
    const mockDb = {
      categories: {
        findByIds: jest.fn().mockResolvedValue([
          { id: '1', name: 'Electronics' },
          { id: '2', name: 'Books' },
        ]),
      },
    };

    const loader = createCategoryLoader(mockDb as any);

    // Load two categories concurrently
    const [cat1, cat2] = await Promise.all([
      loader.load('1'),
      loader.load('2'),
    ]);

    expect(cat1).toEqual({ id: '1', name: 'Electronics' });
    expect(cat2).toEqual({ id: '2', name: 'Books' });

    // Should have been called ONCE with both IDs (batched)
    expect(mockDb.categories.findByIds).toHaveBeenCalledTimes(1);
    expect(mockDb.categories.findByIds).toHaveBeenCalledWith(['1', '2']);
  });

  it('returns error for non-existent category', async () => {
    const mockDb = {
      categories: { findByIds: jest.fn().mockResolvedValue([]) },
    };

    const loader = createCategoryLoader(mockDb as any);
    await expect(loader.load('999')).rejects.toThrow('Category 999 not found');
  });
});

Testing Directives

Custom directives often implement auth rules. Test them explicitly:

// Schema: type User @requiresRole(role: "admin") { ... }

describe('@requiresRole directive', () => {
  it('allows access for users with required role', async () => {
    const result = await gql(
      server,
      `query { adminOnlyField }`,
      { user: { id: '1', role: 'admin' } }
    );
    expect(result.errors).toBeUndefined();
  });

  it('denies access for users without required role', async () => {
    const result = await gql(
      server,
      `query { adminOnlyField }`,
      { user: { id: '2', role: 'user' } }
    );
    expect(result.errors?.[0]?.extensions?.code).toBe('FORBIDDEN');
  });
});

Snapshot Testing for Schema

Snapshot the full SDL to catch unintended schema changes:

import { lexicographicSortSchema, printSchema } from 'graphql';
import { makeExecutableSchema } from '@graphql-tools/schema';

it('schema has not changed unexpectedly', () => {
  const schema = makeExecutableSchema({ typeDefs, resolvers });
  const schemaString = printSchema(lexicographicSortSchema(schema));
  expect(schemaString).toMatchSnapshot();
});

When you intentionally change the schema, update the snapshot with jest --updateSnapshot. This prevents accidental breaking changes from slipping through.

Organizing Your Test Suite

For a medium-sized GraphQL API, structure tests like this:

tests/
  helpers/
    testServer.ts
    testDb.ts
    factories.ts       # test data factories
  resolvers/
    query/
      user.test.ts
      products.test.ts
      orders.test.ts
    mutation/
      createProduct.test.ts
      updateOrder.test.ts
      auth.test.ts
    subscription/
      orderUpdated.test.ts
  directives/
    requiresRole.test.ts
    rateLimit.test.ts
  schema/
    schema.test.ts     # validation + snapshot

This organization mirrors the resolver file structure, making it easy to find tests for any resolver.

Read more