GraphQL API Testing: Queries, Mutations, Errors and Security
GraphQL APIs look simple from the outside, with one URL and one POST, but they hide a surprising number of testing traps. Errors come back with HTTP 200, “not found” is a null instead of a 404, and a single query can ask for so much nested data that it slows the whole server down. In this lesson you’ll test a real public GraphQL API with Postman, cURL and Playwright, and learn the checklist that catches GraphQL-specific bugs.
query (and optional variables and operationName). Verify the data object and the errors array, not just the HTTP status, because validation errors and missing records usually return HTTP 200. Test queries, mutations, variables, nullability, field-level authorization, pagination, query depth/complexity limits and that introspection is disabled in production where required.GraphQL in 2 Minutes
GET /users/1 gives you every item on the plate whether you want it or not. GraphQL is a buffet: you pick exactly what goes on your plate, { user(id: 1) { name city } }, and nothing else. Great for the customer, but the kitchen must handle someone piling up 50 plates at once.- One endpoint, usually
POST /graphql. - Schema with types and fields defines what can be asked (strongly typed).
- Query reads, mutation writes, subscription streams updates (usually over WebSocket).
- Response is JSON with
dataand/orerrors.
Comparison with REST and SOAP: REST vs SOAP vs GraphQL.
The Public API We’ll Test
We’ll use the free public Countries GraphQL API at https://countries.trevorblades.com/. It needs no key and returns country data. (It’s a third-party demo service; be gentle with it and don’t load test it.)
query {
country(code: "IN") {
name
capital
currency
languages { name }
}
}
Real response (HTTP 200):
{
"data": {
"country": {
"name": "India",
"capital": "New Delhi",
"currency": "INR",
"languages": [ { "name": "Hindi" }, { "name": "English" } ]
}
}
}
Sending GraphQL Requests: cURL and Postman
cURL
curl -s https://countries.trevorblades.com/ \
-H "Content-Type: application/json" \
-d '{"query":"{ country(code: \"IN\") { name capital currency } }"}'
With variables (the right way)
Never build queries by gluing strings together; pass values as variables. It’s cleaner and avoids injection.
{
"query": "query GetCountry($code: ID!) { country(code: $code) { name emoji } }",
"variables": { "code": "JP" },
"operationName": "GetCountry"
}
Response: {"data":{"country":{"emoji":"🇯🇵","name":"Japan"}}}
Postman
- New HTTP request, method POST, URL
https://countries.trevorblades.com/. - Body → GraphQL. Postman fetches the schema and gives autocomplete.
- Write the query in the QUERY box and variables in the GRAPHQL VARIABLES box. Send.
The Biggest Trap: HTTP 200 Doesn’t Mean Success
We sent three “wrong” requests to the real API. Look at the status codes:
| Request | HTTP status | Body |
|---|---|---|
Typo in a field name (nam) | 200 | {"errors":[{"message":"Cannot query field \"nam\" on type \"Country\". Did you mean \"name\"?", "extensions":{"code":"GRAPHQL_VALIDATION_FAILED"}}]} |
Country that doesn’t exist (ZZ) | 200 | {"data":{"country":null}} |
Broken syntax (missing }) | 400 | {"errors":[{"message":"The request did not contain a valid GraphQL request..."}]} |
status === 200 passes for a typo’d field and for a missing record. Every GraphQL test must assert on errors (absent, or the expected code) and on the exact data values, including null where a record doesn’t exist.Status code behaviour varies by server (some return 400 for validation errors when using the newer “GraphQL over HTTP” conventions), so check your API’s documented behaviour and test it explicitly.
What to Test in a GraphQL API
| Area | Test ideas |
|---|---|
| Queries | Each field returns correct values; nested objects; aliases (a: country(code:"IN") b: country(code:"JP")); fragments |
| Variables | Required variable missing; wrong type (ID! given a number/null); extra variables |
| Nullability | Non-null fields (String!) never null; nullable fields null when data is absent |
| Mutations | Create/update/delete effects; returned payload; validation errors in errors or a typed error field |
| Errors | Error message, path, extensions.code; partial data with errors |
| Pagination | Cursor pagination (first, after, pageInfo.hasNextPage, endCursor) |
| Authorization | Field-level and object-level checks: a user can query me { orders } but not user(id: 2) { orders } |
| Performance and abuse | Deeply nested queries, huge lists, aliases repeating an expensive field 100 times, batching |
| Schema | Breaking changes (removed fields, changed types); deprecated fields still work |
| Introspection | Disabled or restricted in production if the policy says so |
GraphQL Security Checks
author { posts { author { posts { ... } } } } can nest 20 levels, one small request can force millions of database lookups. Servers should enforce a maximum depth, a complexity/cost limit and pagination limits (e.g. first ≤ 100). Testers verify these limits return a clear error instead of timing out.- Introspection:
{ __schema { types { name } } }reveals the whole schema. Often disabled in production for internal APIs. - Field suggestions: “Did you mean …?” messages (like the one above) can leak hidden field names; some teams disable them in production.
- Authorization per resolver: every field that returns sensitive data needs its own check.
- Injection: arguments still reach databases; test them like REST inputs.
- Batching / alias abuse: 1,000 login attempts in one request via aliases can bypass rate limiting.
Automating GraphQL Tests with Playwright
Playwright’s request fixture is all you need. A small helper keeps tests readable:
import { test, expect, APIRequestContext } from '@playwright/test';
const GQL = 'https://countries.trevorblades.com/';
async function gql(request: APIRequestContext, query: string, variables?: Record<string, unknown>) {
const res = await request.post(GQL, { data: { query, variables } });
return { status: res.status(), body: await res.json() };
}
test('query with variables returns the right country', async ({ request }) => {
const { status, body } = await gql(request,
'query GetCountry($code: ID!) { country(code: $code) { name capital currency } }',
{ code: 'IN' });
expect(status).toBe(200);
expect(body.errors).toBeUndefined(); // the essential GraphQL check
expect(body.data.country).toEqual({ name: 'India', capital: 'New Delhi', currency: 'INR' });
});
test('unknown record returns null, not an error', async ({ request }) => {
const { status, body } = await gql(request, '{ country(code: "ZZ") { name } }');
expect(status).toBe(200);
expect(body.errors).toBeUndefined();
expect(body.data.country).toBeNull();
});
test('invalid field returns 200 with a validation error', async ({ request }) => {
const { status, body } = await gql(request, '{ country(code: "IN") { nam } }');
expect(status).toBe(200);
expect(body.data).toBeUndefined();
expect(body.errors[0].extensions.code).toBe('GRAPHQL_VALIDATION_FAILED');
expect(body.errors[0].message).toContain('Cannot query field "nam"');
});
test('aliases fetch two records in one request', async ({ request }) => {
const { body } = await gql(request, '{ in: country(code: "IN") { name } jp: country(code: "JP") { name } }');
expect(body.data).toEqual({ in: { name: 'India' }, jp: { name: 'Japan' } });
});
These tests ran green against the live API while this lesson was written. For your own APIs, also add schema validation of data (next module) and auth tests per field.
GraphQL Tools for Testers
- Postman / Insomnia: GraphQL body type with schema autocomplete.
- GraphiQL / Apollo Sandbox: in-browser explorer with docs from introspection.
- Playwright, REST Assured, pytest: GraphQL is just HTTP POST, so any API framework works.
- GraphQL Inspector: detects breaking schema changes in CI.
- k6: load testing GraphQL operations.
Mutations: Testing Writes
The countries API is read-only, so here is a typical mutation shape you’ll see in real projects:
mutation AddToCart($input: AddToCartInput!) {
addToCart(input: $input) {
cart { id itemCount total }
errors { field message } # some APIs return business errors as data
}
}
# variables: { "input": { "productId": "101", "quantity": 2 } }
Test cases: valid input updates the cart (verify with a follow-up query); quantity 0 / -1 / 1000 returns an error in errors or the typed errors field; unknown product; another user’s cart id (authorization); running the same mutation twice (idempotency where expected).
Common Mistakes When Testing GraphQL
- Asserting only
status 200. - Building queries with string concatenation instead of variables.
- Testing only the fields the UI uses; other clients may request more.
- Ignoring
nullin nested data (partial failures). - Not testing depth/complexity limits and introspection settings.
Interview Questions: GraphQL Testing
How is GraphQL testing different from REST testing?
GraphQL has one endpoint and a schema; clients choose fields. Tests must check the data and errors arrays rather than relying on status codes, cover field selection, variables, nullability, field-level authorization, and abuse limits such as query depth and complexity.
Why can a GraphQL error return HTTP 200?
Because GraphQL reports errors in the response body’s errors array and can return partial data. Many servers return 200 for validation and resolver errors and use 4xx only for malformed requests.
What are queries, mutations and subscriptions?
Queries read data, mutations change data, and subscriptions push real-time updates to clients, usually over WebSocket.
What are GraphQL variables and why use them?
Values passed separately from the query text in a variables object, typed in the operation definition such as $code: ID!. They make queries reusable and prevent injection from string concatenation.
What is introspection and why might it be disabled?
A built-in feature that lets clients query the schema itself (__schema, __type). It helps tooling but exposes the full API surface, so internal APIs often disable or restrict it in production.
How do you test GraphQL performance risks?
Send deeply nested queries, very large page sizes, many aliases of expensive fields and batched operations, and verify the server enforces depth, complexity and pagination limits with clear errors rather than slowing down.
How does pagination usually work in GraphQL?
Through cursor-based connections: arguments like first and after, and a pageInfo object with hasNextPage and endCursor, with results in edges and node.
How do you test authorization in GraphQL?
For each sensitive type and field, call queries and mutations with different roles and users, including nested paths that reach the same data, and verify unauthorized fields return errors or null without leaking data.
Which tools do you use for GraphQL testing?
Postman or Insomnia for manual testing, GraphiQL or Apollo Sandbox for exploration, Playwright or other HTTP frameworks for automation, GraphQL Inspector for schema changes and k6 for load testing.
What is a GraphQL fragment?
A reusable set of fields defined once (fragment CountryInfo on Country { name capital }) and spread into queries with …CountryInfo, keeping large queries consistent.