Free Ebook cover GraphQL API Design and Performance: Build Flexible Backends with Schemas, Resolvers, and Security

GraphQL API Design and Performance: Build Flexible Backends with Schemas, Resolvers, and Security

New course

21 pages

Testing Strategy: Unit, Integration, and Contract Tests for Schemas and Resolvers

Capítulo 18

Estimated reading time: 0 minutes

+ Exercise

Why GraphQL Testing Needs Multiple Layers

GraphQL concentrates a lot of behavior behind a single endpoint: schema validation, resolver logic, authorization checks, and orchestration across data sources. A single “happy path” test that posts a query to /graphql can miss important failures such as incorrect nullability behavior, missing selection-set handling, or a resolver that returns the right shape but violates a contract clients rely on. A practical testing strategy separates concerns into three layers: unit tests for pure logic and resolver behavior in isolation, integration tests for the executable schema running against real infrastructure boundaries (database, HTTP services, queues) in a controlled environment, and contract tests that lock down the schema and operation behavior so changes don’t silently break clients.

This chapter focuses on how to test schemas and resolvers without re-teaching schema design, resolver architecture, security, or observability. The goal is to build a repeatable test pyramid that gives fast feedback during development and high confidence during release.

Test Pyramid for GraphQL: What to Test Where

A useful mental model is to map each test type to a specific risk. Unit tests reduce the risk of incorrect business logic and resolver mapping. Integration tests reduce the risk of wiring issues: incorrect context construction, misconfigured data sources, and mismatched serialization. Contract tests reduce the risk of breaking clients: schema changes, field behavior changes, and operation regressions.

Unit tests (fast, many)

  • Pure functions used by resolvers (formatting, validation, mapping, policy decisions).
  • Resolver functions with mocked dependencies (repositories, HTTP clients, feature flags).
  • Edge cases: nullability, empty lists, error propagation, and conditional branching.

Integration tests (slower, fewer)

  • Execute GraphQL operations against the real executable schema.
  • Use a real database (often ephemeral) and/or a stubbed external service.
  • Verify context wiring, middleware, plugins, and resolver composition.

Contract tests (targeted, stable)

  • Schema snapshot/diff checks to detect breaking changes.
  • Operation-level “golden” tests that assert response shape and key semantics.
  • Consumer-driven contracts when multiple clients depend on the API.

Tooling Baseline: Executing Operations Without HTTP

For unit and many integration tests, you can execute GraphQL operations directly against the schema without starting an HTTP server. This keeps tests fast and avoids flakiness from ports and network timing. Most GraphQL servers expose a way to run an operation programmatically (for example, graphql() from graphql-js, or an Apollo Server test client). The key is to build an executable schema the same way production does, then inject a test context.

Establish a small test harness that creates: (1) the executable schema, (2) a context factory, and (3) a helper to execute operations and return data and errors. Keep this harness in one place so tests don’t re-implement setup logic.

Continue in our app.

You can listen to the audiobook with the screen off, receive a free certificate for this course, and also have access to 5,000 other free online courses.

Or continue reading below...
Download App

Download the app

// pseudo-code illustrating a reusable test harness pattern (Node/TypeScript style) const makeTestContext = (overrides = {}) => ({ user: overrides.user ?? null, dataSources: overrides.dataSources ?? makeMockDataSources(), now: overrides.now ?? (() => new Date('2025-01-01T00:00:00Z')), }); async function executeOperation({ query, variables, contextOverrides }) { const schema = makeExecutableSchemaForTests(); const contextValue = makeTestContext(contextOverrides); return graphql({ schema, source: query, variableValues: variables, contextValue }); }

Unit Testing Resolvers: Isolate Dependencies and Assert Semantics

Unit tests should focus on resolver semantics, not on GraphQL parsing or transport. Treat a resolver like a function that receives parent, args, context, and info, and returns a value or throws. The most common unit-test mistake is asserting too much about the internal implementation (for example, exact SQL strings). Prefer asserting observable behavior: returned values, calls to dependencies, and error conditions.

Step-by-step: unit test a query resolver

1) Identify dependencies. A resolver typically calls a repository or service. Provide a fake implementation with predictable behavior. 2) Create a minimal context object containing only what the resolver needs. 3) Call the resolver directly with representative args. 4) Assert the returned value and that dependencies were called with correct parameters.

// Example resolver (simplified) const resolvers = { Query: { order: async (_parent, { id }, ctx) => { const order = await ctx.repos.orders.getById(id); if (!order) return null; if (!ctx.policies.canViewOrder(ctx.user, order)) throw new ForbiddenError('Not allowed'); return order; }, }, }; // Unit test pseudo-code test('order returns null when not found', async () => { const ctx = { user: { id: 'u1' }, repos: { orders: { getById: async () => null } }, policies: { canViewOrder: () => true }, }; const result = await resolvers.Query.order(null, { id: 'o1' }, ctx); expect(result).toBeNull(); }); test('order throws when policy denies', async () => { const ctx = { user: { id: 'u1' }, repos: { orders: { getById: async () => ({ id: 'o1', userId: 'u2' }) } }, policies: { canViewOrder: () => false }, }; await expect(resolvers.Query.order(null, { id: 'o1' }, ctx)).rejects.toThrow('Not allowed'); });

Unit testing field resolvers: selection sets and derived fields

Field resolvers often compute derived values or join data. Unit tests should cover: (a) correct derivation, (b) correct handling of missing parent fields, and (c) correct calls to loaders/services. If a field resolver depends on the GraphQL selection set (via info), keep that logic minimal and test it with a small, explicit info stub or, better, move selection-set parsing into a helper function that can be unit tested separately.

// Derived field example: Order.totalCents sums line items function computeTotalCents(items) { return items.reduce((sum, i) => sum + i.unitPriceCents * i.quantity, 0); } const resolvers = { Order: { totalCents: (order) => computeTotalCents(order.items ?? []), }, }; test('totalCents sums items and handles missing items', () => { expect(resolvers.Order.totalCents({ items: [{ unitPriceCents: 500, quantity: 2 }] })).toBe(1000); expect(resolvers.Order.totalCents({})).toBe(0); });

Unit testing mutations: validate inputs and side effects

Mutations usually have more side effects: writing to a database, publishing events, or calling external services. In unit tests, mock those side effects and assert that the resolver calls them in the right order with the right payload. Also test idempotency rules and conflict handling if your mutation supports client-generated IDs or retry behavior.

// Mutation example: cancelOrder const resolvers = { Mutation: { cancelOrder: async (_p, { id }, ctx) => { const order = await ctx.repos.orders.getById(id); if (!order) throw new UserInputError('Unknown order'); if (!ctx.policies.canCancelOrder(ctx.user, order)) throw new ForbiddenError('Not allowed'); await ctx.repos.orders.updateStatus(id, 'CANCELED'); await ctx.events.publish('OrderCanceled', { orderId: id }); return { ok: true }; }, }, }; test('cancelOrder publishes event after status update', async () => { const calls = []; const ctx = { user: { id: 'u1' }, repos: { orders: { getById: async () => ({ id: 'o1' }), updateStatus: async () => calls.push('updateStatus'), }, }, policies: { canCancelOrder: () => true }, events: { publish: async () => calls.push('publish') }, }; const res = await resolvers.Mutation.cancelOrder(null, { id: 'o1' }, ctx); expect(res).toEqual({ ok: true }); expect(calls).toEqual(['updateStatus', 'publish']); });

Integration Testing: Execute Real GraphQL Against a Test Environment

Integration tests should exercise the executable schema as a whole: parsing, validation, resolver execution, and serialization. They are the best place to catch issues like incorrect scalar serialization, missing resolver registration, or context wiring bugs. Keep them focused: test a small set of representative operations that cover critical paths and tricky edges (nullability, lists, interfaces/unions, and error paths).

Step-by-step: integration test with an ephemeral database

1) Start a disposable database instance for tests (containerized or in-memory, depending on your stack). 2) Run migrations and seed minimal fixtures. 3) Build the executable schema using the same composition as production. 4) Execute operations programmatically (no HTTP) with a real repository implementation. 5) Assert on data, errors, and database state.

// Integration test pseudo-code outline test('query order returns items and computed total', async () => { const db = await startTestDb(); await db.migrate(); const orderId = await db.seedOrder({ items: [{ unitPriceCents: 500, quantity: 2 }] }); const schema = makeExecutableSchema({ db }); const result = await graphql({ schema, source: 'query($id: ID!){ order(id:$id){ id totalCents items{ quantity unitPriceCents } } }', variableValues: { id: orderId }, contextValue: { user: { id: 'u1' }, db }, }); expect(result.errors).toBeUndefined(); expect(result.data.order.totalCents).toBe(1000); });

Integration testing external services: stubs vs real sandboxes

If resolvers call external HTTP services, integration tests should avoid hitting real third-party endpoints. Use a local stub server that returns deterministic responses, or a mock HTTP layer that intercepts requests. The goal is to validate your client code, request construction, and response mapping without relying on network availability. Reserve “sandbox” tests for a separate pipeline stage if you truly need them.

When stubbing, assert that the resolver sends the correct request (method, path, headers, body) and handles error responses. This catches subtle regressions like missing authentication headers or incorrect query parameters.

Integration tests for error and nullability behavior

GraphQL’s error model can surprise teams: a thrown error in a field resolver may null out that field and potentially bubble nulls up depending on schema nullability. Integration tests are the right place to verify the actual response shape clients will see. Write tests that intentionally trigger failures and assert: which fields are null, whether sibling fields still resolve, and what appears in errors (message, path, extensions).

// Example: ensure partial data is returned when a nullable field fails test('nullable field failure returns partial data', async () => { const schema = makeExecutableSchemaForTests({ failShipping: true }); const res = await graphql({ schema, source: '{ order(id:"o1"){ id shippingAddress { city } } }', contextValue: makeTestContext() }); expect(res.data.order.id).toBe('o1'); expect(res.data.order.shippingAddress).toBeNull(); expect(res.errors[0].path).toEqual(['order', 'shippingAddress']); });

Contract Testing: Lock Down Schema and Client Expectations

Contract tests are about preventing accidental breaking changes. They work best when they are automated in CI and run on every change to the schema or resolvers. There are two complementary approaches: schema contracts (what types/fields exist and their signatures) and operation contracts (how specific queries behave).

Schema contract tests: snapshot and diff

A schema contract test typically prints the schema (SDL) and compares it to a committed snapshot. If the schema changes, the test fails and forces a review. This is not about blocking all change; it is about making change explicit. To make schema snapshots useful, ensure the printed schema is stable (sorted, consistent directives) and generated from the same build step as production.

Step-by-step: 1) Generate the schema SDL from the executable schema. 2) Normalize it (stable ordering). 3) Compare to a committed file. 4) If changed, require a human-reviewed update to the snapshot.

// Schema snapshot pseudo-code test('schema has no unexpected changes', () => { const schema = makeExecutableSchemaForTests(); const sdl = printSchema(schema); expect(normalizeSDL(sdl)).toMatchSnapshot(); });

To go beyond snapshots, add a breaking-change detector that compares the current schema to the last released schema and flags changes like removing fields, tightening nullability, changing argument types, or removing enum values. This is especially valuable when multiple teams contribute to the same API.

Operation contract tests: golden responses for critical queries

Schema snapshots don’t tell you if a resolver changed semantics. Operation contract tests execute a curated set of GraphQL operations and compare the response to an approved “golden” result. These tests should focus on stable, business-critical behavior and avoid volatile fields like timestamps unless they are normalized.

Step-by-step: 1) Choose a small set of operations that represent key client flows. 2) Seed deterministic fixtures. 3) Execute the operation against the executable schema. 4) Normalize non-deterministic values (IDs, timestamps) if needed. 5) Compare to a stored snapshot or explicit assertions.

// Golden operation test pseudo-code test('GetOrder operation contract', async () => { await seedDeterministicFixtures(); const res = await executeOperation({ query: 'query GetOrder($id:ID!){ order(id:$id){ id status totalCents } }', variables: { id: 'o-fixed-1' }, contextOverrides: { user: { id: 'u-fixed-1' } }, }); expect(res.errors).toBeUndefined(); expect(res.data).toMatchSnapshot(); });

Consumer-driven contracts (CDC) for GraphQL

When you have multiple client applications, a powerful approach is to let each client publish the set of operations it depends on (often as persisted queries or a manifest). The server then runs those operations in CI against the proposed schema and resolver changes. This catches breaking changes even when the schema still “looks compatible” but an operation fails validation or returns a different shape.

Implement CDC in a pragmatic way: collect operations from clients, validate them against the schema (static check), and optionally execute them against seeded data (behavioral check). The static check catches signature breaks; the behavioral check catches semantic breaks.

Practical Patterns for Stable, Maintainable GraphQL Tests

Use factories and deterministic fixtures

Flaky tests often come from random IDs, timestamps, and ordering. Use factories that can generate deterministic entities, and freeze time in tests. If ordering matters, assert explicitly on sort order or normalize lists before snapshotting.

Prefer “assert the contract” over “assert the implementation”

For unit tests, assert that a resolver calls a repository method with the right parameters and returns the right value, not that it uses a specific internal helper. For integration and contract tests, assert on GraphQL responses: field presence, nullability outcomes, and error paths. This keeps tests resilient to refactors.

Test boundaries: validation, coercion, and custom scalars

GraphQL performs input coercion and validation before resolvers run. If you have custom scalars (dates, money, JSON), add integration tests that verify serialization and parsing. Also test boundary values: empty strings, large numbers, invalid formats, and missing required inputs. These tests catch regressions when scalar implementations change.

Test directives and middleware effects

If you use schema directives or middleware for cross-cutting behavior (for example, masking fields, enforcing policies, or transforming results), integration tests should cover at least one operation that exercises each directive/middleware path. Unit tests can cover directive logic in isolation if it is implemented as a pure function, but the integration test ensures it is actually applied to the schema.

Organize tests by operation and by resolver

A maintainable structure is to have: (1) unit tests colocated with resolver modules, and (2) integration/contract tests organized by GraphQL operation name (for example, GetOrder.test, CancelOrder.test). This mirrors how clients think and makes it easier to identify what broke when a test fails.

CI Workflow: Fast Feedback and Release Confidence

A practical CI pipeline runs unit tests on every commit, integration tests on pull requests, and contract tests on pull requests plus release branches. Schema diff checks should run early and fail fast. Operation contract tests should run with deterministic fixtures and minimal external dependencies. If you maintain a “last released schema” artifact, include it in the repository or fetch it from a trusted build artifact store so breaking-change detection is consistent across environments.

When a contract test fails, treat it as a product decision, not just a technical failure: either update the contract intentionally (and communicate to clients), or adjust the implementation to preserve behavior. This is how tests become a safety net rather than a speed bump.

Now answer the exercise about the content:

Which testing layer is primarily responsible for preventing accidental breaking changes that could silently impact GraphQL clients?

You are right! Congratulations, now go to the next page

You missed! Try again.

Contract tests are designed to protect clients by detecting schema and operation behavior changes, using schema snapshot/diff checks and operation-level golden tests.

Next chapter

Deployment and Operations: CI/CD, Configuration, and Environment Management

Arrow Right Icon
Download the app to earn free Certification and listen to the courses in the background, even with the screen off.