Глубина запросов GraphQL и обнаружение N+1
Updated Jul 2026
Уникальные сложности тестирования GraphQL
GraphQL привносит сложности тестирования, которых нет в REST: атаки глубиной запроса, проблемы N+1 запросов, сложность склеивания схем и возможность клиентов запрашивать произвольные комбинации полей. Всё это требует специализированных стратегий тестирования.
Тестирование глубины запросов
Вектор атаки
Злонамеренный (или неопытный) клиент может сконструировать глубоко вложенные запросы, вызывающие экспоненциальную нагрузку на базу данных:
# Depth attack: recursive query
query DepthAttack {
user(id: "1") {
friends {
friends {
friends {
friends {
friends {
name # Depth 6 -- should be blocked
}
}
}
}
}
}
}
Каждый уровень вложенности может умножить количество запросов к базе данных. Без ограничений глубины запрос с глубиной 10 по полю friends со 100 друзьями на пользователя может вызвать 100^10 = 10^20 обращений к базе.
Набор тестов глубины, сгенерированный ИИ
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 запросов
Проблема
Проблема N+1 возникает, когда разрешение списка из N элементов вызывает N дополнительных запросов к базе данных вместо одного пакетного запроса.
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
Обнаружение N+1 в тестах
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."
)
Настройка логирования запросов
# 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
Анализ сложности запросов
Помимо глубины, GraphQL-серверы должны также ограничивать сложность запросов:
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)
Ключевой вывод
Тестирование GraphQL требует стратегий, выходящих за рамки REST: ограничения глубины предотвращают экспоненциальный рост запросов, обнаружение N+1 обеспечивает эффективность работы с базой данных, а анализ сложности блокирует запросы, истощающие ресурсы. ИИ может генерировать эти наборы тестов из GraphQL-схемы, систематически тестируя каждое рекурсивное отношение и вложенный путь разрешения.