GraphQL Testing Strategies: Schema, Resolver, and Integration Testing
GraphQL Testing Strategies: Schema, Resolver, and Integration Testing
Testing GraphQL APIs is fundamentally different from testing REST. With REST, each endpoint has a fixed request/response shape -- you test the endpoint, you've tested the contract. With GraphQL, clients choose their own query shape from your schema. A single resolver might serve thousands of different query combinations. Your testing strategy needs to account for this flexibility, and the patterns that work for REST don't transfer cleanly.
The Three Layers of GraphQL Testing
A solid GraphQL testing strategy has three layers:
- Schema testing: Does your schema validate? Are breaking changes caught before deployment?
- Resolver testing: Does each resolver correctly fetch, transform, and return data?
- Integration testing: Does a full GraphQL query execute correctly against real data sources?
Each layer catches different classes of bugs. Skip any one and you'll have gaps.
Schema Testing and Validation
Schema Linting with graphql-schema-linter
Your schema is an API contract. Linting catches structural problems -- inconsistent naming, missing descriptions, deprecated patterns -- before they reach clients.
npm install -g graphql-schema-linter
# .graphql-schema-linter.config.yml
rules:
- defined-types-are-used
- deprecations-have-a-reason
- descriptions-are-capitalized
- enum-values-all-caps
- fields-have-descriptions
- input-object-fields-sorted-alphabetically
- types-are-capitalized
- relay-connection-types-spec
graphql-schema-linter schema.graphql
This catches problems like enum values that aren't uppercase, fields without descriptions, and types defined but never referenced. Not glamorous, but it prevents schema rot.
Breaking Change Detection with GraphQL Inspector
GraphQL Inspector compares two versions of your schema and identifies breaking changes, dangerous changes, and safe changes.
npm install -g @graphql-inspector/cli
# Compare current schema against what's deployed
graphql-inspector diff 'git:origin/main:schema.graphql' 'schema.graphql'
Output:
✖ Field 'User.email' was removed
This is a breaking change. Existing queries referencing this field will fail.
⚠ Field 'User.emailAddress' was added
New field. Safe change.
⚠ Argument 'limit' on 'Query.users' changed default from 10 to 20
Dangerous change. Existing clients relying on the default will get different results.
Integrate this into CI to block PRs that introduce breaking changes without a deprecation period:
# .github/workflows/schema-check.yml
name: Schema Check
on: pull_request
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- run: npm install -g @graphql-inspector/cli
- run: graphql-inspector diff 'git:origin/main:schema.graphql' 'schema.graphql'
Apollo Studio Schema Checks
If you're using Apollo's managed federation or Apollo Studio, schema checks are built into the platform:
# Register your schema
npx rover graph publish my-graph@production --schema schema.graphql
# Check a proposed schema against production traffic
npx rover graph check my-graph@production --schema schema.graphql
Apollo Studio compares your proposed schema against actual query traffic from the last N days. It doesn't just check for structural breaks -- it tells you whether any real clients are using the fields you're changing. This is the most accurate way to assess breaking change impact, but it requires sending your operation data to Apollo's platform.
Resolver Unit Testing
Resolvers are functions. Test them like functions -- pass in arguments, mock data sources, assert outputs.
Testing a Query Resolver
import { describe, test, expect, beforeEach } from "vitest";
import { resolvers } from "./resolvers";
// Mock data source
const mockUserDB = {
findById: vi.fn(),
findMany: vi.fn(),
};
const mockContext = {
dataSources: { users: mockUserDB },
currentUser: { id: "user-1", role: "admin" },
};
describe("Query.user", () => {
beforeEach(() => {
vi.clearAllMocks();
});
test("returns user by ID", async () => {
const expectedUser = { id: "user-1", name: "Alice", email: "[email protected]" };
mockUserDB.findById.mockResolvedValue(expectedUser);
const result = await resolvers.Query.user(
null, // parent
{ id: "user-1" }, // args
mockContext, // context
{} as any, // info (rarely needed in unit tests)
);
expect(result).toEqual(expectedUser);
expect(mockUserDB.findById).toHaveBeenCalledWith("user-1");
});
test("returns null for non-existent user", async () => {
mockUserDB.findById.mockResolvedValue(null);
const result = await resolvers.Query.user(
null,
{ id: "nonexistent" },
mockContext,
{} as any,
);
expect(result).toBeNull();
});
});
Testing a Mutation Resolver
Mutations are where business logic concentrates. Test validation, authorization, and side effects:
describe("Mutation.createPost", () => {
test("creates post for authenticated user", async () => {
const mockPostDB = {
create: vi.fn().mockResolvedValue({
id: "post-1",
title: "Test Post",
authorId: "user-1",
}),
};
const ctx = {
dataSources: { posts: mockPostDB },
currentUser: { id: "user-1", role: "user" },
};
const result = await resolvers.Mutation.createPost(
null,
{ input: { title: "Test Post", body: "Content here" } },
ctx,
{} as any,
);
expect(result.title).toBe("Test Post");
expect(mockPostDB.create).toHaveBeenCalledWith({
title: "Test Post",
body: "Content here",
authorId: "user-1",
});
});
test("throws when unauthenticated", async () => {
const ctx = {
dataSources: { posts: { create: vi.fn() } },
currentUser: null,
};
await expect(
resolvers.Mutation.createPost(
null,
{ input: { title: "Test", body: "Body" } },
ctx,
{} as any,
),
).rejects.toThrow("Authentication required");
});
test("validates title length", async () => {
const ctx = {
dataSources: { posts: { create: vi.fn() } },
currentUser: { id: "user-1", role: "user" },
};
await expect(
resolvers.Mutation.createPost(
null,
{ input: { title: "", body: "Body" } },
ctx,
{} as any,
),
).rejects.toThrow("Title is required");
});
});
Testing Field Resolvers
Field resolvers handle computed fields and relationships. These are easy to overlook:
describe("User.fullName", () => {
test("concatenates first and last name", () => {
const parent = { firstName: "Alice", lastName: "Smith" };
const result = resolvers.User.fullName(parent, {}, {}, {} as any);
expect(result).toBe("Alice Smith");
});
test("handles missing last name", () => {
const parent = { firstName: "Alice", lastName: null };
const result = resolvers.User.fullName(parent, {}, {}, {} as any);
expect(result).toBe("Alice");
});
});
Integration Testing
Integration tests execute real GraphQL queries against your server with real (or realistic) data sources. This is where you catch problems that unit tests miss: incorrect resolver chaining, N+1 queries, authorization bugs that only surface with real data.
Testing with a Real Server
import { describe, test, expect, beforeAll, afterAll } from "vitest";
import { createTestServer } from "./test-utils";
let server: Awaited<ReturnType<typeof createTestServer>>;
beforeAll(async () => {
server = await createTestServer(); // starts server + test database
});
afterAll(async () => {
await server.stop();
});
describe("User queries", () => {
test("fetches user with posts", async () => {
// Seed test data
await server.db.user.create({
data: {
id: "user-1",
name: "Alice",
posts: {
create: [
{ id: "post-1", title: "First Post" },
{ id: "post-2", title: "Second Post" },
],
},
},
});
const response = await server.executeOperation({
query: `
query GetUser($id: ID!) {
user(id: $id) {
name
posts {
title
}
}
}
`,
variables: { id: "user-1" },
});
expect(response.body.singleResult.errors).toBeUndefined();
expect(response.body.singleResult.data).toEqual({
user: {
name: "Alice",
posts: [
{ title: "First Post" },
{ title: "Second Post" },
],
},
});
});
test("returns null for unauthorized fields", async () => {
const response = await server.executeOperation(
{
query: `
query GetUser($id: ID!) {
user(id: $id) {
name
email
secretAdminNotes
}
}
`,
variables: { id: "user-1" },
},
{ contextValue: { currentUser: { id: "user-2", role: "user" } } },
);
const data = response.body.singleResult.data;
expect(data.user.name).toBe("Alice");
expect(data.user.secretAdminNotes).toBeNull();
});
});
Type-Safe Tests with graphql-codegen
Use @graphql-codegen/cli to generate TypeScript types from your schema and operations. This makes your test queries type-safe:
# codegen.ts
import type { CodegenConfig } from "@graphql-codegen/cli";
const config: CodegenConfig = {
schema: "schema.graphql",
documents: ["src/**/*.graphql", "tests/**/*.graphql"],
generates: {
"src/__generated__/types.ts": {
plugins: ["typescript", "typescript-operations"],
},
"tests/__generated__/test-types.ts": {
plugins: ["typescript", "typescript-operations"],
documents: ["tests/**/*.graphql"],
},
},
};
export default config;
Define test queries in .graphql files:
# tests/queries/GetUser.graphql
query GetUser($id: ID!) {
user(id: $id) {
name
email
posts {
title
createdAt
}
}
}
Now your test variables and responses are typed:
import type { GetUserQuery, GetUserQueryVariables } from "./__generated__/test-types";
// TypeScript catches typos in variable names and query fields
const variables: GetUserQueryVariables = { id: "user-1" };
GraphQL-Specific Testing Patterns
Testing the N+1 Problem
GraphQL's nested resolution model makes N+1 queries a constant risk. Test for it explicitly:
test("user.posts does not cause N+1 queries", async () => {
// Seed 10 users with 5 posts each
await seedUsers(10, { postsEach: 5 });
const queryLog: string[] = [];
server.db.$on("query", (e) => queryLog.push(e.query));
await server.executeOperation({
query: `
query {
users(limit: 10) {
name
posts { title }
}
}
`,
});
// With DataLoader: should be 2 queries (users + posts batch)
// Without DataLoader: would be 11 queries (users + 10 individual post queries)
const postQueries = queryLog.filter((q) => q.includes("posts"));
expect(postQueries.length).toBeLessThanOrEqual(1);
});
Testing Error Handling
GraphQL returns errors differently from REST. Test that errors are properly formatted:
test("returns validation errors in GraphQL format", async () => {
const response = await server.executeOperation({
query: `
mutation {
createPost(input: { title: "", body: "" }) {
id
}
}
`,
});
const errors = response.body.singleResult.errors;
expect(errors).toHaveLength(1);
expect(errors[0].message).toBe("Validation failed");
expect(errors[0].extensions.code).toBe("BAD_USER_INPUT");
expect(errors[0].extensions.validationErrors).toEqual([
{ field: "title", message: "Title is required" },
{ field: "body", message: "Body is required" },
]);
});
Testing Subscriptions
Subscriptions require async iteration testing:
test("receives new post notifications", async () => {
const subscription = server.executeSubscription({
query: `
subscription {
postCreated {
title
author { name }
}
}
`,
});
// Trigger the event
await server.executeOperation({
query: `
mutation {
createPost(input: { title: "New Post", body: "Content" }) {
id
}
}
`,
});
const result = await subscription.next();
expect(result.value.data.postCreated.title).toBe("New Post");
});
GraphQL vs REST Testing: Key Differences
| Aspect | REST | GraphQL |
|---|---|---|
| Contract testing | Each endpoint is a contract | Schema is the contract |
| Breaking changes | New endpoint version | Schema diff + usage analysis |
| Response shape | Fixed per endpoint | Client-determined |
| N+1 queries | Rare (server-controlled) | Common (client-driven nesting) |
| Error format | HTTP status codes | errors array in response |
| Overfetching | Common | Rare (but underfetching possible) |
| Test coverage | Test each endpoint | Test resolvers + query combinations |
The biggest difference: REST tests can exhaustively cover all response shapes because they're fixed. GraphQL tests can't cover every possible query combination, so you need to be strategic -- test common patterns, edge cases in resolver logic, and authorization boundaries.
Tools Summary
| Tool | Purpose | When to Use |
|---|---|---|
| GraphQL Inspector | Schema diffing, breaking change detection | CI pipeline on every PR |
| Apollo Studio | Schema checks against real traffic | Production schema governance |
| graphql-schema-linter | Schema convention enforcement | CI pipeline |
| graphql-codegen | Type-safe test queries | All TypeScript GraphQL projects |
| DataLoader | N+1 prevention (and testability) | Every GraphQL server |
Recommendations
Minimum viable testing: Schema validation in CI (GraphQL Inspector) + resolver unit tests for business logic + a handful of integration tests for critical query paths. This catches most bugs with reasonable effort.
For production APIs: Add Apollo Studio schema checks against real traffic data. The ability to see "0 clients use this field" before removing it is invaluable.
For TypeScript teams: Always use graphql-codegen for typed test queries. Untyped test queries silently pass with wrong field names and give false confidence.
Testing priorities: Authorization logic first (security), mutation side effects second (data integrity), query resolvers third (correctness), field resolvers last (usually trivial). Don't waste time testing auto-generated CRUD resolvers that your framework provides.