GraphQL Testing
What you need to know to test a GraphQL API — how it differs from REST, what breaks, and what to assert. Pair this with the API Testing Concepts sheet for the REST side.
REST vs GraphQL — what changes for testing
GraphQL exposes a single endpoint (usually POST /graphql) and the client describes exactly what it wants. That inverts several REST testing habits.
| Aspect | REST | GraphQL — testing implication |
|---|---|---|
| Endpoints | Many URLs, one per resource | One URL — you test operations, not paths |
| Status codes | Errors via 4xx/5xx | Usually 200 OK even on errors — assert on the errors array, not status |
| Over/under-fetching | Common | Client picks fields — test that field selection is honoured |
| Shape | Server decides | Client decides — response shape mirrors the query |
| Discovery | Docs/OpenAPI | Introspection — the schema is queryable |
The single biggest testing trap: GraphQL returns 200 OK for most errors. A failed query comes back as 200 with an errors array in the body. Asserting status === 200 proves nothing — you must assert on body.errors and body.data.
Anatomy of a request
A GraphQL request is a POST with a JSON body carrying query, optional variables, and optional operationName.
curl -X POST https://api.example.com/graphql \
-H "Content-Type: application/json" \
-d '{
"query": "query GetUser($id: ID!) { user(id: $id) { name email } }",
"variables": { "id": "42" }
}'{
"data": {
"user": { "name": "Alice", "email": "a@x.com" }
}
}Always pass dynamic values through variables, not by string-interpolating them into the query — the same discipline as parameterised SQL, and for the same injection reasons.
Operation types
| Operation | Purpose | Test focus |
|---|---|---|
query | Read data | Field selection, nesting, nullability, pagination |
mutation | Write data | Side effects, return payload, error handling, idempotency |
subscription | Real-time stream (WebSocket) | Connection, message delivery, teardown |
Mutations are where state changes, so test both the returned payload and the persisted result with a follow-up query — a mutation can return a success payload while the write silently fails downstream.
Querying fields and nesting
query {
order(id: "1001") {
total
customer {
name
address { city country }
}
items {
quantity
product { name price }
}
}
}Test that you get back exactly the fields you asked for and nothing more. Requesting a field that doesn't exist should produce a validation error before execution.
Variables and the schema
query GetOrders($status: OrderStatus!, $limit: Int = 10) {
orders(status: $status, first: $limit) {
id
total
}
}OrderStatus! (non-null) and Int are schema types. The ! means required — omitting a non-null variable is a validation error, which is a cheap, high-value negative test. Default values (= 10) should be exercised by omitting the variable.
The errors array — the thing you actually assert on
{
"data": { "user": null },
"errors": [
{
"message": "User not found",
"path": ["user"],
"extensions": { "code": "NOT_FOUND" }
}
]
}Key assertions for a GraphQL response:
datais present and shaped like the query (for the success case)errorsis absent or empty (success) — or present with the expectedcode(negative case)- Partial success is real: a response can carry both
data(some fields resolved) anderrors(some fields failed). Don't assume one excludes the other. - Lean on
extensions.codefor stable error assertions —messagestrings change, codes shouldn't.
Schema and introspection
The schema is queryable at runtime via introspection — useful for tests and tooling, risky in production.
query {
__schema {
types { name kind }
}
__type(name: "User") {
fields { name type { name } }
}
}Test-relevant points: use introspection (or a committed schema SDL) to detect breaking schema changes — a removed field or a field made non-null can break existing clients. Note that introspection is often disabled in production as hardening, so don't write production smoke tests that depend on it.
The N+1 problem
The classic GraphQL performance bug. A query for a list where each item resolves a nested field can fire one database query per item — 1 query for 100 orders, then 100 more for each order's customer.
query {
orders(first: 100) {
id
customer { name } # naive resolver: 1 DB hit PER order = N+1
}
}It usually isn't visible in functional tests (the response is correct) — it surfaces as latency under load. Test for it by asserting on resolver/DB call counts, or watch query timings as list size grows. The fix is server-side (DataLoader-style batching), but QA should catch it.
Authentication
Auth travels in headers, same as REST — the operation itself is in the body.
curl -X POST https://api.example.com/graphql \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{ "query": "{ me { id role } }" }'GraphQL-specific authz testing: because a single query can traverse deep into the graph, test that field-level and object-level authorisation hold through nested fields — a user who can read an order may not be allowed to read its customer.paymentMethod, even in the same query.
Common GraphQL bugs to test for
| Bug | What to assert |
|---|---|
Errors returned as 200 with no errors array | Failed operations populate errors with a code |
| Nested authorisation bypass | Deep fields enforce the same authz as top-level |
| N+1 under load | Resolver/DB call count doesn't scale with list size |
| Over-fetching ignored | Requesting fewer fields returns fewer fields |
| Non-null field returns null | Schema non-null (!) is honoured or errors correctly |
| Introspection enabled in prod | Production hardening disables __schema |
| Unbounded query depth/complexity | Deeply nested or expensive queries are rejected/limited |
Tooling
| Tool | Use |
|---|---|
| Insomnia, Hoppscotch | Schema-aware GraphQL clients for exploration |
| Postman | GraphQL inside a full API platform with collections |
| GraphQL Playground | In-browser schema IDE (largely succeeded by GraphiQL/Apollo Sandbox) |
| Code-based (any HTTP client) | Automated assertion suites in CI |
Quick testing checklist
- Success:
datashaped like the query, noerrors - Negative: bad input produces
errorswith a stableextensions.code - Status is
200even on logical errors — assert the body, not the status - Non-null variables rejected when omitted; defaults applied when omitted
- Field selection honoured — fewer requested fields means fewer returned
- Nested authorisation enforced through deep fields
- Mutations: verify both the return payload and the persisted state
- Partial success handled (
dataanderrorstogether) - N+1 watched as list size grows
- Introspection disabled (or intentionally enabled) per environment