Skip to main content

API Testing

Testing GraphQL APIs: What's Actually Different From REST

Jovan RadivojevicAug 14, 20266 min read
Testing GraphQL APIs: What's Actually Different From REST

Point a REST testing habit at a GraphQL API and the first thing that breaks is status code checking. A GraphQL request almost always returns 200 OK, even when the query fails, because the error lives inside the response body, not the HTTP layer.

Miss that, and a test suite can pass while every request it sent actually failed.

Errors live in the body, not the status code

{
  "data": null,
  "errors": [
    { "message": "User not found", "path": ["user"] }
  ]
}

A test that only checks response.status === 200 will call this a pass. The correct check inspects the errors array first, and only trusts data once you've confirmed errors is empty or absent.

const { data, errors } = response.body;
expect(errors).toBeUndefined();
expect(data.user.email).toBe(expectedEmail);

Partial success is a real response shape

GraphQL allows a response to return data for some fields and an error for others in the same call. A query for a user and their orders can return the user successfully while the orders field comes back null with an error.

REST doesn't really have an equivalent: a REST call either succeeds or it doesn't. A GraphQL test suite needs to explicitly cover the partial case, or it will only ever be tested against all-or-nothing scenarios that don't reflect production behavior.

Test the query shape, not just the result

Because a client controls exactly which fields it requests, the same endpoint returns different response shapes depending on the query sent. Two tests that both call /graphql are not testing the same thing unless they send the same query. Keep test queries and their expected shapes together, not scattered across test files with the actual query buried in a shared helper nobody checks.

Mutations need a different failure model than queries

A failed query just means bad data. A failed mutation can mean a half-applied write, and GraphQL's single-endpoint model makes that easy to miss.

mutation {
  updateOrder(id: "42", status: SHIPPED) {
    order { id status }
    errors { message field }
  }
}
  • Test the error-shape convention your schema actually uses. Some mutations put errors in the top-level errors array, others return a payload with its own errors field for validation failures specifically. Know which one a given mutation uses before writing the assertion.
  • Test retried mutations explicitly. A client that retries a timed-out createOrder call needs that mutation to be idempotent, or the retry creates a duplicate order. This is a design property to verify, not something schema validation can catch on its own.
  • Test partial mutation failure the same way you'd test partial query failure. A batch mutation that updates three records and fails on the second should tell you exactly which one failed and whether the first commit already landed.

N+1 queries are a correctness problem for tests, not just performance

A GraphQL query for a list of users and each user's orders can silently trigger one database call per user if the resolver isn't batched. That's the classic N+1 problem, and it matters for testing in a way it doesn't for REST: the same query, run against 5 records versus 500, can go from fine to timing out, and a test suite that only ever seeds 5 records will never see it.

  • Seed a realistic record count in at least one test per resolver that returns a list, not just the minimum needed to check the shape.
  • If the schema uses DataLoader or an equivalent batching layer, assert on query count (via a query-logging hook), not just response time. Response time varies with CI load, query count doesn't.
  • Nested list fields compound the problem fastest: a list of users, each with a list of orders, each with a list of line items multiplies resolver calls at every level.

This is the same concern load testing covers at the traffic layer, showing up one level down, in a single request.

Field-level access control needs its own tests

REST typically gates access per endpoint. GraphQL often gates access per field on the same type, which means a single query can be partially authorized. A user can read their own email but not another user's, from the same User type, through the same query shape.

// Same query, two identities, two expected outcomes
const asOwner = await runQuery(userQuery, { token: ownerToken });
expect(asOwner.data.user.email).toBeDefined();

const asOther = await runQuery(userQuery, { token: otherUserToken });
expect(asOther.errors?.[0]?.message).toMatch(/not authorized/i);

Test this per sensitive field, not just per query. A schema change that adds a new field to an existing type can silently expose it to every identity that already has access to the type, without anyone changing a resolver's authorization logic at all.

Building the query, not just sending it

Hand-written query strings scattered across test files are the fastest way to lose track of what's actually being tested. A typed client generated from the schema catches a variable type mismatch or a field that no longer exists at compile time, before the test ever runs against a live API.

// A generated client turns schema drift into a type error, not a runtime failure
const result = await client.query({
  query: GetUserWithOrders,
  variables: { userId: "42" },
});
expect(result.data.user.orders).toHaveLength(3);

Without generation, the same drift shows up as a runtime errors array buried in a response object, which is strictly harder to notice than a red squiggle in an editor.

Schema validation catches what response assertions miss

Since every field has a declared type in the schema, validating a response against the schema catches type mismatches that a manual assertion would have to check field-by-field. This is the same idea as contract testing, applied to a format that already ships its own contract. Use the SDL you already have instead of hand-writing a second one.

Where to start

If you want to see these patterns against a real schema instead of a theoretical one, our GraphQL practice API exposes queries and mutations built specifically to exercise partial errors, nested fields, and nullable types. It's the same approach we bring to API testing engagements: start from what the schema actually promises, then test against that.