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.