GraphQL Query Depth and N+1 Detection
Unique GraphQL Testing Challenges
GraphQL introduces testing challenges that REST does not have: query depth attacks, N+1 query problems, schema stitching complexity, and the ability for clients to request arbitrary field combinations. These require specialized test strategies.
Query Depth Testing
The Attack Vector
A malicious (or naive) client can construct deeply nested queries that cause exponential database load:
# Depth attack: recursive query
query DepthAttack {
user(id: "1") {
friends {
friends {
friends {
friends {
friends {
name # Depth 6 -- should be blocked
}
}
}
}
}
}
}
Each level of nesting can multiply database queries. Without depth limits, a query with depth 10 on a friends field with 100 friends per user could trigger 100^10 = 10^20 database lookups.
AI-Generated Depth Test Suite
class TestGraphQLDepthLimits:
"""Verify that the GraphQL server enforces query depth limits."""
def _build_nested_query(self, field: str, depth: int) -> str:
"""Build a query with n levels of nesting."""
query = "{ " + f'{field} {{ '
for _ in range(depth - 1):
query += f'{field} {{ '
query += "id name "
query += "} " * depth
query += "}"
return query
@pytest.mark.parametrize("depth,should_succeed", [
(1, True), # Shallow query -- always allowed
(3, True), # Normal depth -- allowed
(5, True), # Moderate depth -- typically allowed
(10, False), # Deep query -- should be blocked
(20, False), # Very deep -- definitely blocked
])
def test_query_depth_limit(self, graphql_client, depth, should_succeed):
"""Verify depth limits are enforced."""
query = self._build_nested_query("friends", depth)
response = graphql_client.execute(query)
if should_succeed:
assert "errors" not in response or not any(
"depth" in str(e).lower() for e in response.get("errors", [])
)
else:
assert "errors" in response
assert any(
"depth" in str(e).lower() or "complexity" in str(e).lower()
for e in response["errors"]
)
def test_depth_limit_returns_clear_error(self, graphql_client):
"""Blocked queries should return a descriptive error, not a crash."""
query = self._build_nested_query("friends", 20)
response = graphql_client.execute(query)
assert "errors" in response
error = response["errors"][0]
assert "message" in error
# Error should mention depth or complexity, not be a generic server error
assert any(
keyword in error["message"].lower()
for keyword in ["depth", "complexity", "limit", "exceeded"]
)
def test_breadth_limit(self, graphql_client):
"""Wide queries (many fields at same level) should also be bounded."""
# Request every field on every type -- breadth attack
query = """
{
users(first: 100) { id name email role created_at updated_at
friends(first: 100) { id name email role }
orders(first: 100) { id total status items { id name price } }
notifications(first: 100) { id message read }
preferences { theme language timezone }
}
}
"""
response = graphql_client.execute(query)
# This should either succeed with reasonable data
# or be blocked by a complexity limit
if "errors" in response:
assert any(
"complexity" in str(e).lower()
for e in response["errors"]
)
N+1 Query Detection
The Problem
The N+1 problem occurs when resolving a list of N items triggers N additional database queries instead of one batched query.
Query: users(first: 10) { friends { name } }
N+1 pattern (BAD):
Query 1: SELECT * FROM users LIMIT 10 -- 1 query
Query 2: SELECT * FROM friends WHERE user_id = 1 -- N queries
Query 3: SELECT * FROM friends WHERE user_id = 2
...
Query 11: SELECT * FROM friends WHERE user_id = 10
Total: 11 queries
Batched pattern (GOOD):
Query 1: SELECT * FROM users LIMIT 10
Query 2: SELECT * FROM friends WHERE user_id IN (1,2,3,...,10)
Total: 2 queries
Detecting N+1 in Tests
class TestGraphQLN1Detection:
"""Detect N+1 query patterns in GraphQL resolvers."""
def test_user_friends_not_n_plus_1(self, graphql_client, query_logger):
"""Fetching users with friends should batch, not N+1."""
query = """
query {
users(first: 10) {
id
name
friends {
id
name
}
}
}
"""
query_logger.reset()
response = graphql_client.execute(query)
# Count database queries that were executed
db_queries = query_logger.get_queries()
user_count = len(response["data"]["users"])
# N+1 would be: 1 (list users) + N (fetch friends per user)
# Correct batching: 1 (list users) + 1 (batch friends)
assert len(db_queries) <= 3, (
f"Expected <= 3 DB queries (batched), got {len(db_queries)} "
f"for {user_count} users. Likely N+1 pattern:\n"
+ "\n".join(q.sql for q in db_queries)
)
def test_nested_resolution_performance(self, graphql_client):
"""Complex nested query should complete within SLA."""
query = """
query {
products(first: 20) {
id
name
category { name }
reviews(first: 5) {
rating
author { name }
}
}
}
"""
import time
start = time.monotonic()
response = graphql_client.execute(query)
duration = time.monotonic() - start
assert duration < 2.0, (
f"Nested query took {duration:.2f}s, SLA is 2.0s. "
f"Possible N+1 in reviews or author resolution."
)
def test_dataloader_batching(self, graphql_client, query_logger):
"""Verify DataLoader batches related entity lookups."""
query = """
query {
orders(first: 20) {
id
customer {
id
name
}
items {
product {
id
name
}
}
}
}
"""
query_logger.reset()
response = graphql_client.execute(query)
db_queries = query_logger.get_queries()
# With DataLoader: ~4 queries (orders, customers, items, products)
# Without DataLoader: 1 + 20 + 20*items + ... queries
assert len(db_queries) <= 6, (
f"Expected <= 6 DB queries with DataLoader, got {len(db_queries)}. "
f"Verify DataLoader is configured for customer and product resolvers."
)
Setting Up Query Logging
# conftest.py
import logging
class QueryLogger:
"""Capture database queries for N+1 detection."""
def __init__(self):
self.queries = []
self._handler = None
def start(self):
"""Start capturing SQL queries."""
self._handler = logging.getLogger('sqlalchemy.engine').addHandler(
self._capture_handler()
)
def reset(self):
self.queries = []
def get_queries(self):
return self.queries
def _capture_handler(self):
class CaptureHandler(logging.Handler):
def __init__(self, logger):
super().__init__()
self.logger = logger
def emit(self, record):
if "SELECT" in record.getMessage():
self.logger.queries.append(
CapturedQuery(sql=record.getMessage(), timestamp=time.time())
)
return CaptureHandler(self)
@pytest.fixture
def query_logger():
logger = QueryLogger()
logger.start()
return logger
Query Complexity Analysis
Beyond depth, GraphQL servers should also limit query complexity:
class TestQueryComplexity:
"""Test query complexity limits."""
def test_complexity_score_calculation(self, graphql_client):
"""Verify the server correctly calculates and limits complexity."""
# A query that requests many items with nested relations
# Each `first: N` multiplies the complexity
query = """
query {
users(first: 100) { # complexity: 100
orders(first: 50) { # complexity: 100 * 50 = 5000
items(first: 20) { # complexity: 5000 * 20 = 100000
product {
reviews(first: 10) { # complexity: 100000 * 10 = 1000000
author { name }
}
}
}
}
}
}
"""
response = graphql_client.execute(query)
# This query should be blocked due to excessive complexity
assert "errors" in response
error_messages = [e["message"] for e in response["errors"]]
assert any("complexity" in msg.lower() for msg in error_messages)
Key Takeaway
GraphQL testing requires strategies beyond REST: depth limits prevent exponential query blowup, N+1 detection ensures database efficiency, and complexity analysis blocks resource-exhausting queries. AI can generate these test suites from the GraphQL schema, systematically testing every recursive relationship and nested resolution path.