Stubbing GraphQL requests with Cypress

June 15, 2020

Having start­ed using Cypress recent­ly, I’ve been impressed at how easy it was to write inte­gra­tion tests that just work. That said, there are a few sur­pris­ing hic­cups I’ve encoun­tered while using it.

For exam­ple, it’s a well-known issue that Cypress, even in 2020, does not sup­port the Fetch API. This pre­sents a few issues, espe­cial­ly when using GraphQL and Apollo.

The issue page linked above pre­sents a few workarounds, but all of the ones I’ve tested either don’t work or are too com­pli­cat­ed, so I ended up writ­ing my own ver­sion that allows Apollo GraphQL calls to be stubbed out.

Usage

This plugin can be used like this, by spec­i­fy­ing the query name (userAccount in this case), and the fake pay­load 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 con­tin­ue to pass through to the server unmod­i­fied.

It can sup­port fail­ure sce­nar­ios 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-batch­ing ver­sion

There are two ver­sions, one that sup­ports batch­ing and one that does­n’t. I’ll show the latter ver­sion first, since it’s sim­pler:

// 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 orig­i­nal fetch object in originalFetch so that we can use it later for unstubbed queries. Then, we create a custom ver­sion 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 fol­low­ing:

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

We check if operationName match­es any of the stubbed queries. If it does, we will return the response in a Fetch-like response object, using the responseStub util­i­ty func­tion. Oth­er­wise, we will let the request pass through unmod­i­fied to the server using originalFetch.

If your app does­n’t use batch­ing, then you’re done. Oth­er­wise, read on.

Batch­ing ver­sion

To sup­port batch­ing, we first need to see how batched requests and respons­es are rep­re­sent­ed. In Apollo, this is straight­for­ward — the batched query is simply an array of query objects, and the batched response is an array of query respons­es:

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

There­fore, to stub out cer­tain 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 respons­es back into their orig­i­nal posi­tions to form the final response, which will con­tain a mix of actual server respons­es and respons­es 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
}

Exten­sions

We’re only switch­ing on query names, but as you can see, since we have pro­gram­mat­ic access to the request, we can extend the com­mand to behave/​stub dif­fer­ent­ly based on other aspects of the GraphQL request, such as the value of the vari­ables (e.g. return this response if accountData is queried with a user ID of 1, oth­er­wise return anoth­er response) This will be an easy and sig­nif­i­cant improve­ment to the cur­rent imple­men­ta­tion, and I’ll update this blog post with that behav­iour if or when I have the need for it.