Back to Blog
On this page5 sections

// deep dive

REST vs GraphQL testing: the actual differences

qa.codesqa.codes · 28 October 2025 · 9 min read
Intermediate
api-testinggraphqlrestcomparison

Most 'REST vs GraphQL' content is about API design. The testing perspective is different — and weirder. What actually changes when you're testing them: query construction, schema-aware tooling, the N+1-shaped test bug, and why GraphQL flips the test pyramid on you.

part ofAPI bugs QA should catch

The shape of a REST test vs a GraphQL test

A REST test targets a URL, sends a method, and asserts on the response. The URL encodes the resource. The method encodes the operation. The response shape is fixed by the server.

// REST test
const response = await fetch('/api/users/123');
expect(response.status).toBe(200);
const user = await response.json();
expect(user.id).toBe('123');
expect(user.email).toBeDefined();

A GraphQL test targets one URL (/graphql), sends a POST with a query document, and asserts on the response. The URL tells you nothing about the operation — the operation is in the body. The response shape is partly determined by the query, not fixed by the server.

// GraphQL test
const response = await fetch('/graphql', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    query: `
      query GetUser($id: ID!) {
        user(id: $id) {
          id
          email
          role
        }
      }
    `,
    variables: { id: '123' },
  }),
});
expect(response.status).toBe(200);
const { data, errors } = await response.json();
expect(errors).toBeUndefined();
expect(data.user.id).toBe('123');

Two differences immediately stand out. First, the test constructs the query — you're testing a specific client-side data requirement, not a fixed server resource. Second, and this catches teams repeatedly: GraphQL returns HTTP 200 for business-logic errors. A query that fails because a resolver throws, a field that doesn't resolve, a permissions check that rejects — these all return { errors: [...] } with an HTTP 200 status code.

Asserting response.status === 200 tells you nothing about whether the GraphQL operation succeeded. You always check the errors field. Teams writing their first GraphQL tests often skip this and end up with tests that pass while the actual query is silently failing.

Schema as a testing superpower

This is where GraphQL has a structural advantage that REST approaches with OpenAPI can't quite match. The GraphQL schema is a machine-readable contract available at runtime via introspection.

Any live GraphQL API responds to an introspection query that returns the complete schema: every type, every field, every argument, every non-null constraint. Your test tooling can use this to validate query syntax before sending, generate type-safe fixtures, and catch schema drift automatically.

import { buildClientSchema, getIntrospectionQuery, validate, parse } from 'graphql';
 
// Fetch the live schema
const introspectionResult = await graphqlFetch({ query: getIntrospectionQuery() });
const schema = buildClientSchema(introspectionResult.data);
 
// Validate a query against the live schema before sending it
const queryDoc = parse(`
  query GetUser($id: ID!) {
    user(id: $id) {
      id
      nonExistentField   # this will error at validation, not at runtime
    }
  }
`);
const errors = validate(schema, queryDoc);
// errors: [GraphQLError: Cannot query field "nonExistentField" on type "User"]

Schema-aware clients — graphql-request, Apollo Client, urql — use introspection to provide type-safe query variables and autocompletion in test files. When a field is removed from the schema, a query that references it fails schema validation immediately, with a precise error pointing to the broken field. Compare that to a REST endpoint where a removed field produces a silently undefined value in the response that may or may not trip an assertion.

REST's OpenAPI spec offers similar capabilities in theory. In practice, the spec and the implementation drift — particularly in teams where the spec is manually maintained rather than generated from the code. GraphQL's introspection is always accurate because it's derived from the running API. This makes GraphQL test suites more resilient to schema evolution: the schema validation step catches interface breakage before the test even runs.

For teams thinking about interface compatibility testing between services, GraphQL's schema introspection maps naturally to the contract testing model — the schema itself is the contract, and introspection is how you verify both sides still agree on it.

The N+1-shaped test bug

This is the testing footgun specific to GraphQL, and it's subtle enough that many teams hit it in production before they see it in tests.

The scenario: a GraphQL query fetches a list of users, and for each user fetches their recent orders. The resolver for user.orders runs once per user in the list. In a test environment with 3 users seeded in the database, this resolves with 4 queries (1 for the user list + 1 per user for orders). The test passes.

In production with 10,000 users in the response, the same query issues 10,001 database queries. This is the N+1 problem, and GraphQL's client-controlled query shape makes it easy to write queries that trigger it without realising it. The test data volume doesn't expose the problem; production volume does.

The test that actually catches this requires realistic data volume or explicit query-count assertions:

it('uses DataLoader batching and does not issue O(n) database queries', async () => {
  await seedUsers(100);
 
  let queryCount = 0;
  const trackingDb = db.$on('query', () => queryCount++);
 
  await graphqlClient.request(GET_USERS_WITH_ORDERS);
 
  // Should be 2 queries: one for users, one batched for all orders
  expect(queryCount).toBeLessThanOrEqual(3);
});

Most teams don't write tests like this, because it requires infrastructure (query instrumentation, realistic seed volumes) that isn't in the default test setup. The point isn't that you must write N+1 tests for every query — it's that GraphQL's flexibility pushes some performance responsibility to the client side, which means tests need to at least periodically validate that data-fetching patterns are sane.

Why GraphQL flips the test pyramid

The standard test pyramid: many unit tests, fewer integration tests, even fewer end-to-end tests. The reasoning is that unit tests are fastest, integration tests are slower and more brittle, and end-to-end tests are slowest and most expensive. The advice is to put most coverage at the lowest appropriate level.

GraphQL changes the denominator for integration testing in a meaningful way.

A REST API has many endpoints — each one a potential integration test target. A thorough REST integration test suite tests a range of URLs, methods, and parameter combinations. The surface area is wide.

A GraphQL API has one endpoint. The variety of what you can meaningfully test at the HTTP integration level collapses. There are fewer distinct integration tests to write, because there are fewer entry points. The shape variation happens in the query string, not the URL.

What expands is the resolver layer — the code that handles individual fields. Each resolver is a discrete unit of logic with its own responsibilities: fetching data, applying auth checks, transforming types, enforcing limits. These are naturally unit-testable, and the GraphQL architecture makes them easy to test without the HTTP layer:

// Unit test a resolver directly — no HTTP request needed
import { userResolver } from '../resolvers/user';
 
it('throws AuthorizationError when requesting another user\'s profile', async () => {
  const context = { currentUser: { id: 'user-a' } };
  await expect(
    userResolver({ id: 'user-b' }, {}, context, {} as any)
  ).rejects.toThrow('Not authorized');
});

The tooling landscape in 2026

REST tooling is mature and well-distributed. Any HTTP client works. The ecosystem is stable, the patterns are understood, and the choice of tool rarely matters much.

GraphQL tooling has matured significantly. Apollo Studio offers a hosted explorer with schema introspection support. Tools like GraphQL Code Generator produce typed fixtures and query types from your schema, making test data reliable by construction. Schema-aware clients validate queries before execution and surface schema errors as type errors in TypeScript.

Where GraphQL still has a lead: the schema-first tooling ecosystem. Generating typed test helpers from a live schema is something REST's OpenAPI tooling approximates but hasn't matched in developer experience. The workflow of graphql-codegen → typed query hooks → typed test fixtures is compelling when you have a moderately complex API.

The practical takeaway: testing REST and GraphQL require different habits, not fundamentally different infrastructure. REST tests focus on URL and status code semantics. GraphQL tests focus on errors-field semantics, schema validation, and resolver-level unit testing. Neither is harder — they're just shaped differently.


// related

Deep dives·11 November 2025 · 10 min read

Contract testing, explained without the Pact marketing

Contract testing is two things wearing one name: a model and a tool. The model is genuinely useful; the marketing for the tool oversells where it fits. Here's the model, separated from any vendor's pitch.

contract-testingpactapi-testingmicroservices