GraphQL Testing
GraphQL is a query language and type system for APIs, defined by the GraphQL Specification. An API usually exposes one endpoint, such as POST /graphql. The client sends a document naming exactly the fields it wants; the server validates it against a typed schema, executes it and returns JSON in the same shape.
Example: { order(id: "1042") { status total } } returns {"data": {"order": {"status": "SHIPPED", "total": 249.5}}} and nothing more. HTTP still carries the request, but most of what a tester checks moves into the bodies.
Why it matters
GraphQL moves risk to places REST habits do not look. Errors usually arrive inside a 200 OK response, so a status-only test passes while the operation failed. Because the client composes every request, the same data is reachable through several paths, and authorization forgotten on one of them leaks data.
One request can also ask for nested lists that cost far more than any REST call. The OWASP API Security Top 10 2023 lists unrestricted resource consumption and broken object property level authorization among its risks; GraphQL makes both easy to get wrong.
Schema, queries and mutations
The schema, written in the schema definition language (SDL), is the contract: every type, every field with its type, and the entry points. Fields of Query read data and fields of Mutation change it; ! marks a value that is never null. Arguments and variables are typed: Int is a signed 32-bit integer, so 2147483648 is refused with a request error before execution.
Compared with REST, there is usually one endpoint, the operation travels in the body, and the client, not the endpoint, chooses the returned fields. A REST API has an OpenAPI document only if the team provides one; a GraphQL server always has its schema and enforces it, so every field and argument is a ready-made test condition.
REST
Many resources, each at its own URI; the method says what to do
GET /v1/orders/1042
- Endpoints:
- One URI per resource, several methods
- Fields returned:
- Decided by the endpoint
- Errors:
- One status code describes the whole request
- Contract:
- An OpenAPI document, if the team provides one
- Access checks:
- Often per endpoint and method
- Cost of one request:
- Set by the endpoint design, usually paged
GraphQL
One endpoint; the request body names the operation and every field
POST /graphql
- Endpoints:
- Usually a single endpoint for everything
- Fields returned:
- Chosen by the client in each query
- Errors:
- In the
errorsarray of the body, usually with200 OK; other fields can still return data - Contract:
- The schema: always present, enforced, readable by introspection when enabled
- Access checks:
- Per object and field, on every path that reaches it
- Cost of one request:
- Grows with depth, list sizes and aliases
Typical behaviour: servers differ, especially in the status codes they use, so check what your API documents.
- Query:
datamirrors the selection, with every requested field. Anullmay be a real "no value" or a failed field; only a matching entry inerrorstells them apart. - Mutation: changes data and returns the fields you select from the result; several mutation fields in one operation run in order.
- Variables: sent in a
variablesobject and type-checked; test them like any input — missing,null, wrong type, out of range.
# Schema (SDL), shortened
type Query {
order(id: ID!): Order
me: User!
}
type Mutation {
cancelOrder(id: ID!, reason: String): Order
}
type Order {
id: ID!
status: OrderStatus!
total: Float!
trackingUrl: String
internalNote: String
customer: User!
items: [OrderItem!]!
}
type User { id: ID! name: String! email: String! orders(first: Int): [Order!]! }
type OrderItem { sku: String! quantity: Int! }
enum OrderStatus { PLACED SHIPPED CANCELLED }
# An operation, sent over HTTP as a JSON body:
# {"query": "query OrderSummary($id: ID!) { ... }", "variables": {"id": "1042"}}
query OrderSummary($id: ID!) {
order(id: $id) { status total items { sku quantity } }
}Errors that arrive with HTTP 200
The specification defines the response, not the HTTP status: in practice a JSON object with data, errors or both. Where REST uses one status code for the whole request, many GraphQL servers answer 200 OK to everything they could process; some use 4xx only for requests they cannot parse or validate.
A request error — bad syntax, an unknown field, a wrongly typed variable — stops execution: errors, no data. An execution error (called a field error in earlier editions of the specification and in many tools) makes that one field null, adds an errors entry naming it in path, and still returns the rest of data: partial success. If the field is non-null, the null moves up to the nearest nullable parent. Below, the carrier service behind trackingUrl timed out.
- Assert on the body: on success
errorsis absent and every field has the right type; on an expected failure, checkerrors,pathand any documented error code.extensionsis defined by your server, not the specification. - Partial success: a missing tracking link must not look like "no tracking available", and no
messagemay expose stack traces or SQL.
HTTP/1.1 200 OK
Content-Type: application/json
{
"errors": [
{
"message": "Carrier service timed out",
"locations": [{ "line": 4, "column": 5 }],
"path": ["order", "trackingUrl"],
"extensions": { "code": "UPSTREAM_TIMEOUT" }
}
],
"data": {
"order": { "status": "SHIPPED", "trackingUrl": null }
}
}Query depth, size and cost
A User has orders, each Order has a customer, and that customer has orders again, so a short query can nest as deep as the client likes. Every list level multiplies the work: 100 orders, each with a customer who has 100 orders, is already 10,000 objects. A REST endpoint returns what its designers decided, usually in pages; a GraphQL request costs what the client asks for. An alias, such as o1: in front of a field, renames that field in the response, so one request can repeat the same field many times.
Expect a maximum depth, a cap on list arguments such as first, a cost budget, a time-out and rate limits that count operations, each failing with a clear entry in errors, not a crash; find the depth limit by adding one level at a time. Test only systems you own or have written permission to test, preferably a test environment, in small steps.
- With
firstcapped at 100, send 100, 101, 0 and -1: never a silently larger page. - In one request, send a rate-limited mutation, such as redeeming a coupon, under 10 aliases: the limit must count every alias, not the single HTTP request.
# Nesting: every list level multiplies the work
query Deep {
me {
orders(first: 100) {
customer {
orders(first: 100) {
customer { orders(first: 100) { id } }
}
}
}
}
}
# Aliases: the same field many times in one request
query Aliases {
o1: order(id: "1001") { total }
o2: order(id: "1002") { total }
# ... and so on, up to o500
}Introspection: the schema describes itself
Introspection is part of the specification: system fields starting with __ return the schema. __schema lists every type and the root operation types, __type(name: "Order") describes one type, and __typename names the type of any object. Use it to list every query, mutation, field and argument to cover; ask fields for includeDeprecated: true, because deprecated fields are left out by default but still work.
Because it hands out a map of the API, the OWASP GraphQL Cheat Sheet advises disabling introspection in production and other publicly accessible environments. Exposing it can be intended for an API built for external clients; even then, the cheat sheet suggests considering disabling it for unauthenticated or unauthorized users. Disabling it does not make the schema secret: guessed fields still answer and some servers suggest names in errors ("Did you mean …?"), which the cheat sheet suggests turning off too, so authorization must hold anyway.
- Save the result as a snapshot for each release and compare snapshots: a removed field or a new required argument breaks clients.
- In each environment, send
__schemawithout a token and with an ordinary user's token, and compare the result with the agreed policy.
query Inventory {
__schema {
queryType { name }
mutationType { name }
types {
name
kind
fields(includeDeprecated: true) { name isDeprecated args { name } }
}
}
}Common mistakes
- Asserting only
200 OKinstead of checkingerrorsanddata. - Calling a response with both
dataanderrorsa plain pass or fail without stating each field's expected value. - Testing authorization on the main query only, not on nested objects, lists and mutation results.
- Assuming only the front end's queries matter: any valid token can send any query the schema allows.
- Running depth or alias tests against production or a shared environment without permission.
The QA perspective
Review every schema change like a requirement: which new fields hold personal or internal data and who may read them, which lists are paged and with what maximum, which errors clients should expect. Ask how depth, cost and rate limits are configured and where introspection is on.
In test cases and bug reports, record the operation, variables, user or role and the full response body, and quote the error's path or the exact query that reached a protected field.
Practice exercise
Design tests for a new delivery field
Your team adds the fields below to the lesson's Order type. Requirement: "Customers see the delivery address and tracking events of their own orders; support agents see them for any order. trackingEvents returns at most 50 events per request."
Write test cases giving the user, the operation and what must and must not appear in data and errors.
type Order {
# ...existing fields from the lesson
deliveryAddress: Address
trackingEvents(first: Int): [TrackingEvent!]!
}
type Address { line1: String! city: String! postcode: String! }
type TrackingEvent { status: String! at: String! }- List the roles and the test data you need.
- Write the positive cases.
- Write authorization cases that reach the address through two paths.
- Apply 2-value boundary value analysis to
first: test each boundary value and its closest neighbour in the adjacent partition.
Show the model answer
Data: A owns order 1042 with 60 tracking events, B owns order 2077, plus one support agent; each test uses that user's token.
Positive: A and the agent ask order(id: "1042") for deliveryAddress and trackingEvents(first: 10) and get both with no errors.
Authorization: B asks order(id: "1042") for the same fields and gets order: null, no address or events anywhere, and the same response as for an unknown id. B sends cancelOrder(id: "1042") { deliveryAddress }: null plus an error, and the agent sees the order's status unchanged. Through me { orders }, B sees only their own addresses.
Limit: first: 50 returns 50 events; first: 51 gives a clear error or exactly 50, as documented, never 51. For the lower boundary, ask whether 0 is valid: if the valid range is 1 to 50, test 0 (error) and 1 (one event); if it is 0 to 50, test -1 (error) and 0 (an empty list). Without first, ask what the default is: at most 50, never all 60.
Frequently asked questions
Why does a GraphQL API return 200 when something failed?
The GraphQL specification describes the response, not the HTTP status, and an execution error in one field still produces a useful response with partial data. Many servers therefore answer 200 to any request they could process, and some use 4xx only for requests they cannot parse or validate. Check your API's documentation, and assert on the body in every case.
Should introspection be turned off?
It is useful in development and test environments. For production and other publicly accessible environments, the OWASP GraphQL Cheat Sheet advises disabling it; for an API meant to be explored by external clients, it suggests at least considering disabling it for unauthenticated or unauthorized users. Treat the decision as a requirement and test it in each environment; turning it off reduces exposure but never replaces authorization.
Can I test GraphQL with my REST tools?
Yes. Over HTTP, a GraphQL request is usually a POST with a JSON body holding query and variables, so any HTTP client can send it. GraphQL tools add schema-aware editing and validation. The Mutqan GraphQL Query Tester runs queries and mutations with variables and headers against an endpoint and shows the response.
References
- GraphQL SpecificationThe GraphQL query language and type system, validation rules and execution semantics.
- OWASP Cheat Sheet Series: GraphQLGraphQL-specific risks: query depth and complexity limits, introspection in production, authorization per field.
- OWASP API Security Top 10 (2023)The ten most common API weaknesses: broken object-level authorization, broken authentication, unrestricted resource consumption and more.
Practise with Mutqan tools
Run GraphQL queries and mutations with variables and headers against any endpoint and inspect the response.
Evaluate JSONPath expressions ($.store.book[*].author) against JSON and see matches live.
Compare two API responses (e.g. staging vs production) and list every structural and value difference.
Compare two JSON documents semantically and highlight added, removed and changed values.
Analyze an API response: structure, field types, nulls, duplicates, sizes and potential data-quality issues.
Go deeper
Your progress on this topic
Not started
Mark the topic complete when you have studied it and done the exercise.
Sign in to track your progress
Mark topics complete, continue where you left off and unlock specialisation exams.