QA Engineer Skills 2026QA-2026GraphQL Testing

GraphQL Testing

GraphQL uses a single endpoint (POST /graphql) with a query language in the request body. Unlike REST, where the server decides what data to return, in GraphQL the client specifies exactly what fields it needs. This flexibility introduces unique testing challenges.


How GraphQL Differs from REST

Aspect REST GraphQL
Endpoints Multiple (/users, /orders) Single (/graphql)
Response shape Server-defined Client-defined (via query)
Over-fetching Common (get all fields) Eliminated (request only needed fields)
Under-fetching Common (need multiple requests) Eliminated (nested queries)
HTTP status codes Used for all errors Returns 200 for most errors
Error handling Status code + body Always check errors array in response

Basic Query Testing

def test_graphql_user_query(api, base_url):
    query = """
    query GetUser($id: ID!) {
        user(id: $id) {
            name
            email
            posts {
                title
                publishedAt
            }
        }
    }
    """
    r = requests.post(f"{base_url}/graphql",
        json={"query": query, "variables": {"id": "123"}},
        headers=api.headers)

    assert r.status_code == 200
    data = r.json()

    # GraphQL returns 200 even for errors — always check the errors array
    assert "errors" not in data, f"GraphQL errors: {data.get('errors')}"
    assert data["data"]["user"]["name"] == "Alice"
    assert isinstance(data["data"]["user"]["posts"], list)

Mutation Testing

def test_graphql_create_user(api, base_url):
    mutation = """
    mutation CreateUser($input: CreateUserInput!) {
        createUser(input: $input) {
            id
            name
            email
        }
    }
    """
    variables = {
        "input": {
            "name": "New User",
            "email": "newuser@test.com"
        }
    }
    r = requests.post(f"{base_url}/graphql",
        json={"query": mutation, "variables": variables},
        headers=api.headers)

    data = r.json()
    assert "errors" not in data
    user = data["data"]["createUser"]
    assert user["name"] == "New User"
    assert user["id"] is not None

Error Handling in GraphQL

GraphQL returns HTTP 200 even for many types of errors. The errors are in the response body:

def test_graphql_validation_error(api, base_url):
    """Invalid query syntax should return errors array."""
    r = requests.post(f"{base_url}/graphql",
        json={"query": "{ invalid syntax here }"},
        headers=api.headers)

    data = r.json()
    assert "errors" in data
    assert len(data["errors"]) > 0
    assert "message" in data["errors"][0]

def test_graphql_not_found(api, base_url):
    """Querying a non-existent resource."""
    query = """
    query { user(id: "nonexistent") { name, email } }
    """
    r = requests.post(f"{base_url}/graphql",
        json={"query": query},
        headers=api.headers)

    data = r.json()
    # Implementation-dependent: either errors array or null data
    assert data["data"]["user"] is None or "errors" in data

def test_graphql_partial_errors(api, base_url):
    """GraphQL can return partial data with errors."""
    query = """
    query {
        user(id: "123") { name }
        nonExistentField { data }
    }
    """
    r = requests.post(f"{base_url}/graphql",
        json={"query": query},
        headers=api.headers)

    data = r.json()
    # May have both data and errors
    if "errors" in data:
        for error in data["errors"]:
            assert "message" in error

Security Testing

Introspection

GraphQL introspection allows clients to query the entire schema — useful in development but a security risk in production.

def test_introspection_disabled_in_production(prod_url):
    """Introspection should be disabled in production."""
    query = """
    query {
        __schema {
            types { name }
        }
    }
    """
    r = requests.post(f"{prod_url}/graphql", json={"query": query})
    data = r.json()
    # Should either error or return no data
    assert "errors" in data or data.get("data", {}).get("__schema") is None

Query Depth Limiting

Deeply nested queries can be used for denial-of-service attacks:

def test_query_depth_limit(api, base_url):
    """Deeply nested queries should be rejected."""
    # Build a deeply nested query
    query = "{ user(id: \"1\") { " + \
        "friends { " * 20 + \
        "name" + \
        " }" * 20 + \
        " } }"

    r = requests.post(f"{base_url}/graphql",
        json={"query": query},
        headers=api.headers)

    data = r.json()
    assert "errors" in data  # Should be rejected due to depth limit

def test_query_complexity_limit(api, base_url):
    """Queries requesting too many resources should be limited."""
    query = """
    query {
        users(first: 1000) {
            edges {
                node {
                    name
                    posts(first: 1000) {
                        edges {
                            node {
                                title
                                comments(first: 1000) {
                                    edges { node { body } }
                                }
                            }
                        }
                    }
                }
            }
        }
    }
    """
    r = requests.post(f"{base_url}/graphql",
        json={"query": query},
        headers=api.headers)

    data = r.json()
    assert "errors" in data  # Should be rejected due to complexity

Authorization in GraphQL

def test_graphql_unauthorized_field(viewer_api, base_url):
    """Viewer should not be able to query admin-only fields."""
    query = """
    query {
        user(id: "123") {
            name
            email
            internalNotes  # admin-only field
        }
    }
    """
    r = requests.post(f"{base_url}/graphql",
        json={"query": query},
        headers=viewer_api.headers)

    data = r.json()
    # Either errors or null for the restricted field
    if "errors" not in data:
        assert data["data"]["user"]["internalNotes"] is None

Practical Exercise

  1. Write tests for a GraphQL query: valid query, query with variables, query for non-existent resource
  2. Write a mutation test with input validation (missing required fields)
  3. Test that introspection is disabled (or that you know whether it should be enabled/disabled)
  4. Test query depth limiting with a deeply nested query
  5. Test authorization: verify that different roles see different fields

Key Takeaways

  • GraphQL returns HTTP 200 even for errors — always check the errors array
  • Test both queries and mutations with valid and invalid inputs
  • Introspection should be disabled in production (security risk)
  • Query depth and complexity limits must be enforced (DoS prevention)
  • Authorization must be tested at the field level, not just the query level
  • Partial errors are valid in GraphQL — the response can have both data and errors