Back to All Articles
API Testing

GraphQL Testing — A Complete QA Guide

Honnesh Muppala May 5, 2026 13 min read

GraphQL vs REST — How Testing Differs

GraphQL is a query language for APIs developed by Facebook in 2012 and open-sourced in 2015. Unlike REST APIs that expose multiple endpoints (one per resource), a GraphQL API exposes a single endpoint — typically /graphql — and the client specifies exactly what data it wants using a query language.

This single-endpoint, client-driven data fetching model has fundamental implications for how QA engineers test GraphQL APIs:

Architecture Overview

Understanding the GraphQL request/response chain helps you identify where bugs can originate and design tests that cover each layer.

Test Client
(Postman / Python / Browser)
Single GraphQL
Endpoint /graphql
Resolver Layer
(field-level business logic)
Database
(PostgreSQL / MongoDB)
REST API
(downstream service)
Cache
(Redis / DataLoader)

A single GraphQL query can resolve fields from multiple data sources — a user object might come from the database while their active subscription status comes from a downstream REST service. Understanding this helps you write tests that verify each resolver's data independently as well as the assembled response.

GraphQL Basics — Query Syntax

Before testing GraphQL, you need to understand the query language itself. Here are the core constructs:

Query — reading data

# Basic query — request specific fields
query GetUser($id: ID!) {
  user(id: $id) {
    id
    email
    firstName
    lastName
    createdAt
    orders {
      id
      status
      totalAmount
    }
  }
}

# Variables (sent alongside query)
{
  "id": "42"
}

Mutation — writing data

mutation CreateUser($input: CreateUserInput!) {
  createUser(input: $input) {
    id
    email
    firstName
    createdAt
  }
}

# Variables
{
  "input": {
    "email": "newuser@example.com",
    "firstName": "Jane",
    "lastName": "Smith",
    "password": "SecurePass123!"
  }
}

Fragments — reusable field sets

fragment UserFields on User {
  id
  email
  firstName
  lastName
}

query GetUsers {
  activeUsers {
    ...UserFields
    lastLoginAt
  }
  inactiveUsers {
    ...UserFields
    deactivatedAt
  }
}

Introspection query — explore the schema

# Get all types in the schema
query IntrospectSchema {
  __schema {
    types {
      name
      kind
      description
      fields {
        name
        type {
          name
          kind
        }
      }
    }
  }
}

# Get details about a specific type
query IntrospectType {
  __type(name: "User") {
    name
    fields {
      name
      type {
        name
        kind
        ofType { name kind }
      }
    }
  }
}

Postman for GraphQL

Postman has native GraphQL support — it is not treated as a special JSON POST body but as a first-class request type with dedicated UI elements.

Setting up a GraphQL request in Postman

  1. Create a new request, set method to POST and URL to your GraphQL endpoint
  2. In the Body tab, select GraphQL (not raw JSON)
  3. The Query field opens with GraphQL syntax highlighting and schema-aware auto-complete
  4. The Variables field accepts JSON for your query variables
  5. Click "Use GraphQL Introspection" to fetch and cache the schema for auto-complete

Test script for GraphQL in Postman

// GraphQL always returns 200, so check the body errors array
pm.test("No GraphQL errors", () => {
    const body = pm.response.json();
    pm.expect(body.errors).to.be.undefined;
});

pm.test("User data is returned", () => {
    const body = pm.response.json();
    pm.expect(body.data).to.not.be.null;
    pm.expect(body.data.user).to.have.property("id");
    pm.expect(body.data.user).to.have.property("email");
});

pm.test("Only requested fields are returned", () => {
    const user = pm.response.json().data.user;
    // We did NOT request password — verify it is absent
    pm.expect(user).to.not.have.property("password");
    pm.expect(user).to.not.have.property("passwordHash");
});

Insomnia for GraphQL

Insomnia is a strong alternative for GraphQL testing, particularly for teams that prefer a leaner tool. Its GraphQL support includes:

Test Cases for GraphQL APIs

GraphQL requires a different mental model for test case design. Here is a comprehensive test case taxonomy:

Happy path — valid query returns expected fields

Field selection — only requested fields returned

Variables — correct and incorrect inputs

Mutation verification

Error cases

Critical GraphQL testing rule: Always check both pm.response.code === 200 AND body.errors === undefined. A GraphQL response of {"data": null, "errors": [{"message": "User not found"}]} returns HTTP 200 — a functional test checking only status code would pass while the query actually failed. Every GraphQL test must assert the absence of errors.

Python Automation with the gql Library

The gql library is the best Python client for GraphQL testing. It supports synchronous and asynchronous transports, schema validation, and plays well with pytest fixtures.

pip install "gql[requests]"
from gql import gql, Client
from gql.transport.requests import RequestsHTTPTransport

# Set up a reusable client
transport = RequestsHTTPTransport(
    url="https://api.example.com/graphql",
    headers={
        "Authorization": "Bearer YOUR_API_TOKEN",
        "Content-Type": "application/json"
    },
    verify=True,
    retries=3
)

client = Client(transport=transport, fetch_schema_from_transport=True)

# Define a query using the gql() helper (parses and validates the query)
GET_USER_QUERY = gql("""
    query GetUser($id: ID!) {
        user(id: $id) {
            id
            email
            firstName
            lastName
            status
            createdAt
        }
    }
""")

# Execute the query with variables
result = client.execute(GET_USER_QUERY, variable_values={"id": "42"})

print(result)
# {'user': {'id': '42', 'email': 'user@example.com', 'firstName': 'Jane', ...}}

Pytest Test Suite for GraphQL

import pytest
from gql import gql, Client
from gql.transport.requests import RequestsHTTPTransport

# =========================================
# Fixtures
# =========================================
@pytest.fixture(scope="session")
def graphql_client():
    """Reusable GraphQL client for the session."""
    transport = RequestsHTTPTransport(
        url="https://api.example.com/graphql",
        headers={"Authorization": "Bearer " + get_token()}
    )
    return Client(transport=transport, fetch_schema_from_transport=True)


@pytest.fixture
def created_user_id(graphql_client):
    """Create a test user and yield the ID, then clean up."""
    mutation = gql("""
        mutation CreateTestUser($input: CreateUserInput!) {
            createUser(input: $input) { id }
        }
    """)
    result = graphql_client.execute(mutation, variable_values={
        "input": {
            "email": "pytest-user@test.com",
            "firstName": "Pytest",
            "lastName": "User",
            "password": "TestPass123!"
        }
    })
    user_id = result["createUser"]["id"]
    yield user_id

    # Teardown — delete the user after test
    delete_mutation = gql("""
        mutation DeleteUser($id: ID!) { deleteUser(id: $id) { success } }
    """)
    graphql_client.execute(delete_mutation, variable_values={"id": user_id})


# =========================================
# Query Tests
# =========================================
class TestGraphQLQueries:

    def test_get_user_returns_correct_fields(self, graphql_client, created_user_id):
        query = gql("""
            query GetUser($id: ID!) {
                user(id: $id) {
                    id email firstName lastName status
                }
            }
        """)
        result = graphql_client.execute(query, variable_values={"id": created_user_id})
        user = result["user"]

        assert user["id"] == created_user_id
        assert user["email"] == "pytest-user@test.com"
        assert user["status"] == "active"
        assert "password" not in user, "Sensitive field must not be returned"

    def test_query_nonexistent_user_returns_null(self, graphql_client):
        query = gql("""
            query GetUser($id: ID!) { user(id: $id) { id email } }
        """)
        result = graphql_client.execute(query, variable_values={"id": "999999"})
        assert result["user"] is None

    def test_list_users_returns_array(self, graphql_client):
        query = gql("""
            query { users(limit: 10) { id email } }
        """)
        result = graphql_client.execute(query)
        assert isinstance(result["users"], list)
        assert len(result["users"]) <= 10


# =========================================
# Mutation Tests
# =========================================
class TestGraphQLMutations:

    def test_create_user_mutation(self, graphql_client):
        mutation = gql("""
            mutation CreateUser($input: CreateUserInput!) {
                createUser(input: $input) {
                    id email firstName status createdAt
                }
            }
        """)
        result = graphql_client.execute(mutation, variable_values={
            "input": {
                "email": "newuser-mutation@test.com",
                "firstName": "New",
                "lastName": "User",
                "password": "Pass123!"
            }
        })
        created = result["createUser"]
        assert "id" in created
        assert created["email"] == "newuser-mutation@test.com"
        assert created["status"] == "active"
        assert "createdAt" in created

    def test_update_user_mutation(self, graphql_client, created_user_id):
        mutation = gql("""
            mutation UpdateUser($id: ID!, $input: UpdateUserInput!) {
                updateUser(id: $id, input: $input) {
                    id firstName
                }
            }
        """)
        result = graphql_client.execute(mutation, variable_values={
            "id": created_user_id,
            "input": {"firstName": "Updated"}
        })
        assert result["updateUser"]["firstName"] == "Updated"


# =========================================
# Error Tests
# =========================================
class TestGraphQLErrors:

    def test_unauthorized_query_returns_error(self):
        """Anonymous client should get UNAUTHENTICATED error."""
        from gql.transport.requests import RequestsHTTPTransport
        from gql.transport.exceptions import TransportQueryError

        anon_transport = RequestsHTTPTransport(
            url="https://api.example.com/graphql"
        )
        anon_client = Client(transport=anon_transport)

        query = gql("query { users(limit: 1) { id } }")
        with pytest.raises(TransportQueryError) as exc_info:
            anon_client.execute(query)
        assert "UNAUTHENTICATED" in str(exc_info.value)

Schema Validation via Introspection

GraphQL introspection lets you query the schema itself at runtime. This enables powerful schema contract tests — verify that the schema matches your expected API contract before running functional tests.

def test_user_type_has_required_fields(graphql_client):
    """Verify the User type has all expected fields."""
    query = gql("""
        query IntrospectUser {
            __type(name: "User") {
                name
                fields {
                    name
                    type {
                        name
                        kind
                        ofType { name kind }
                    }
                }
            }
        }
    """)
    result = graphql_client.execute(query)
    user_type = result["__type"]
    assert user_type is not None, "User type must exist in schema"

    field_names = {f["name"] for f in user_type["fields"]}
    required_fields = {"id", "email", "firstName", "lastName", "status", "createdAt"}
    missing = required_fields - field_names

    assert not missing, f"User type missing required fields: {missing}"

def test_create_user_mutation_exists(graphql_client):
    """Verify createUser mutation exists in schema."""
    query = gql("""
        query {
            __type(name: "Mutation") {
                fields { name }
            }
        }
    """)
    result = graphql_client.execute(query)
    mutation_names = {f["name"] for f in result["__type"]["fields"]}
    assert "createUser" in mutation_names
    assert "updateUser" in mutation_names
    assert "deleteUser" in mutation_names

Authentication in GraphQL

GraphQL authentication is handled at the HTTP transport layer — typically a Bearer token in the Authorization header. The GraphQL layer then receives the token, validates it, and populates the request context with the authenticated user.

# Authenticated transport
authenticated_transport = RequestsHTTPTransport(
    url="https://api.example.com/graphql",
    headers={"Authorization": f"Bearer {token}"}
)

# Unauthenticated transport — no header
anon_transport = RequestsHTTPTransport(
    url="https://api.example.com/graphql"
)

def test_private_query_requires_auth(graphql_client, anon_client):
    query = gql("query { myProfile { id email } }")

    # Authenticated — should succeed
    result = graphql_client.execute(query)
    assert result["myProfile"]["id"] is not None

    # Anonymous — should raise auth error
    with pytest.raises(TransportQueryError) as exc_info:
        anon_client.execute(query)
    error_message = str(exc_info.value).lower()
    assert "unauthenticated" in error_message or "unauthorized" in error_message

Performance — The N+1 Query Problem

The N+1 problem is a classic GraphQL performance bug. When you query a list of N users and each user has an orders field, a naive resolver makes 1 query to fetch users, then N additional queries — one per user — to fetch their orders. For 100 users, that is 101 database queries for one GraphQL request.

Detecting N+1 from a test perspective

import time

def test_users_with_orders_response_time(graphql_client):
    """N+1 problem manifests as disproportionately slow response for large lists."""
    query = gql("""
        query {
            users(limit: 50) {
                id
                email
                orders { id status totalAmount }
            }
        }
    """)

    start = time.time()
    result = graphql_client.execute(query)
    elapsed = time.time() - start

    users = result["users"]
    assert len(users) == 50

    # If N+1 exists and each DB query takes ~10ms:
    # Expected with DataLoader: ~50ms (batched)
    # Expected with N+1: ~500ms+ (50 individual queries)
    assert elapsed < 0.5, (
        f"Query took {elapsed:.2f}s — possible N+1 problem. "
        "Check if DataLoader or JOIN is used for orders resolver."
    )

Tools like Apollo Studio and GraphQL Inspector can detect N+1 at the schema level. From a QA perspective, a response time threshold test that flags when fetching a fixed list of records takes disproportionately long is an effective proxy check that can live in your CI pipeline.

GraphQL vs REST — Comparison

Aspect GraphQL REST
Endpoint Structure Single endpoint (/graphql) Multiple endpoints (/users, /orders, etc.)
HTTP Method POST (always, even for reads) GET, POST, PUT, PATCH, DELETE by resource
Status Codes for Errors Usually 200 — errors in body 4xx/5xx status codes carry semantic meaning
Data Fetching Client specifies exact fields — no over/under-fetching Server determines response shape — over/under-fetching common
Versioning No versioning — schema evolution via deprecation URL versioning (/v1/, /v2/) or header versioning
Caching Harder — no standard HTTP cache for POST Easy — GET responses cached by standard HTTP caching
Schema / Contract Strongly typed schema, machine-readable OpenAPI / Swagger (if written — often incomplete)
Testing Approach Body-focused, schema introspection, error array check Status code + body, endpoint coverage

Best Practices

1. Always test error responses — not just success paths

GraphQL error responses follow a specific format: {"data": null, "errors": [{"message": "...", "extensions": {"code": "..."}}]}. Test that your API returns well-structured errors with meaningful codes, not generic 500 errors or empty error arrays.

2. Validate schema in CI on every build

Add schema introspection tests to your CI pipeline that run before functional tests. If the schema changes in a breaking way — a field is removed, a type changes — these tests catch it immediately and prevent the broken API from being deployed.

3. Use variables — never string interpolation

# WRONG — vulnerable to injection, not reusable
query = f'{{ user(id: "{user_id}") {{ email }} }}'

# CORRECT — variables are type-safe and sanitized by the GraphQL engine
query = gql("query GetUser($id: ID!) { user(id: $id) { email } }")
result = client.execute(query, variable_values={"id": user_id})

4. Test authorization on every mutation

Every mutation should be tested with: the correct owner (success), a different authenticated user (should fail with FORBIDDEN), and an unauthenticated request (should fail with UNAUTHENTICATED). Authorization bugs in mutations are among the highest-severity GraphQL vulnerabilities.

From Experience at Viasat: Viasat's network management APIs included an internal GraphQL service for device configuration queries. When the team transitioned from a REST-based internal API to GraphQL, the biggest QA challenge was the shift from "check the status code" to "check the errors array." Building a custom pytest fixture that automatically asserted the absence of errors on every response — unless the test specifically expected an error — eliminated a whole class of tests that were passing while returning silent GraphQL errors in the response body.

Back to Blog
From Experience — Virtusa: At Virtusa, Postman was our primary API testing tool across every client engagement. We built environment-variable-driven collections covering dev, staging, and production, with pre-request scripts handling OAuth token refresh automatically. The discipline that made the biggest difference: validating response schemas, not just status codes. This caught three breaking backend changes before they reached QA — each time, a field was renamed or a type changed. Status 200 with wrong data is a silent failure that status-code-only tests never catch.