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
| Level | Executes | Catches |
|---|---|---|
| Resolver unit test | One function with a fake context | Business logic, edge cases |
| Document integration test | Full schema execution against a test database | Wiring, loaders, nullability, auth |
| Schema snapshot | A printed SDL diff | Accidental breaking changes |
| Client contract test | Generated types compile | Queries 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.
Related
Error handling, validation and custom scalars Schema design, evolution and tooling
Last refreshed 2026-09-18.