GraphQL API Testing: Jest, Apollo MockedProvider, and E2E Testing

GraphQL API Testing: Jest, Apollo MockedProvider, and E2E Testing

GraphQL APIs break most REST testing assumptions. One endpoint, unlimited query shapes, typed schemas, subscriptions — it's a different beast. This guide covers the full testing stack: unit tests for resolvers, integration tests with supertest, Apollo MockedProvider for component testing, schema validation, subscription testing, and E2E automation.

What Makes GraphQL Testing Different from REST

REST testing maps naturally to HTTP: you test endpoints, verbs, and status codes. GraphQL collapses that model.

Single endpoint, variable payloads. Every request hits POST /graphql. The request body defines what you're testing. You can't infer what a request does from the URL alone.

Partial success. A GraphQL response can return HTTP 200 with errors in the body. Your test assertions need to check data AND errors — a 200 doesn't mean success.

Schema as contract. The schema is the contract. A resolver that returns the right data shape but violates the schema type is a bug. You need schema validation tests, not just response content tests.

N+1 queries. A single GraphQL query can trigger dozens of database calls depending on the fields requested. Integration tests need to capture this, not just unit tests.

Subscriptions. WebSocket-based subscriptions require a different testing approach entirely — you can't use plain HTTP clients.

The testing strategy reflects these differences: resolver unit tests, schema validation, HTTP integration tests with a real server, component-level mocking with Apollo MockedProvider, and E2E tests for the full flow.

Testing GraphQL with Jest + graphql-request

graphql-request is the simplest way to fire GraphQL queries in tests. It handles the HTTP mechanics so you focus on what the API returns.

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

Start with a real server running (or use supertest to avoid that requirement — covered below). Basic query test:

import { GraphQLClient, gql } from 'graphql-request';

const client = new GraphQLClient('http://localhost:4000/graphql');

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

describe('User queries', () => {
  test('returns user by id', async () => {
    const data = await client.request(GET_USER, { id: '1' });

    expect(data.user).toMatchObject({
      id: '1',
      name: expect.any(String),
      email: expect.stringContaining('@'),
      role: expect.stringMatching(/^(ADMIN|USER|MODERATOR)$/),
    });
  });

  test('returns null for nonexistent user', async () => {
    const data = await client.request(GET_USER, { id: 'nonexistent-999' });
    expect(data.user).toBeNull();
  });
});

For testing errors, graphql-request throws by default when the response contains errors. Catch and inspect:

const CREATE_USER = gql`
  mutation CreateUser($input: CreateUserInput!) {
    createUser(input: $input) {
      id
      email
    }
  }
`;

test('rejects duplicate email', async () => {
  // First creation succeeds
  await client.request(CREATE_USER, {
    input: { email: 'test@example.com', name: 'Test User' },
  });

  // Second should fail
  await expect(
    client.request(CREATE_USER, {
      input: { email: 'test@example.com', name: 'Another User' },
    })
  ).rejects.toThrow(/email.*already.*exists/i);
});

If you need raw access to both data and errors in a partial success scenario, use rawRequest:

test('partial query with permission error', async () => {
  const { data, errors } = await client.rawRequest(
    `query { publicField privateField }`,
    {}
  );

  expect(data.publicField).toBeDefined();
  expect(errors).toHaveLength(1);
  expect(errors[0].extensions.code).toBe('FORBIDDEN');
});

Apollo Client Testing with MockedProvider

When testing React components that use Apollo hooks (useQuery, useMutation), you don't want a real server. Apollo ships MockedProvider for this exact purpose.

npm install --save-dev @apollo/client @testing-library/react

Define mocks as request/result pairs:

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

const mocks = [
  {
    request: {
      query: GET_USER,
      variables: { id: '42' },
    },
    result: {
      data: {
        user: {
          __typename: 'User',
          id: '42',
          name: 'Alice Chen',
          email: 'alice@example.com',
          role: 'ADMIN',
        },
      },
    },
  },
];

test('renders user profile after loading', async () => {
  render(
    <MockedProvider mocks={mocks} addTypename={false}>
      <UserProfile userId="42" />
    </MockedProvider>
  );

  // Loading state should appear first
  expect(screen.getByText(/loading/i)).toBeInTheDocument();

  // Wait for data to render
  await waitFor(() => {
    expect(screen.getByText('Alice Chen')).toBeInTheDocument();
  });

  expect(screen.getByText('alice@example.com')).toBeInTheDocument();
  expect(screen.getByRole('badge', { name: /admin/i })).toBeInTheDocument();
});

Test error states explicitly — this is where most teams skip coverage:

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

test('shows error message when query fails', async () => {
  render(
    <MockedProvider mocks={errorMocks} addTypename={false}>
      <UserProfile userId="99" />
    </MockedProvider>
  );

  await waitFor(() => {
    expect(screen.getByRole('alert')).toHaveTextContent(/user not found/i);
  });
});

For mutations, verify the UI updates after success:

test('updates user role on mutation success', async () => {
  const mutationMocks = [
    {
      request: {
        query: GET_USER,
        variables: { id: '42' },
      },
      result: { data: { user: { __typename: 'User', id: '42', name: 'Alice', role: 'USER' } } },
    },
    {
      request: {
        query: UPDATE_ROLE,
        variables: { userId: '42', role: 'ADMIN' },
      },
      result: {
        data: { updateUserRole: { __typename: 'User', id: '42', role: 'ADMIN' } },
      },
    },
  ];

  render(
    <MockedProvider mocks={mutationMocks} addTypename={false}>
      <UserRoleEditor userId="42" />
    </MockedProvider>
  );

  await waitFor(() => screen.getByRole('button', { name: /make admin/i }));

  await userEvent.click(screen.getByRole('button', { name: /make admin/i }));

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

Integration Testing with Supertest + GraphQL

Integration tests run against the real server without a separate server process. Use supertest with your Express, Fastify, or Apollo Server instance.

import request from 'supertest';
import { createApp } from '../src/app';

let app: Express.Application;

beforeAll(async () => {
  app = await createApp({ database: testDb });
});

afterAll(async () => {
  await testDb.destroy();
});

describe('POST /graphql', () => {
  test('executes query and returns shaped data', async () => {
    const response = await request(app)
      .post('/graphql')
      .set('Content-Type', 'application/json')
      .set('Authorization', `Bearer ${testUserToken}`)
      .send({
        query: `
          query {
            products(first: 5) {
              edges {
                node {
                  id
                  name
                  price
                  inStock
                }
              }
              pageInfo {
                hasNextPage
                endCursor
              }
            }
          }
        `,
      });

    expect(response.status).toBe(200);
    expect(response.body.errors).toBeUndefined();

    const { products } = response.body.data;
    expect(products.edges).toHaveLength(5);
    expect(products.edges[0].node).toMatchObject({
      id: expect.any(String),
      name: expect.any(String),
      price: expect.any(Number),
      inStock: expect.any(Boolean),
    });
  });

  test('returns 400 for malformed query', async () => {
    const response = await request(app)
      .post('/graphql')
      .send({ query: 'query { this is not valid graphql !!!}' });

    expect(response.status).toBe(400);
  });

  test('returns errors array for unauthorized field', async () => {
    const response = await request(app)
      .post('/graphql')
      // No auth header
      .send({ query: `query { adminStats { totalRevenue } }` });

    expect(response.status).toBe(200); // GraphQL returns 200
    expect(response.body.data.adminStats).toBeNull();
    expect(response.body.errors[0].extensions.code).toBe('UNAUTHENTICATED');
  });
});

For Fastify, inject requests directly without spinning up a port:

import Fastify from 'fastify';
import { buildServer } from '../src/server';

test('fastify graphql endpoint handles variables', async () => {
  const app = await buildServer();

  const response = await app.inject({
    method: 'POST',
    url: '/graphql',
    headers: { 'content-type': 'application/json' },
    payload: {
      query: `query GetProduct($id: ID!) { product(id: $id) { name } }`,
      variables: { id: 'prod-1' },
    },
  });

  expect(response.statusCode).toBe(200);
  expect(JSON.parse(response.body).data.product.name).toBe('Widget Pro');
});

Mocking Resolvers in Unit Tests

Unit test resolvers in isolation — no database, no network. This is where you test business logic: authorization checks, data transformation, error handling.

import { userResolver } from '../src/resolvers/user';
import { createMockContext } from '../src/test-utils/context';

describe('user resolver', () => {
  test('returns user when authenticated', async () => {
    const ctx = createMockContext({
      userId: 'caller-1',
      db: {
        users: {
          findUnique: jest.fn().mockResolvedValue({
            id: '42',
            name: 'Bob',
            email: 'bob@example.com',
          }),
        },
      },
    });

    const result = await userResolver.Query.user(
      null,
      { id: '42' },
      ctx
    );

    expect(ctx.db.users.findUnique).toHaveBeenCalledWith({
      where: { id: '42' },
    });
    expect(result).toMatchObject({ id: '42', name: 'Bob' });
  });

  test('throws FORBIDDEN when accessing another user without admin role', async () => {
    const ctx = createMockContext({
      userId: 'caller-1',
      userRole: 'USER',
    });

    await expect(
      userResolver.Query.user(null, { id: 'someone-else' }, ctx)
    ).rejects.toMatchObject({
      extensions: { code: 'FORBIDDEN' },
    });
  });

  test('field resolver masks email for non-admin callers', async () => {
    const ctx = createMockContext({ userId: 'other', userRole: 'USER' });
    const parent = { id: '42', email: 'private@example.com' };

    const maskedEmail = await userResolver.User.email(parent, {}, ctx);

    expect(maskedEmail).toBe('p***@example.com');
  });
});

Testing Subscriptions (WebSocket)

Subscriptions run over WebSocket and require a client that speaks the graphql-ws protocol. Use the graphql-ws client directly in tests.

import { createClient } from 'graphql-ws';
import WebSocket from 'ws';

describe('order subscriptions', () => {
  let client: ReturnType<typeof createClient>;

  beforeEach(() => {
    client = createClient({
      url: 'ws://localhost:4000/graphql',
      webSocketImpl: WebSocket,
    });
  });

  afterEach(() => client.dispose());

  test('receives order status updates', async () => {
    const received: unknown[] = [];

    await new Promise<void>((resolve, reject) => {
      const unsub = client.subscribe(
        {
          query: `
            subscription OrderStatus($orderId: ID!) {
              orderStatusChanged(orderId: $orderId) {
                orderId
                status
                updatedAt
              }
            }
          `,
          variables: { orderId: 'order-123' },
        },
        {
          next: (value) => {
            received.push(value.data);
            if (received.length >= 2) {
              unsub();
              resolve();
            }
          },
          error: reject,
          complete: resolve,
        }
      );

      // Trigger the updates from another client or API call
      setTimeout(() => triggerOrderUpdate('order-123', 'PROCESSING'), 50);
      setTimeout(() => triggerOrderUpdate('order-123', 'SHIPPED'), 100);
    });

    expect(received).toHaveLength(2);
    expect(received[0]).toMatchObject({ status: 'PROCESSING' });
    expect(received[1]).toMatchObject({ status: 'SHIPPED' });
  });
});

For unit-level subscription testing, test the async generator that backs your resolver:

test('subscription resolver emits events for matching order', async () => {
  const ctx = createMockContext({ userId: 'user-1' });
  const generator = orderResolver.Subscription.orderStatusChanged.subscribe(
    null,
    { orderId: 'order-123' },
    ctx
  );

  // Emit a mock event
  pubsub.publish('ORDER_UPDATED', {
    orderId: 'order-123',
    status: 'PROCESSING',
  });

  const { value } = await generator.next();
  expect(value.orderStatusChanged.status).toBe('PROCESSING');
});

Schema Validation Testing

The schema is the contract. Validate it in CI to catch breaking changes before they ship.

import { buildSchema, validateSchema, lexicographicSortSchema, printSchema } from 'graphql';
import { readFileSync } from 'fs';

test('schema is valid', () => {
  const typeDefs = readFileSync('./src/schema.graphql', 'utf8');
  const schema = buildSchema(typeDefs);
  const errors = validateSchema(schema);

  expect(errors).toHaveLength(0);
});

For detecting breaking changes between releases, use @graphql-inspector/core:

npm install --save-dev @graphql-inspector/core
import { diff, CriticalityLevel } from '@graphql-inspector/core';
import { loadSchema } from '@graphql-tools/load';
import { GraphQLFileLoader } from '@graphql-tools/graphql-file-loader';

test('no breaking changes introduced', async () => {
  const oldSchema = await loadSchema('./schema.baseline.graphql', {
    loaders: [new GraphQLFileLoader()],
  });
  const newSchema = await loadSchema('./src/schema.graphql', {
    loaders: [new GraphQLFileLoader()],
  });

  const changes = await diff(oldSchema, newSchema);
  const breaking = changes.filter(
    (c) => c.criticality.level === CriticalityLevel.Breaking
  );

  if (breaking.length > 0) {
    console.log('Breaking changes:');
    breaking.forEach((c) => console.log(` - ${c.message}`));
  }

  expect(breaking).toHaveLength(0);
});

Automate this in CI: run the schema diff against the last tagged release. Fail the build on breaking changes.

Common GraphQL Test Mistakes

Testing HTTP status instead of errors array. GraphQL returns 200 for application-level errors. Always check response.body.errors.

Not testing partial responses. A query can return partial data with errors. Mock this in your MockedProvider tests — it happens in production when one resolver fails and others succeed.

Ignoring __typename. Apollo Client uses __typename for cache normalization. Omit it in mocks and you'll get cryptic cache bugs. Either set addTypename={false} on MockedProvider for all tests, or include __typename in every mock result.

Testing queries, not mutations. Mutations change state. Test the before and after, not just the return value. Verify the database record, the cache invalidation, the side effects.

No error extension assertions. Your resolvers should throw structured errors with extensions.code. Test for UNAUTHENTICATED, FORBIDDEN, NOT_FOUND, VALIDATION_ERROR — not just expect(errors).toHaveLength(1).

Mocking too deep. If your integration test mocks the database, you're not testing the integration. Mock the database only in unit tests. Integration tests should hit a real (test) database.

Skipping N+1 tests. Add a test that makes a query returning a list and counts database calls. If you load 10 users and their posts, you should see 2 queries (users + posts via DataLoader), not 11.

test('loads users with posts without N+1', async () => {
  const queryCount = { value: 0 };
  db.on('query', () => queryCount.value++);

  await client.request(GET_USERS_WITH_POSTS, { first: 10 });

  expect(queryCount.value).toBeLessThanOrEqual(3);
});

End-to-End GraphQL Testing with Playwright and HelpMeTest

Unit and integration tests cover the API contract. E2E tests cover the user's experience — the full chain from browser to database.

With Playwright, you test the network layer directly:

import { test, expect } from '@playwright/test';

test('search returns products matching query', async ({ page }) => {
  // Intercept and verify the GraphQL request
  const searchRequest = page.waitForRequest((req) => {
    if (req.url().includes('/graphql') && req.method() === 'POST') {
      const body = req.postDataJSON();
      return body?.operationName === 'SearchProducts';
    }
    return false;
  });

  await page.goto('/products');
  await page.getByPlaceholder('Search products').fill('widget');
  await page.getByRole('button', { name: 'Search' }).click();

  const req = await searchRequest;
  const body = req.postDataJSON();
  expect(body.variables.query).toBe('widget');

  // Verify the UI renders the response
  await expect(page.getByTestId('product-card')).toHaveCount(3);
  await expect(page.getByText('Widget Pro')).toBeVisible();
});

You can also mock GraphQL responses in Playwright to test error states without a server:

test('shows error state when search fails', async ({ page }) => {
  await page.route('**/graphql', async (route) => {
    const body = route.request().postDataJSON();
    if (body?.operationName === 'SearchProducts') {
      await route.fulfill({
        status: 200,
        contentType: 'application/json',
        body: JSON.stringify({
          data: null,
          errors: [{ message: 'Search service unavailable', extensions: { code: 'SERVICE_ERROR' } }],
        }),
      });
    } else {
      await route.continue();
    }
  });

  await page.goto('/products');
  await page.getByPlaceholder('Search products').fill('widget');
  await page.getByRole('button', { name: 'Search' }).click();

  await expect(page.getByRole('alert')).toContainText('Search service unavailable');
});

For ongoing monitoring, you need tests that run 24/7 — not just on deploy. A search that breaks at 3am should alert you before users report it.

HelpMeTest runs Playwright-based E2E tests continuously against your production GraphQL API. Write the test scenario in plain English, let the AI generate the Playwright steps, and get alerts the moment something breaks.

The free plan covers 10 tests with 24/7 monitoring — enough to cover your critical GraphQL paths: authentication, core queries, mutations that write data, and subscription connectivity. The pro plan at $100/month gives you unlimited tests and API testing support for direct GraphQL endpoint monitoring without a browser.

Setup takes under 10 minutes: connect your URL, describe your test scenarios, and HelpMeTest generates the test code. Your search, checkout, and authentication flows get covered immediately. When your next deploy breaks a resolver, you find out before customers do.

The stack covered here — unit tests for resolvers, MockedProvider for components, supertest for integration, schema validation in CI, Playwright for E2E — is the full picture. Each layer catches different failure modes. Skip any layer and you have a blind spot. Run them all and you ship with confidence.

Read more