Article · Network & API Mocking for Reliable Tests

Stubbing GraphQL Queries in Cypress

Every GraphQL query and mutation is a POST to the same /graphql URL, so a path-based cy.intercept('POST', '/graphql') hands one stub to all of them — and the wrong-shaped fixture reaching a component is a classic silent flake. This page expands Cypress Network Interception Patterns within Network & API Mocking for Reliable Tests, showing how to branch on operationName, alias per operation, and wait deterministically.

14 sections URL: /network-api-mocking-for-reliable-tests/cypress-network-interception-patterns/stubbing-graphql-queries-in-cypress/
One endpoint routed by operation name A single POST /graphql intercept reads req.body.operationName and replies with the matching fixture, aliasing each operation. POST /graphqlreq.body GetUser → user.json ListOrders → orders.json PlaceOrder → error.json req.alias = opcy.wait('@GetUser')
One intercept reads the operation name in the POST body and dispatches to per-operation fixtures.

Root cause #

Cypress matches intercepts by method and URL, and every GraphQL operation shares the /graphql URL. A naive cy.intercept('POST', '/graphql', { fixture: 'x.json' }) therefore replies with the same body to the user query, the orders query, and the place-order mutation. The component receives a payload of the wrong shape and either renders wrong or throws, and because the failure depends on which operation the run happened to exercise first, it looks intermittent.

Path match returns one fixture for all Because every operation shares the URL, a URL-only intercept serves the same fixture to every query and mutation. GetUser PlaceOrder same fixture → wrong shape
Reading `req.body.operationName` is what separates the operations that share the URL.

The fix is to look inside the POST body, where GraphQL clients place { query, variables, operationName }, and branch on operationName. Assigning req.alias per operation then lets you cy.wait('@GetUser') and gate assertions on that exact response — the same aliasing discipline as aliasing and waiting on cy.intercept requests.

Step-by-step fix #

1. Branch on operationName and alias each operation #

Register one intercept and dispatch inside the handler.

Register, dispatch, alias One intercept reads the operation, replies with the matching fixture, and sets an alias for a deterministic wait. read operationName reply fixture req.alias = op
Aliasing by operation lets each assertion wait on its own response.
// cypress/support/graphql.js — one intercept, per-operation fixtures + aliases
cy.intercept('POST', '/graphql', (req) => {
  const op = req.body.operationName;
  const fixtures = { GetUser: 'user.json', ListOrders: 'orders.json' };
  if (fixtures[op]) req.reply({ fixture: fixtures[op] });
  req.alias = op; // enables cy.wait('@GetUser') for deterministic ordering
}).as('graphql');

2. Wait on the aliased operation before asserting #

cy.visit('/dashboard');
cy.wait('@GetUser').its('response.statusCode').should('eq', 200); // gate on the exact response
cy.get('[data-cy=user-name]').should('be.visible');

3. Return a GraphQL error correctly #

A failed mutation returns HTTP 200 with an errors[] array, not a 4xx/5xx.

// PlaceOrder failure: status 200 with an errors array, mimicking a real server.
if (op === 'PlaceOrder') {
  req.reply({ statusCode: 200, body: { errors: [{ message: 'Out of stock' }] } });
}

3b. Give every operation its own alias #

An alias is what makes the command log answer “which stub handled this”, and in GraphQL — where every request shares one path — that answer is otherwise expensive to obtain. Aliasing per operation rather than per endpoint turns an unexplained response into a single glance.

It also makes waiting precise. Waiting on a shared endpoint alias resolves on whichever operation arrived first, which on a page issuing three queries is effectively random; waiting on the operation’s own alias resolves on the one the assertion depends on.

// One alias per operation, not one per endpoint.
// Trade-off: more aliases to declare, and the command log becomes readable and
// waits become deterministic.
cy.intercept('POST', '/graphql', (req) => {
  const { operationName } = req.body;
  req.alias = operationName;              // GetInvoices, PayInvoice, …
  req.reply({ body: { data: fixtureFor(operationName) } });
});

cy.wait('@GetInvoices');

4. Handle batched operations explicitly #

Many GraphQL clients batch several operations into one HTTP request to reduce round trips, and a handler written for a single operation will answer for the whole batch — returning one operation’s data for a request that also contained two others. The symptom is components rendering with data that belongs to a different query, which reads as a caching bug rather than a mocking one.

// A batched request carries an array; respond per operation, in order.
// Trade-off: batch-aware handlers are more code and are unavoidable when the
// client batches — the alternative is disabling batching in test builds.
cy.intercept('POST', '/graphql', (req) => {
  const ops = Array.isArray(req.body) ? req.body : [req.body];
  req.reply(ops.map(({ operationName, variables }) => fixtureFor(operationName, variables)));
}).as('graphql');

Where the client supports it, disabling batching under test is a legitimate simplification — provided the batched configuration is exercised somewhere, since batching changes error handling and partial-failure behaviour in ways that are worth covering.

5. Model partial errors, which are the normal case #

GraphQL returns a 200 with data and errors populated together when some fields resolve and others do not — field-level authorisation, a downstream timeout, a nullable resolver failing. An interface that renders data without inspecting errors shows silently missing sections, and a test asserting only on the status cannot distinguish that from a complete response.

// 200, data present, one field errored — the shape real APIs return under load.
// Trade-off: partial fixtures are more elaborate than a plain failure and they
// exercise the branch that actually runs when a resolver degrades.
cy.intercept('POST', '/graphql', {
  statusCode: 200,
  body: {
    data: { invoice: { id: 'INV-1', total: 1250, customer: null } },
    errors: [{ message: 'customer service unavailable', path: ['invoice', 'customer'] }],
  },
}).as('partialInvoice');

The assertion that matters is what the interface does with it: the missing section should be visibly absent or explained rather than blank, and the rest of the page should still render.

6. Watch the client cache, which sits between the stub and the render #

A normalised client cache can merge a previously cached entity into the current render, and a cache-first fetch policy can skip the network entirely — in which case the stub is never called and a test waiting on its alias hangs. Both produce the impression that interception is broken when it is working perfectly.

Resetting the client’s store between tests, alongside any handler reset, removes the class. Where caching behaviour is itself the subject, keep one spec that exercises it deliberately rather than leaving every spec at the mercy of whatever the cache retained.

Pitfalls #

Cypress GraphQL anti-patterns and fixes Path-only match, missing operationName fallback, 4xx errors, and late registration each map to a fix. match on path only branch on operationName client omits operationName match a query substring return 4xx for GraphQL error 200 + errors[] array register after the action intercept in beforeEach
Each red habit reintroduces the wrong-fixture race; the green fix keys on operation identity.
  • Matching on path only returns the wrong fixture — branch on operationName.
  • Clients that omit operationName — fall back to a stable substring of query.
  • Returning 4xx for a GraphQL error — use 200 with an errors[] array.
  • Registering after the action — intercept in beforeEach before any navigation.

Reliability targets #

Cypress GraphQL scorecard Targets for wrong-fixture rate, operation coverage, flake rate, and CI pass rate. 0wrong fixtures 100%op coverage < 0.5%flake rate ≥ 99.5%CI pass
Per-operation fixtures with aliased waits drive wrong-fixture flakes to zero.
Metric Target How to hit it
Wrong-fixture responses 0 Branch on operationName
Operation coverage 100% of ops the page issues One fixture per operation
GraphQL test flake rate < 0.5% Alias + wait per operation
CI pass rate ≥ 99.5% Intercept before navigation

Frequently Asked Questions #

How do I read the operation name in Cypress? Inside the cy.intercept handler, read req.body.operationName; the body also has query and variables if you need to match on those.

Can I alias each GraphQL operation separately? Yes — set req.alias = operationName in the handler, then cy.wait('@GetUser') waits on that specific operation’s response.

Why does my mutation error test pass with a 500 fixture? It should not model a GraphQL error as a 500. Real servers return HTTP 200 with an errors[] array; stub that shape instead.

Why does my stub fire for the wrong query? Because the matcher is on the endpoint rather than on the operation. Every GraphQL request in an application posts to the same path, so a path-only intercept matches all of them and the first registered handler answers. Branch on operationName inside the handler, and give each operation its own alias so the command log shows which one answered.

Should variables be part of the match? When they change the response, yes. A query issued with status: 'open' and the same query with status: 'void' are different exchanges, and matching on the operation name alone returns whichever fixture was registered first. Where variables do not affect the answer, ignoring them keeps the handler simpler and more resilient to harmless additions.

How do I stub a mutation and its refetch? Treat them as two operations with separate aliases, and wait for both before asserting. A mutation that triggers a refetch produces two requests, and asserting after only the first sees the pre-refetch state — an intermittent failure whose frequency depends entirely on machine speed.

Do persisted queries break this approach? They can. With persisted queries the client sends a hash rather than the query text, and unless it also sends the operation name there is nothing readable to branch on. Check what the request body actually contains before writing matchers; if only a hash is present, either configure the client to include the operation name in test builds or match on the hash values themselves.

Fixtures Organised by Operation #

A GraphQL suite accumulates fixtures quickly, and organising them by operation name rather than by screen keeps them findable. One module per operation, exporting a factory that takes variables and returns the data shape, means a test states which operation it is overriding and why — and a schema change touches one file rather than every spec that happened to inline that payload.

Where a screen issues several operations, waiting on each alias individually rather than on a shared endpoint alias keeps the spec deterministic — a shared wait resolves on whichever operation happened to arrive first, which on a loaded runner is effectively arbitrary.

Keeping error fixtures beside their success counterparts makes it obvious which operations have failure coverage and which do not.