← All articles
TESTING GraphQL Testing Strategies: Schema, Resolver, and In... 2026-02-09 · 8 min read · graphql · testing · apollo

GraphQL Testing Strategies: Schema, Resolver, and Integration Testing

Testing 2026-02-09 · 8 min read graphql testing apollo schema-validation

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:

  1. Schema testing: Does your schema validate? Are breaking changes caught before deployment?
  2. Resolver testing: Does each resolver correctly fetch, transform, and return data?
  3. 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.