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:
- No URL-based routing to test: There is no
GET /users/42versusGET /users— all requests go to the same endpoint. The variation is in the query body. - Always HTTP 200 (usually): GraphQL APIs typically return HTTP 200 even for errors — the errors are inside the response body in an
errorsarray. You cannot rely on status codes for pass/fail detection. - No over-fetching or under-fetching to test: Clients request exactly the fields they need. You test that requested fields are present and unrequested fields are absent.
- Schema is the contract: The GraphQL schema is a machine-readable API contract. Testing against the schema replaces much of the endpoint-by-endpoint documentation verification you do with REST.
- Introspection for schema validation: GraphQL APIs support an introspection query that returns the full schema structure — types, fields, mutations, subscriptions. This is a powerful testing primitive with no REST equivalent.
Architecture Overview
Understanding the GraphQL request/response chain helps you identify where bugs can originate and design tests that cover each layer.
(Postman / Python / Browser)
Endpoint
/graphql(field-level business logic)
(PostgreSQL / MongoDB)
(downstream service)
(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
- Create a new request, set method to POST and URL to your GraphQL endpoint
- In the Body tab, select GraphQL (not raw JSON)
- The Query field opens with GraphQL syntax highlighting and schema-aware auto-complete
- The Variables field accepts JSON for your query variables
- 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:
- GraphQL workspace type: Create a workspace specifically for a GraphQL API, which stores the introspected schema
- Schema fetch: Click "Refresh Schema" to pull the latest schema from the API's introspection endpoint — auto-complete updates immediately
- Query builder: Visual tree-based query builder that lets you check/uncheck fields to compose queries without writing raw GraphQL syntax
- Variables pane: Side-by-side JSON variables pane next to the query editor
- Response formatting: GraphQL responses are formatted and color-coded, with the
errorsarray highlighted separately fromdata
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
- Send a valid query with correct variables
- Assert HTTP 200 and
errorsis absent from response - Assert all requested fields are present in
data - Assert field types match schema (string, integer, boolean)
- Assert relationships are correctly resolved (user.orders is an array)
Field selection — only requested fields returned
- Request a subset of available fields, assert others are absent
- Verify sensitive fields like
passwordHash,internalIdare never returned even if requested by name - Test with an alias:
myUser: user(id: "42") { email }— verify the aliased key is in the response
Variables — correct and incorrect inputs
- Pass required variables correctly — expect success
- Omit a required variable — expect GraphQL validation error, not 500
- Pass a variable of the wrong type (string where Int expected) — expect type error
- Pass null for a non-nullable variable — expect non-null constraint error
Mutation verification
- Run a create mutation, assert the returned object has an ID and all input fields
- Verify the side effect: query for the created resource by ID and confirm it exists
- Run an update mutation, verify the changed field in the return and via a subsequent query
- Run a delete mutation, verify the resource no longer exists (returns null or error on query)
Error cases
- Request a field that does not exist in the schema → expect GraphQL field error
- Send malformed JSON body → expect parse error
- Send a valid query with no authorization header → expect
UNAUTHENTICATEDerror code - Access another user's private data → expect
FORBIDDENerror code
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.
Back to Blog