Testing GraphQL APIs

Resolver unit tests, integration tests that execute a real document, schema snapshots, mocking data sources and testing error paths.

Choosing the level

LevelExecutesCatches
Resolver unit testOne function with a fake contextBusiness logic, edge cases
Document integration testFull schema execution against a test databaseWiring, loaders, nullability, auth
Schema snapshotA printed SDL diffAccidental breaking changes
Client contract testGenerated types compileQueries that no longer match the schema
import { ApolloServer } from "@apollo/server";
import { startServerAndCreateTestClient } from "@apollo/server-integration-testing";

const testServer = new ApolloServer({ typeDefs, resolvers });

it("returns the author for each book without N+1", async () => {
  const { data, errors } = await testServer.executeOperation(
    {
      query: "query ($first: Int) { books(first: $first) { nodes { id author { name } } } }",
      variables: { first: 5 },
    },
    { contextValue: { db: testDb, user: testUser, loaders: createLoaders(testDb), dbCalls: 0 } },
  );

  expect(errors).toBeUndefined();
  expect(data.books.nodes).toHaveLength(5);
  expect(contextValue.dbCalls).toBeLessThanOrEqual(2);
});
💡
Test with a document, not by calling resolvers directly, for anything that crosses a field boundary. Half the real bugs in a GraphQL server live in the wiring: a loader created outside the context, a null propagating further than intended, an authorization check on the wrong field.

Testing the error paths

it("returns a typed error for an expired coupon", async () => {
  const { data, errors } = await execute(PLACE_ORDER, { couponCode: "EXPIRED" });

  expect(errors).toBeUndefined();
  expect(data.placeOrder.order).toBeNull();
  expect(data.placeOrder.errors[0]).toMatchObject({ field: "couponCode", code: "COUPON_EXPIRED" });
});

it("rejects an unauthenticated request", async () => {
  const { errors } = await server.executeOperation({ query: ME }, { contextValue: { user: null } });
  expect(errors[0].extensions.code).toBe("UNAUTHENTICATED");
});

it("hides a field the caller cannot read", async () => {
  const { data, errors } = await execute(BOOK_DRAFT, {}, { user: otherUser });
  expect(data.book.draftBody).toBeNull();
  expect(errors[0].extensions.code).toBe("FORBIDDEN");
});
  • Assert on extensions.code, not on the message text - messages get reworded and translations change.
  • Test the partial-success case explicitly: a nullable field failing while the rest of the response succeeds.
  • Cover nullability boundaries: a resolver returning null for a non-null field should produce an error and propagate correctly.
  • Include a test that an unauthorised caller gets no data rather than an empty object - silent empty results are how leaks hide.

Schema snapshots and mocking

import { printSchema, lexicographicSortSchema } from "graphql";

it("matches the published schema", () => {
  const current = printSchema(lexicographicSortSchema(schema));
  expect(current).toMatchSnapshot();
});

// mock a data source so resolver tests stay fast and deterministic
const fakeBookRepo = {
  findPage: vi.fn().mockResolvedValue({ nodes: [{ id: "1", title: "A", authorId: "42" }] }),
  findManyByIds: vi.fn().mockResolvedValue([{ id: "42", name: "Le Guin" }]),
};
  • Sort the printed schema before snapshotting, or a reordering of type definitions produces a meaningless diff.
  • A schema snapshot failing is not automatically a bug - review the diff and update deliberately. That review is the point.
  • Mock at the data source, not the loader: mocking the loader hides exactly the batching behaviour you want to verify.
  • Run the suite against the real database engine in at least one job; a mocked repository never catches a bad SQL query.

FAQ

How do I test subscriptions?
Subscribe with a real WebSocket test client, perform the mutation, and await the payload with a short timeout. Assert on both the happy path and that a client without permission receives nothing.
Should I unit test resolvers at all?
Yes, for resolvers with branching logic that is awkward to reach through a document. Keep them as plain functions taking a context object, and they become easy to test without a server.

Error handling, validation and custom scalars Schema design, evolution and tooling

Last refreshed 2026-09-18.