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:
- HTTP status is nearly useless. GraphQL returns 200 even for most errors. Tests that rely on status codes miss real failures.
- Schema is the contract. Drift is silent, and clients break without warning.
- N+1 bugs hide in resolvers. A query that looks fast for one user times out for a thousand.
- Partial responses are normal.
datais partially populated anderrorsis non-empty — that's a valid GraphQL response, not a failure. - 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
/graphqlcurl -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
/graphqlcurl -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.codeisGRAPHQL_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:
- On each CI run, introspect the live schema:
{ __schema { types { name fields { name type { name } } } } } - Diff it against a committed
schema.graphqlsnapshot. - Use a tool like graphql-inspector to classify changes as breaking, dangerous, or safe.
- 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.,
Int→String). - 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:
- Happy path — valid input, assert
data.{mutation}returns the expected shape,errorsis empty. - Validation errors — bad input (missing required, wrong type, out-of-range), assert HTTP 200 +
errors[0].extensions.codematches a known validation code. - Authorization — if the mutation requires auth, call unauthenticated and assert
UNAUTHENTICATEDerror. - Side-effects — call a query after the mutation to verify the write landed.
- 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
errorsis 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
GraphQL lets the client decide what data to fetch. Here's how it works and when it beats REST.
Queries read. Mutations change state. Here's the divide — and how to write both correctly.
The same operation in three protocols. Pick the right one for the job — and test it right.