Smart GraphQL Stubbing in Cypress

How to spy on and stub GraphQL calls from the application using Cypress and its new cy.route2 API

🧭 Find the source code for this blog post in the repository bahmutov/todo-graphql-example

Testing GraphQL calls

I have implemented a TodoMVC application using GraphQL backend: you can add new todos, mark them completed, and delete them.

Application in action

All HTTP requests to the backend go through the same endpoint POST /, making traditional stubbing using Cypress cy.route command difficult.

All GraphQL calls look the same

In this blog post I will show how to spy on network calls using the new much more powerful command cy.route2. I don't know if this approach would become the official Cypress library for working with GraphQL; this is my own exploration. Previously I have explored stubbing the entire backend during testing, but I do see a value in using the actual backend during end-to-end testing.

Testing without network control

Before I start spying and stubbing network calls from the application to the backend, let's try writing a test that does NOT control the network, and cannot even observe the GraphQL network calls. Since we do not control the backend either, we are left with guesses about what data our application receives from the backend. Thus if we want to check how the user toggles a todo, we have to "play" against the user interface only. We cannot do specific actions during the test like:

  • load 2 todos
  • find the first todo
  • assert it is not completed yet
  • click on the checkbox
  • the first todo should have class completed

Instead our test has to hope that there is at least a single todo returned by the backend. The test cannot assume the first item is NOT complete at the start; instead it only can check if the class changes after clicking on the checkbox.

cypress/integration/ui-spec.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
describe('TodoMVC', () => {
it('toggles todo', () => {
let startClass
cy.visit('/')
cy.get('li.todo')
.should('have.length.gt', 0)
.first()
.invoke('attr', 'class')
.then((x) => {
startClass = x
})

cy.get('li.todo').first().find('.toggle').click()

// the class names should change
cy.get('li.todo')
.first()
.invoke('attr', 'class')
.should((x) => {
expect(x).to.not.equal(startClass)
})
})
})

Without controlling the data our test can only toggle the Todo

The test is non-deterministic: notice how every run is either "incomplete -> complete Todo" or "complete -> incomplete Todo" scenario. Without tightly setting up the backend data and without spying or stubbing the network call fetching the data, the test has no way of performing the same actions.

route2

Recently we have introduced cy.route2 command that can intercept any HTTP call made by the application: it can intercept static resources like HTML and CSS, it can intercept any Ajax call (XMLHttpRequest and fetch). Thus we can build powerful abstractions on top of cy.route2 with the following main features:

  • you can stub a call using cy.route2(..., <response object>)
  • you can inspect an outgoing request with cy.route2(..., (req) => ... inspect req) without stubbing it
  • you can stub a call using cy.route2(..., (req) => ... req.reply(...)) which provides a way to do dynamic stubbing
  • you can inspect and even modify the server's response with cy.route2(..., (req) => ... req.reply((res) => ...) with the server's response being the res variable

Let's use this to deal with GraphQL calls our application makes.

Operation name

Every GraphQL call I see in this application sends a JSON object in the request, and receives a JSON object back. Every request includes a field operationName with values allTodos, AddTodo, updateTodo, DeleteTodo in our application.

Operation name in every request

Let's use the operation name to distinguish the network calls. Traditionally the REST API calls would have the HTTP method to do the same, but here we can spy on the request, parse it, and stub the first request that loads the data using operationName: allTodos request. We will use the cy.route2 command to spy on the fetch POST / request, but without stubbing it at first, just printing the request object sent to the server.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
describe('TodoMVC with GraphQL cy.route2', () => {
it('completes the first todo', () => {
cy.route2(
{
method: 'POST',
url: '/',
},
(req) => {
const g = JSON.parse(req.body)
console.log(g)
},
)
cy.visit('/')
})
})

The printed object shows the operation name, the query, and the empty variables object, since fetching all todos does not need to send any variables.

Printed GraphQL request object

Let's stub the response - this will always load the same todos in the application, allowing us to reliably test completing an item user story:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
const allTodos = [
{ id: '1', title: 'use GraphQL', completed: false, __typename: 'Todo' },
{
id: '2',
title: 'test with Cypress',
completed: true,
__typename: 'Todo',
},
]

cy.route2(
{
method: 'POST',
url: '/',
},
(req) => {
const g = JSON.parse(req.body)
if (g.operationName === 'allTodos') {
req.reply({
body: {
data: {
allTodos,
},
},
// must mimic the backend headers, CORS is a thing
headers: {
'access-control-allow-origin': '*',
},
})
}
},
)

The test replies to GraphQL operations allTodos with the same list of items. All other requests will be passed to the backend unchanged, since we did not execute the req.reply call.

Let's finish writing the full test: we know the first item is incomplete; thus we can confirm the first list item gets the expected class when clicked.

1
2
3
4
5
6
7
8
9
10
cy.route2(...)
cy.visit('/')
cy.get('.todo-list li')
.should('have.length', 2)
.first()
.should('not.have.class', 'completed')
.find('.toggle')
.click({ force: true })

cy.get('.todo-list li').first().should('have.class', 'completed')

The test fails the last assertion - the Todo item nevers gets the expected completed class for some reason.

The first item never gets the expected class

Hmm, we see 2 GraphQL requests happening after clicking on the .toggle checkbox. The application sends updateTodo query and then fetches the updated list of todos. But we have stubbed the operationName: allTodos and are returning the original list. An ideal test in my opinion would do the following in this case:

  • stub the operation: allTodos request like we did to return known list of Todos
  • stub the operation: UpdateTodo request to confirm the right call to the backend is performed after clicking on the item
  • stub the operation: allTodos request with changed data matching what the server normally would return

We can add this logic to our test inside the cy.route2 handler:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
let allTodosCount = 0

cy.route2(
{
method: 'POST',
url: '/',
},
(req) => {
const g = JSON.parse(req.body)
if (g.operationName === 'allTodos') {
allTodosCount += 1

if (allTodosCount === 1) {
// return the list of todos for the first "allTodos"
req.reply({
body: {
data: {
allTodos,
},
},
headers: {
'access-control-allow-origin': '*',
},
})
} else if (allTodosCount === 2) {
// return the updated list of todos for the second "allTodos"
const completedFirstTodo = Cypress._.cloneDeep(allTodos)
completedFirstTodo[0].completed = true
req.reply({
body: {
data: {
allTodos: completedFirstTodo,
},
},
headers: {
'access-control-allow-origin': '*',
},
})
} else {
// do not allow any more unexpected calls
throw new Error('Did not expect more allTodos requests')
}
} else if (g.operationName === 'updateTodo') {
// confirm the web app sends the right variables
// to make a todo completed
expect(g.variables).to.deep.equal({
id: '1',
completed: true,
})
req.reply()
} else {
throw new Error(`Unexpected operation ${g.operationName}`)
}
},
)
// the rest of the test

The test now passes and works the same - and it never sends stray requests to the server (via updateTodo request).

The consistent test using GraphQL stubbing

Since we can spy and stub GraphQL requests, our tests can do everything we might want from the Cypress Network Guide. But our test is pretty verbose. Can we do better?

Refactoring common code

Our cy.route2(...) calls are very verbose - they parse the request, look at the operation name property, respond with an appropriate stub. We should factor out this logic into a nice little helper. I call my helper routeG since it is route2 + GraphQL logic. The main idea is to make it simple to deal with GraphQL operations, inspect the outgoing requests, and check the variables passed back and forth.

  1. We are constantly parsing the request and response JSON objects to check the operation name. The helper method should take care of parsing request bodies
  2. The call stub has to set the CORS headers on every request, we probably want to move these common headers into a factory method to avoid adding to every request

I called my library routeG - similar to cy.route2 but meant for GraphQL. Here is how it works - you can use it directly to stub calls using operation names.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import { routeG } from './routeG'

it('stubs all todos (simpler)', () => {
const allTodos = [...]
routeG(
{
// stub any call to "operation: allTodos" with this response
// that will be placed into "body: data: {...}"
allTodos: {
allTodos,
},
},
// extra options
{
headers: {
'access-control-allow-origin': '*',
},
},
)
cy.visit('/')
cy.get('.todo-list li').should('have.length', allTodos.length)
})

Every time application calls GraphQL method with operationName: allTodos our interceptor built on top of cy.route2 responds with the given list of todo items.

Every allTodos call is stubbed

Since we pass the headers with every response, let's use another method routeG exports to make a method that adds those headers automatically.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import { initRouteG } from './routeG'

it('stubs all todos (best)', () => {
// make our own routeG with automatically attached headers
const routeG = initRouteG({
headers: {
'access-control-allow-origin': '*',
},
})
routeG({
// stub any call to "operation: allTodos" with this response
// that will be placed into "body: data: {...}"
allTodos: {
allTodos,
},
})
cy.visit('/')
cy.get('.todo-list li').should('have.length', allTodos.length)
})

Great. What about multiple queries? For example in our "complete todo" test we needed different responses to the allTodos requests, and we needed a single response to the updateTodo request. Sure - routeG accepts an object with keys being the operations, and the values are either single stubs or lists of stubs for first call, second call, etc.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
it('completes the first todo', () => {
// respond to every request (if there is a stub)
// with same CORS headers
const routeG = initRouteG({
headers: {
'access-control-allow-origin': '*',
},
})

const completedFirstTodo = Cypress._.cloneDeep(allTodos)
completedFirstTodo[0].completed = true

// respond twice (differently) to `operation: allTodos` calls
// and respond once to `updateTodo` call
routeG({
// when application loads the list, reply with the initial list
allTodos: [
{
allTodos,
},
// but for the second request reply with updated list
{
allTodos: completedFirstTodo,
},
],
// when the app tries to update a todo
// stub the call so it does not go to the server
updateTodo: {},
})
cy.visit('/')
// make first item completed
// check the UI, since it should be updated correctly
})

Complete Todo test with 3 stubs

Inspecting requests

When we test completing an item, we want to confirm the GraphQL calls going to the backend. It should have the right operation name, and have the right variables. Our helper routeG takes core of it automatically - it records all GraphQL requests automatically. If we want to inspect them, just use the response property from the routeG(...) call.

1
2
3
4
5
6
7
8
9
10
11
12
13
const { requests } = routeG({ ... })
// complete the first item
// check the update call to the server
cy.log('check call **updateTodo**')
cy.wrap(requests)
.its('updateTodo.0')
.should('deep.contain', {
operationName: 'updateTodo',
variables: {
id: '1',
completed: true,
},
})

All requests are saved by the operation name. We can see them all in the cy.wrap(requests) object.

All GraphQL requests are linked in a single object

Bonus 1 - number of requests

When using cy.route or cy.route2 the only way to confirm the number of requests was to wait for each one using cy.wait(<alias>). For example, to assert there were two Ajax calls:

1
2
3
cy.route2(...).as('ajax')
cy.wait('@ajax')
cy.wait('@ajax')

With routeG and its requests object, you simply assert the length of the array using cy.its with its built-in retry-ability:

1
2
3
const { requests } = routeG({...})
// check if the app really called "operationName: allTodos" twice
cy.wrap(requests).its('allTodos').should('have.length', 2)

Learn more

I might play more with routeG to see if I can write useful end-to-end tests for my web applications, and if it turns useful, I might move it from bahmutov/todo-graphql-example into its own NPM package. Meanwhile, check out: