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.
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.
// 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 #
- Matching on path only returns the wrong fixture — branch on
operationName. - Clients that omit
operationName— fall back to a stable substring ofquery. - Returning 4xx for a GraphQL error — use 200 with an
errors[]array. - Registering after the action — intercept in
beforeEachbefore any navigation.
Reliability targets #
| 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.