Skip to content
Codeloom

Courses / GraphQL Complete Guide

Lesson 17 of 18

GraphQL Testing Strategies

Test GraphQL APIs effectively with unit tests for resolvers, integration tests against the schema, and end-to-end mocking strategies.

Intermediate 13 min read

What you'll learn

  • How to unit test resolvers in isolation
  • How to run integration tests against a full GraphQL schema
  • How to mock data sources and external services
  • How to validate schema changes with snapshot testing
  • How to test error handling, auth, and edge cases

Prerequisites

  • Basic GraphQL resolvers and schema design
  • Familiarity with Jest or a similar testing framework

The Testing Pyramid for GraphQL

GraphQL APIs benefit from three testing layers:

Unit tests verify individual resolvers in isolation with mocked dependencies. They run fast, catch logic bugs, and form the base of your testing pyramid.

Integration tests execute actual GraphQL operations against your schema, testing the full resolver chain, type coercion, and error formatting. They catch wiring issues that unit tests miss.

End-to-end tests hit the running HTTP server and verify the complete request lifecycle including middleware, auth, and transport-level concerns.

Unit Testing Resolvers

Resolvers are plain functions. Test them like any other function by passing arguments and a mock context:

// resolvers/user.js
export const userResolvers = {
  Query: {
    user: async (_, { id }, { db }) => {
      const user = await db.users.findById(id);
      if (!user) {
        throw new GraphQLError('User not found', {
          extensions: { code: 'NOT_FOUND' },
        });
      }
      return user;
    },
    users: async (_, { limit = 10, offset = 0 }, { db, user }) => {
      if (!user || user.role !== 'ADMIN') {
        throw new GraphQLError('Forbidden', {
          extensions: { code: 'FORBIDDEN' },
        });
      }
      return db.users.findAll({ limit, offset });
    },
  },

  Mutation: {
    updateUser: async (_, { id, input }, { db, user }) => {
      if (!user || (user.id !== id && user.role !== 'ADMIN')) {
        throw new GraphQLError('Forbidden');
      }
      return db.users.update(id, input);
    },
  },
};
// resolvers/user.test.js
import { userResolvers } from './user.js';

describe('User Query Resolvers', () => {
  const mockDb = {
    users: {
      findById: jest.fn(),
      findAll: jest.fn(),
      update: jest.fn(),
    },
  };

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

  describe('user', () => {
    it('returns a user when found', async () => {
      const mockUser = { id: '1', name: 'Alice', email: 'alice@test.com' };
      mockDb.users.findById.mockResolvedValue(mockUser);

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

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

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

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

  describe('users', () => {
    it('returns users for admin', async () => {
      const mockUsers = [{ id: '1', name: 'Alice' }];
      mockDb.users.findAll.mockResolvedValue(mockUsers);

      const result = await userResolvers.Query.users(
        null,
        { limit: 10 },
        { db: mockDb, user: { id: '1', role: 'ADMIN' } }
      );

      expect(result).toEqual(mockUsers);
    });

    it('throws for non-admin users', async () => {
      await expect(
        userResolvers.Query.users(
          null,
          {},
          { db: mockDb, user: { id: '1', role: 'USER' } }
        )
      ).rejects.toThrow('Forbidden');
    });

    it('throws for unauthenticated requests', async () => {
      await expect(
        userResolvers.Query.users(null, {}, { db: mockDb, user: null })
      ).rejects.toThrow('Forbidden');
    });
  });

  describe('updateUser', () => {
    it('allows users to update themselves', async () => {
      const updated = { id: '1', name: 'New Name' };
      mockDb.users.update.mockResolvedValue(updated);

      const result = await userResolvers.Mutation.updateUser(
        null,
        { id: '1', input: { name: 'New Name' } },
        { db: mockDb, user: { id: '1', role: 'USER' } }
      );

      expect(result).toEqual(updated);
    });

    it('prevents users from updating other users', async () => {
      await expect(
        userResolvers.Mutation.updateUser(
          null,
          { id: '2', input: { name: 'Hacked' } },
          { db: mockDb, user: { id: '1', role: 'USER' } }
        )
      ).rejects.toThrow('Forbidden');
    });

    it('allows admins to update any user', async () => {
      const updated = { id: '2', name: 'Admin Edit' };
      mockDb.users.update.mockResolvedValue(updated);

      const result = await userResolvers.Mutation.updateUser(
        null,
        { id: '2', input: { name: 'Admin Edit' } },
        { db: mockDb, user: { id: '1', role: 'ADMIN' } }
      );

      expect(result).toEqual(updated);
    });
  });
});

Integration Testing with executeOperation

Apollo Server’s executeOperation method runs queries against your schema without an HTTP server. This tests the full GraphQL execution pipeline:

import { ApolloServer } from '@apollo/server';
import { typeDefs } from './schema.js';
import { resolvers } from './resolvers/index.js';

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

  beforeAll(() => {
    mockDb = createMockDatabase();

    server = new ApolloServer({
      typeDefs,
      resolvers,
    });
  });

  it('fetches a user by id', async () => {
    mockDb.users.findById.mockResolvedValue({
      id: '1',
      name: 'Alice',
      email: 'alice@test.com',
    });

    const response = await server.executeOperation(
      {
        query: `
          query GetUser($id: ID!) {
            user(id: $id) {
              id
              name
              email
            }
          }
        `,
        variables: { id: '1' },
      },
      {
        contextValue: { db: mockDb, user: { id: '1', role: 'USER' } },
      }
    );

    expect(response.body.kind).toBe('single');
    expect(response.body.singleResult.errors).toBeUndefined();
    expect(response.body.singleResult.data.user).toEqual({
      id: '1',
      name: 'Alice',
      email: 'alice@test.com',
    });
  });

  it('returns errors for invalid queries', async () => {
    const response = await server.executeOperation(
      {
        query: `
          query {
            user(id: "1") {
              nonExistentField
            }
          }
        `,
      },
      { contextValue: { db: mockDb } }
    );

    expect(response.body.singleResult.errors).toBeDefined();
    expect(response.body.singleResult.errors[0].message).toContain(
      'Cannot query field "nonExistentField"'
    );
  });

  it('handles mutations and returns updated data', async () => {
    mockDb.users.findById.mockResolvedValue({ id: '1', role: 'USER' });
    mockDb.users.update.mockResolvedValue({
      id: '1',
      name: 'Updated',
      email: 'alice@test.com',
    });

    const response = await server.executeOperation(
      {
        query: `
          mutation UpdateUser($id: ID!, $input: UpdateUserInput!) {
            updateUser(id: $id, input: $input) {
              id
              name
            }
          }
        `,
        variables: { id: '1', input: { name: 'Updated' } },
      },
      {
        contextValue: {
          db: mockDb,
          user: { id: '1', role: 'USER' },
        },
      }
    );

    expect(response.body.singleResult.data.updateUser.name).toBe('Updated');
  });
});

Mocking the Schema

For client-side testing, mock the entire schema to generate realistic fake data:

import { makeExecutableSchema } from '@graphql-tools/schema';
import { addMocksToSchema } from '@graphql-tools/mock';
import { graphql } from 'graphql';
import { faker } from '@faker-js/faker';

const typeDefs = `
  type User {
    id: ID!
    name: String!
    email: String!
    posts: [Post!]!
  }

  type Post {
    id: ID!
    title: String!
    body: String!
    author: User!
  }

  type Query {
    user(id: ID!): User
    posts: [Post!]!
  }
`;

const mocks = {
  User: () => ({
    id: faker.string.uuid(),
    name: faker.person.fullName(),
    email: faker.internet.email(),
  }),
  Post: () => ({
    id: faker.string.uuid(),
    title: faker.lorem.sentence(),
    body: faker.lorem.paragraphs(3),
  }),
  Query: () => ({
    posts: () => new Array(5),  // Generate 5 mock posts
  }),
};

const schema = makeExecutableSchema({ typeDefs });
const mockedSchema = addMocksToSchema({ schema, mocks });

// Use in tests
it('returns mocked user data', async () => {
  const result = await graphql({
    schema: mockedSchema,
    source: `
      query {
        user(id: "1") {
          id
          name
          email
        }
      }
    `,
  });

  expect(result.data.user.name).toBeDefined();
  expect(result.data.user.email).toContain('@');
});

Testing with MockedProvider in React

For React component testing, MockedProvider intercepts Apollo Client operations and returns predefined responses:

import { render, screen, waitFor } from '@testing-library/react';
import { MockedProvider } from '@apollo/client/testing';
import { UserProfile } from './UserProfile';

const GET_USER = gql`
  query GetUser($id: ID!) {
    user(id: $id) {
      id
      name
      email
    }
  }
`;

const mocks = [
  {
    request: {
      query: GET_USER,
      variables: { id: '1' },
    },
    result: {
      data: {
        user: {
          id: '1',
          name: 'Alice',
          email: 'alice@test.com',
        },
      },
    },
  },
];

const errorMocks = [
  {
    request: {
      query: GET_USER,
      variables: { id: '999' },
    },
    error: new Error('User not found'),
  },
];

describe('UserProfile', () => {
  it('renders user data', async () => {
    render(
      <MockedProvider mocks={mocks} addTypename={false}>
        <UserProfile userId="1" />
      </MockedProvider>
    );

    expect(screen.getByText('Loading...')).toBeInTheDocument();

    await waitFor(() => {
      expect(screen.getByText('Alice')).toBeInTheDocument();
      expect(screen.getByText('alice@test.com')).toBeInTheDocument();
    });
  });

  it('renders error state', async () => {
    render(
      <MockedProvider mocks={errorMocks} addTypename={false}>
        <UserProfile userId="999" />
      </MockedProvider>
    );

    await waitFor(() => {
      expect(screen.getByText(/error/i)).toBeInTheDocument();
    });
  });
});

Schema Snapshot Testing

Catch unintentional schema changes by snapshotting the SDL:

import { printSchema } from 'graphql';
import { schema } from './schema.js';

describe('Schema', () => {
  it('matches the snapshot', () => {
    expect(printSchema(schema)).toMatchSnapshot();
  });
});

When the schema changes intentionally, update the snapshot with jest --updateSnapshot. When it changes accidentally, the test catches it.

Testing Error Extensions

Verify that errors include the correct extensions for client consumption:

it('returns proper error extensions for not found', async () => {
  mockDb.users.findById.mockResolvedValue(null);

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

  const error = response.body.singleResult.errors[0];
  expect(error.extensions.code).toBe('NOT_FOUND');
  expect(error.message).toContain('not found');
});

it('returns FORBIDDEN for unauthorized access', async () => {
  const response = await server.executeOperation(
    {
      query: `query { users { id name } }`,
    },
    { contextValue: { db: mockDb, user: null } }
  );

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

End-to-End HTTP Tests

Test the full HTTP stack including middleware, headers, and cookies:

import request from 'supertest';
import { app } from './app.js';

describe('GraphQL HTTP Endpoint', () => {
  it('requires authentication for protected queries', async () => {
    const response = await request(app)
      .post('/graphql')
      .send({
        query: '{ me { id name } }',
      });

    expect(response.status).toBe(200);
    expect(response.body.errors[0].extensions.code).toBe('UNAUTHENTICATED');
  });

  it('accepts valid auth tokens', async () => {
    const token = generateTestToken({ userId: '1', role: 'USER' });

    const response = await request(app)
      .post('/graphql')
      .set('Authorization', `Bearer ${token}`)
      .send({
        query: '{ me { id name email } }',
      });

    expect(response.status).toBe(200);
    expect(response.body.data.me.id).toBe('1');
    expect(response.body.errors).toBeUndefined();
  });

  it('rejects expired tokens', async () => {
    const token = generateTestToken({ userId: '1' }, { expiresIn: '0s' });

    const response = await request(app)
      .post('/graphql')
      .set('Authorization', `Bearer ${token}`)
      .send({
        query: '{ me { id } }',
      });

    expect(response.body.errors).toBeDefined();
  });
});

Testing Best Practices

Test resolver logic, not GraphQL plumbing. Do not test that GraphQL correctly parses a query — that is the framework’s job. Test that your resolvers return the right data and throw the right errors.

Use factories for test data. Create helper functions that generate realistic test objects with sensible defaults:

function createTestUser(overrides = {}) {
  return {
    id: faker.string.uuid(),
    name: faker.person.fullName(),
    email: faker.internet.email(),
    role: 'USER',
    ...overrides,
  };
}

Test edge cases explicitly. Empty lists, null values, maximum pagination limits, special characters in input, and concurrent mutations are all common sources of bugs.

Run integration tests in CI. Unit tests catch logic errors. Integration tests catch wiring errors — missing resolvers, wrong type mappings, broken field references. Both belong in your CI pipeline.

Progress is saved locally to your browser.