QA Engineer Skills 2026QA-2026GraphQL Query Depth and N+1 Detection

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.

Stuck on something from this chapter? Ask on the Help Board — answers are free.