Skip to content

Stubbing GraphQL requests with Cypress

Posted on:June 15, 2020

Having started using Cypress recently, I’ve been impressed at how easy it was to write integration tests that just work. That said, there are a few surprising hiccups I’ve encountered while using it.

For example, it’s a well-known issue that Cypress, even in 2020, does not support the Fetch API. This presents a few issues, especially when using GraphQL and Apollo.

The issue page linked above presents a few workarounds, but all of the ones I’ve tested either don’t work or are too complicated, so I ended up writing my own version that allows Apollo GraphQL calls to be stubbed out.

Usage

This plugin can be used like this, by specifying the query name (userAccount in this case), and the fake payload that should be used:

context("Accounts page", () => {
  beforeEach(() => {
    cy.graphql({
      userAccount: {
        payload: {
          data: {
            user: {
              id: 1,
              first_name: "Bob",
              last_name: "Smith",
              __typename: "User",
            },
          },
        },
      },
    });
    cy.visit("/account");
  });
 
  it("should display user first and last name correctly", () => {
    cy.get(".first-name").should("have.text", "Bob");
    cy.get(".last-name").should("have.text", "Smith");
  });
});

In this case, the userAccount query will be stubbed out, while the rest of the GraphQL queries will continue to pass through to the server unmodified.

It can support failure scenarios too, like so:

context("Accounts page", () => {
  beforeEach(() => {
    cy.graphql({
      appData: {
        payload: null,
        ok: false,
      },
    });
    cy.visit("/account");
  });
 
  it("should display an error snackbar if server response is non 200", () => {
    cy.get(".snackbar").should("be.visible");
  });
});

or:

context("Accounts page", () => {
  beforeEach(() => {
    cy.graphql({
      appData: {
        payload: {
          data: null,
          errors: [
            {
              message: "Internal server error",
              path: ["accounts"],
              locations: [
                {
                  line: 24,
                  column: 30,
                },
              ],
            },
          ],
        },
      },
    });
    cy.visit("/account");
  });
 
  it("should display an error snackbar if server response is non 200", () => {
    cy.get(".snackbar").should("be.visible");
  });
});

Non-batching version

There are two versions, one that supports batching and one that doesn’t. I’ll show the latter version first, since it’s simpler:

// responseStub takes in an object and returns an object that mimics
// the response object from a fetch call
const responseStub = ({ payload, ok }) =>
  Promise.resolve({
    json() {
      return Promise.resolve(payload);
    },
    text() {
      return Promise.resolve(JSON.stringify(payload));
    },
    ok: ok === undefined ? true : ok,
  });
 
Cypress.Commands.add("graphql", stubbedGQLResponses => {
  cy.on("window:before:load", win => {
    const originalFetch = win.fetch;
    const fetch = (path, options, ...rest) => {
      if (options.body) {
        try {
          const body = JSON.parse(options.body);
          if (body.operationName in stubbedGQLResponses) {
            const stubbedResponse = stubbedGQLResponses[body.operationName];
            return responseStub(stubbedResponse);
          }
          return originalFetch(path, options, ...rest);
        } catch (e) {
          return originalFetch(path, options, ...rest);
        }
      }
      return originalFetch(path, options, ...rest);
    };
    cy.stub(win, "fetch", fetch);
  });
});

First, we listen to the window:before:load event in order to stub out the fetch API before the page loads. We store the original fetch object in originalFetch so that we can use it later for unstubbed queries. Then, we create a custom version of fetch which parses the request body to figure out if we need to stub the request, or let it pass through to the server.

Given that an Apollo GraphQL request looks like the following:

{
  "operationName": "accountData",
  "variables": {
    "id": 1
  },
  "query": "query accountData($id: Int!) {\n  ..."
}

We check if operationName matches any of the stubbed queries. If it does, we will return the response in a Fetch-like response object, using the responseStub utility function. Otherwise, we will let the request pass through unmodified to the server using originalFetch.

If your app doesn’t use batching, then you’re done. Otherwise, read on.

Batching version

To support batching, we first need to see how batched requests and responses are represented. In Apollo, this is straightforward — the batched query is simply an array of query objects, and the batched response is an array of query responses:

[
  {
    "operationName": "accountData",
    "variables": {
      "id": 1
    },
    "query": "query accountData($id: Int!) {\n  ..."
  },
  {
    "operationName": "userListings",
    "variables": {
      "id": 1
    },
    "query": "query userListings($id: Int!) {\n  ..."
  }
]

Therefore, to stub out certain queries in a batch request, we need to extract out the stubbed queries, and let only unstubbed queries pass through to the server. When the server returns with the batched response, we need to able to put the stubbed responses back into their original positions to form the final response, which will contain a mix of actual server responses and responses that have been stubbed. Here’s how I did it:

Cypress.Commands.add("graphql", stubbedGQLResponses => {
  cy.on("window:before:load", win => {
    const originalFetch = win.fetch;
    const fetch = (path, options, ...rest) => {
      if (options.body) {
        try {
          const body = JSON.parse(options.body);
          // if the query is batched
          if (Array.isArray(body)) {
            const { filteredQueries, indexes } = removeStubbedQueries(
              body,
              stubbedGQLResponses
            );
            options.body = JSON.stringify(filteredQueries);
            return originalFetch(path, options, ...rest)
              .then(resp => resp.json())
              .then(data => {
                const interpolatedResp = interpolateStubbedResponses(
                  indexes,
                  stubbedGQLResponses,
                  data
                );
                return responseStub({ payload: interpolatedResp, ok: true });
              });
          } else if (body.operationName in stubbedGQLResponses) {
            const stubbedResponse = stubbedGQLResponses[body.operationName];
            return responseStub(stubbedResponse);
          }
          return originalFetch(path, options, ...rest);
        } catch (e) {
          return originalFetch(path, options, ...rest);
        }
      }
      return originalFetch(path, options, ...rest);
    };
    cy.stub(win, "fetch", fetch);
  });
});
 
const removeStubbedQueries = (queries, stubbedGQLResponses) => {
  const filteredQueries = [];
  const indexes = {};
  queries.forEach((q, i) => {
    if (q.operationName in stubbedGQLResponses) {
      indexes[i] = q.operationName;
    } else {
      filteredQueries.push(q);
    }
  });
  return {
    filteredQueries,
    indexes,
  };
};
 
const interpolateStubbedResponses = (
  indexes,
  stubbedGQLResponses,
  response
) => {
  let point = 0;
  const finalResponse = new Array(
    Object.keys(indexes).length + response.length
  );
  for (let i = 0; i < finalResponse.length; i++) {
    if (i in indexes) {
      finalResponse[i] = stubbedGQLResponses[indexes[i]].payload;
    } else {
      finalResponse[i] = response[point];
      point++;
    }
  }
  return finalResponse;
};

Extensions

We’re only switching on query names, but as you can see, since we have programmatic access to the request, we can extend the command to behave/stub differently based on other aspects of the GraphQL request, such as the value of the variables (e.g. return this response if accountData is queried with a user ID of 1, otherwise return another response) This will be an easy and significant improvement to the current implementation, and I’ll update this blog post with that behaviour if or when I have the need for it.