intermediate·9 min read·Updated May 1, 2026

GraphQL Testing Guide: Patterns, Pitfalls, and What REST Testers Miss

GraphQL tests fail in ways REST tests never do. Here's what to cover and why.

Why GraphQL testing needs its own playbook

You can't lift REST testing habits and drop them onto GraphQL. Five things break:

  1. HTTP status is nearly useless. GraphQL returns 200 even for most errors. Tests that rely on status codes miss real failures.
  2. Schema is the contract. Drift is silent, and clients break without warning.
  3. N+1 bugs hide in resolvers. A query that looks fast for one user times out for a thousand.
  4. Partial responses are normal. data is partially populated and errors is non-empty — that's a valid GraphQL response, not a failure.
  5. Over-specified queries create brittle tests. Ask for 40 fields, assert on 3, and the suite breaks when field 17 moves.

Golden rule: check errors before data

Every GraphQL response has this shape:

{ "data": { ... } | null, "errors": [ ... ] (optional) }

HTTP 200 doesn't mean success. The real check is:

assert response.status_code == 200
assert "errors" not in response.json() or response.json()["errors"] == []
# NOW you can assert on data

Most GraphQL test bugs trace back to skipping this step.

Minimal queries

POST/graphql
Minimal query — only the fields we'll assert on.
curl -X POST 'https://demo.totalshiftleft.ai/graphql' \
  -H 'Content-Type: application/json' \
  -d '{"query":"{ users(limit: 2) { items { id name email } } }"}'

Rule: ask only for fields you assert on. If your test only cares that users exist, don't ask for email, role, and created_at. Fewer fields = faster, clearer, less brittle tests.

Compare:

# Brittle
{ users { items { id name email role createdAt lastLoginAt timezone preferences { ... } } } }

# Good
{ users { items { id } } }

The brittle version breaks if timezone gets renamed. The good version doesn't care.

Testing invalid queries

POST/graphql
A query with an invalid field — GraphQL will return 400 + errors.
curl -X POST 'https://demo.totalshiftleft.ai/graphql' \
  -H 'Content-Type: application/json' \
  -d '{"query":"{ users(limit: 2) { items { id name nonExistentField } } }"}'

A query referencing a non-existent field returns HTTP 400 with:

{ "errors": [{ "message": "Cannot query field 'nonExistentField' on type 'User'", "extensions": { "code": "GRAPHQL_VALIDATION_FAILED" } }] }

This is a schema validation error. Your negative tests should:

  • Assert HTTP 400.
  • Assert errors[0].extensions.code is GRAPHQL_VALIDATION_FAILED.
  • Assert the error message mentions the bad field (loose match).

Schema contract testing

The single most important GraphQL test is: has the schema changed in a breaking way?

Steps:

  1. On each CI run, introspect the live schema:
    { __schema { types { name fields { name type { name } } } } }
    
  2. Diff it against a committed schema.graphql snapshot.
  3. Use a tool like graphql-inspector to classify changes as breaking, dangerous, or safe.
  4. Fail the build on breaking changes unless explicitly whitelisted.

Types of breaking changes:

  • Removing a field clients use.
  • Changing a field's type (e.g., IntString).
  • Making a nullable field non-nullable (actually mostly safe, but check).
  • Removing or renaming a type.
  • Changing argument types or making optional args required.

Types of safe changes:

  • Adding new fields.
  • Adding new types.
  • Making required args optional.

N+1 detection

The #1 GraphQL performance bug. You query 10 users with their posts; the server runs 1 user query + 10 post queries instead of 1 user query + 1 post query. Works fine in dev with small data, explodes in prod.

Detection in tests:

  • Run the query with EXPLAIN-equivalent logging enabled.
  • Count the SQL queries executed during the resolver run.
  • Assert the count is under a threshold (e.g., <= users.count + 1).

Most GraphQL servers expose this via a tracing extension (Apollo, Mercurius, graphql-java all have options). Wire it into CI and fail the build on regressions.

Mutation tests

For every mutation, cover:

  1. Happy path — valid input, assert data.{mutation} returns the expected shape, errors is empty.
  2. Validation errors — bad input (missing required, wrong type, out-of-range), assert HTTP 200 + errors[0].extensions.code matches a known validation code.
  3. Authorization — if the mutation requires auth, call unauthenticated and assert UNAUTHENTICATED error.
  4. Side-effects — call a query after the mutation to verify the write landed.
  5. Idempotency — if the mutation should be idempotent (rare in GraphQL, but possible), call twice and assert consistent outcome.

Fragments for test data management

GraphQL queries get repetitive. Use fragments:

fragment UserBasics on User {
  id
  name
  email
}

query Users { users { items { ...UserBasics } } }
query User($id: ID!) { user(id: $id) { ...UserBasics } }

When the schema changes, you update one fragment, not ten queries.

Pagination edge cases

Most GraphQL APIs use cursor-based pagination:

{
  users(first: 10, after: "cursor-xyz") {
    edges { node { id } cursor }
    pageInfo { hasNextPage endCursor }
  }
}

Tests should cover:

  • First page without after.
  • Middle page with a valid cursor.
  • Last page (hasNextPage: false).
  • Invalid cursor (assert graceful error, not 500).
  • Page size of 0 (should 400 or return empty, document which).
  • Page size over max (should 400 or cap silently).

Authorization per-field

GraphQL allows per-field authorization. A query can succeed for most fields and fail for one:

{
  "data": { "user": { "name": "Alice", "email": null } },
  "errors": [{ "message": "Forbidden", "path": ["user", "email"] }]
}

This is valid — not a bug. Your tests need to:

  • Assert specific fields are populated for the allowed role.
  • Assert specific fields come back null with a corresponding error for the denied role.
  • Never fail the whole assertion just because errors is non-empty — inspect which paths failed.

Common mistakes

1. Treating 200 as success. Already covered, but it's the #1 trap.

2. Over-specifying selection sets. Makes tests brittle and slow.

3. No schema snapshot. Silent breaking changes ship to prod.

4. Not testing N+1. Performance-regression the moment data grows.

5. Asserting on error messages verbatim. Messages are human-readable and rewritten often. Assert on extensions.code instead.

6. Ignoring path in errors. The path tells you which field in the query errored. Use it.

What's next

Next: WebSockets intro — the third GraphQL operation type (subscriptions) rides on WebSockets, and WebSocket testing deserves its own treatment.

Related lessons

Read more on the blog