Stubbing GraphQL using Cypress

A more ergonomic solution to stubbing out multiple GraphQL responses using Cypress.

Published
Last updated

Cypress remains the go-to tool for end-to-end testing on the web. Despite the ageing assertion library and the occasionally awkward handling of async operations, it remains one of the best ‘batteries-included’ options today.

However, while recently tasked with writing a test-suite for a GraphQL-backed SPA, I was surprised to find the recommendations for stubbing requests lacking. While it’s true that server-rendered applications require a custom solution — after all, there’s no easy way for a browser-based tool to dynamically intercept and modify back-of-house requests — there is little in the way of inbuilt functionality to aid with client-side GraphQL in Cypress today.

The story so far

Up until recently, the story used to be a lot worse.

Cypress only just gained support for intercepting fetch in late 2020 with the introduction of intercept (the successor to route). This finally enabled support for stubbing requests sent by modern GQL clients such as Apollo and urql without resorting to homegrown monkey-patching. A dedicated section in the documentation was even added on how best to approach GraphQL.

However, while REST-ful APIs distinguish operations by a combination of method and endpoint (e.g. GET /posts, POST /posts), with GraphQL each operation shares the same endpoint (e.g. /query), making intercept’s one-stub-per-route somewhat ill-suited to handling multiple requests.

Instead, each GraphQL query and mutation provides an operationName field which uniquely identifies the operation to the server. Here’s an example of a request:

{ "operationName": "Channels", "variables": { "restaurantIdentifier": "14005" }, "query": "query Channels($restaurantIdentifier: ID!) {\n restaurantOrderChannels(restaurantIdentifier: $restaurantIdentifier) {\n restaurantIdentifier\n fulfilmentMethod\n online\n provider\n reasons {\n expiry\n __typename\n }\n attributes {\n name\n value\n __typename\n }\n __typename\n }\n}\n" }

The official recommendation, then, involves checking the operation name inside your handler and responding with the appropriate stub:

cy.intercept('POST', 'http://localhost:3000/graphql', (req) => { if (req.body.operationName === 'Channels') { req.reply((res) => { // Modify the response body directly }); } });

While this works, each fresh call to intercept takes precedence over those which came before. If, later in the test, you were to register a separate handler for a different GraphQL operation, it would overwrite any that were previously declared:

cy.intercept('POST', 'http://localhost:3000/graphql', (req) => { if (req.body.operationName === 'Channels') { // etc. } }); cy.intercept('POST', 'http://localhost:3000/graphql', (req) => { if (req.body.operationName === 'CreateChannel') { // etc. } }); // The `Channels` operation will no longer be stubbed.

This behaviour makes a lot of sense for route-based handlers, but makes intercepting GraphQL extremely awkward, since all stubs must be declared in a single handler. If you need to add a new interception, but want to retain the previous ones, you have to re-add them explicitly:

cy.intercept('POST', 'http://localhost:3000/graphql', (req) => { // Add a new interception if (req.body.operationName === 'CreateChannel') { // etc. } // Copy over the old ones... if (req.body.operationName === 'Channels') { // etc. } });

There are existing library solutions out there, several which predate Cypress’s support for fetch (and so focus on either monkey-patching or lean on MSW), but the solution today is so dead-simple that I think it’s a shame that Cypress stops short of suggesting it: (semi?) global state.

interceptGQL

We can create a simple Cypress command which wraps intercept and:

  • Retrieves any existing interceptions for the current URL.
  • Merges in the new interception with those already registered, using the operationName as the key.
  • Stores the new interceptions.
  • Registers a Cypress interception using intercept if it doesn’t already exist for the given URL. Inside the intercept handler we check to see we have a matching entry for the current operationName. If so, we serve it up.

The result is something like this:

interface GQLResponse<T> { data: T; errors?: { message: string; locations: { line: string; column: string; }[]; }[]; } Cypress.Commands.add( "interceptGQL", <T>( url: string, operation: string, data: GQLResponse<T>, alias?: string ) => { // Retrieve any previously registered interceptions. const previous = Cypress.config("interceptions"); const alreadyRegistered = url in previous; const next = { ...(previous[url] || {}), [operation]: { alias, data }, }; // Merge in the new interception. Cypress.config("interceptions", { ...previous, [url]: next, }); // No need to register handler more than once per URL. Operation data is // dynamically chosen within the handler. if (alreadyRegistered) { return; } cy.intercept("POST", url, (req) => { const interceptions = Cypress.config("interceptions"); const match = interceptions[url]?.[req.body.operationName]; if (match) { req.alias = match.alias; req.reply({ body: match.data }); } }); } );

In order to ensure we clean up after ourselves, we also need to make sure that before each test the interceptions are reset. This can be done in a beforeEach hook within the global support file:

beforeEach(() => { Cypress.config("interceptions", {}); });

We can then intercept as many requests as we want, sequentially, without fear of overwriting the previous ones:

const stubChannels = { data: { channels: [] } }; cy.interceptGQL("/channels/query", "Channels", stubChannels); // Do some things... const stubChannel = { data: { channel: { id: "1" } } }; cy.interceptGQL("/channels/query", "CreateChannel", stubChannel);

This also makes it easy to place common interceptions in a beforeEach hook, while still adding test-specific interceptions within the test-body itself.

In the above example we’re using Cypress’s Config API to store the interceptions, however there’s no reason why a module-global object wouldn’t work just as well.

Going further

Once in place, interceptGQL can easily be extended to support strongly typed fixtures and operation names, using GraphQL Code Generator and the named operations plugin to ensure that the correct stub data is passed along with each interception.

This also extends to the Config API, to which we can add our interceptions like so:

declare global { namespace Cypress { interface ResolvedConfigOptions { interceptions: { [url: string]: { [operation: string]: { alias?: string; data: unknown; }; }; }; } } }

Hopefully one day we see a solution baked-in to the tool, but until then this is a simple fix that greatly increases the readability (and flexibility) of your tests.

← Home