GraphQL Testing
GraphQL uses a single endpoint (POST /graphql) with a query language in the request body. Unlike REST, where the server decides what data to return, in GraphQL the client specifies exactly what fields it needs. This flexibility introduces unique testing challenges.
How GraphQL Differs from REST
| Aspect | REST | GraphQL |
|---|---|---|
| Endpoints | Multiple (/users, /orders) |
Single (/graphql) |
| Response shape | Server-defined | Client-defined (via query) |
| Over-fetching | Common (get all fields) | Eliminated (request only needed fields) |
| Under-fetching | Common (need multiple requests) | Eliminated (nested queries) |
| HTTP status codes | Used for all errors | Returns 200 for most errors |
| Error handling | Status code + body | Always check errors array in response |
Basic Query Testing
def test_graphql_user_query(api, base_url):
query = """
query GetUser($id: ID!) {
user(id: $id) {
name
email
posts {
title
publishedAt
}
}
}
"""
r = requests.post(f"{base_url}/graphql",
json={"query": query, "variables": {"id": "123"}},
headers=api.headers)
assert r.status_code == 200
data = r.json()
# GraphQL returns 200 even for errors — always check the errors array
assert "errors" not in data, f"GraphQL errors: {data.get('errors')}"
assert data["data"]["user"]["name"] == "Alice"
assert isinstance(data["data"]["user"]["posts"], list)
Mutation Testing
def test_graphql_create_user(api, base_url):
mutation = """
mutation CreateUser($input: CreateUserInput!) {
createUser(input: $input) {
id
name
email
}
}
"""
variables = {
"input": {
"name": "New User",
"email": "newuser@test.com"
}
}
r = requests.post(f"{base_url}/graphql",
json={"query": mutation, "variables": variables},
headers=api.headers)
data = r.json()
assert "errors" not in data
user = data["data"]["createUser"]
assert user["name"] == "New User"
assert user["id"] is not None
Error Handling in GraphQL
GraphQL returns HTTP 200 even for many types of errors. The errors are in the response body:
def test_graphql_validation_error(api, base_url):
"""Invalid query syntax should return errors array."""
r = requests.post(f"{base_url}/graphql",
json={"query": "{ invalid syntax here }"},
headers=api.headers)
data = r.json()
assert "errors" in data
assert len(data["errors"]) > 0
assert "message" in data["errors"][0]
def test_graphql_not_found(api, base_url):
"""Querying a non-existent resource."""
query = """
query { user(id: "nonexistent") { name, email } }
"""
r = requests.post(f"{base_url}/graphql",
json={"query": query},
headers=api.headers)
data = r.json()
# Implementation-dependent: either errors array or null data
assert data["data"]["user"] is None or "errors" in data
def test_graphql_partial_errors(api, base_url):
"""GraphQL can return partial data with errors."""
query = """
query {
user(id: "123") { name }
nonExistentField { data }
}
"""
r = requests.post(f"{base_url}/graphql",
json={"query": query},
headers=api.headers)
data = r.json()
# May have both data and errors
if "errors" in data:
for error in data["errors"]:
assert "message" in error
Security Testing
Introspection
GraphQL introspection allows clients to query the entire schema — useful in development but a security risk in production.
def test_introspection_disabled_in_production(prod_url):
"""Introspection should be disabled in production."""
query = """
query {
__schema {
types { name }
}
}
"""
r = requests.post(f"{prod_url}/graphql", json={"query": query})
data = r.json()
# Should either error or return no data
assert "errors" in data or data.get("data", {}).get("__schema") is None
Query Depth Limiting
Deeply nested queries can be used for denial-of-service attacks:
def test_query_depth_limit(api, base_url):
"""Deeply nested queries should be rejected."""
# Build a deeply nested query
query = "{ user(id: \"1\") { " + \
"friends { " * 20 + \
"name" + \
" }" * 20 + \
" } }"
r = requests.post(f"{base_url}/graphql",
json={"query": query},
headers=api.headers)
data = r.json()
assert "errors" in data # Should be rejected due to depth limit
def test_query_complexity_limit(api, base_url):
"""Queries requesting too many resources should be limited."""
query = """
query {
users(first: 1000) {
edges {
node {
name
posts(first: 1000) {
edges {
node {
title
comments(first: 1000) {
edges { node { body } }
}
}
}
}
}
}
}
}
"""
r = requests.post(f"{base_url}/graphql",
json={"query": query},
headers=api.headers)
data = r.json()
assert "errors" in data # Should be rejected due to complexity
Authorization in GraphQL
def test_graphql_unauthorized_field(viewer_api, base_url):
"""Viewer should not be able to query admin-only fields."""
query = """
query {
user(id: "123") {
name
email
internalNotes # admin-only field
}
}
"""
r = requests.post(f"{base_url}/graphql",
json={"query": query},
headers=viewer_api.headers)
data = r.json()
# Either errors or null for the restricted field
if "errors" not in data:
assert data["data"]["user"]["internalNotes"] is None
Practical Exercise
- Write tests for a GraphQL query: valid query, query with variables, query for non-existent resource
- Write a mutation test with input validation (missing required fields)
- Test that introspection is disabled (or that you know whether it should be enabled/disabled)
- Test query depth limiting with a deeply nested query
- Test authorization: verify that different roles see different fields
Key Takeaways
- GraphQL returns HTTP 200 even for errors — always check the
errorsarray - Test both queries and mutations with valid and invalid inputs
- Introspection should be disabled in production (security risk)
- Query depth and complexity limits must be enforced (DoS prevention)
- Authorization must be tested at the field level, not just the query level
- Partial errors are valid in GraphQL — the response can have both
dataanderrors