Modern QA2026Глубина запросов GraphQL и обнаружение N+1
Join

Course04 API & Contract Testing with AI

Cutting-edge · Chapter 04

Глубина запросов 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-схемы, систематически тестируя каждое рекурсивное отношение и вложенный путь разрешения.