Mocking GraphQL with Lunar in Cypress End-to-End Tests

How to mock GraphQL endpoints using Lunar from Cypress tests.

ReactBoston was in town this weekend. While I have not attended, I saw lots of tweets about it. One of the tweets from @swyx caught my eye:

https://twitter.com/swyx/status/1046482557997584386

I have recently spoke at Boston JS meetup hosted by ezCater but I don't think I had pleasure speaking to Hillary Bauer @mama_bau or Mark Faga @markjfaga. But if you check out their presentation at https://github.com/mjfaga/react-boston-2018-lunar-launch - it includes the complete example project and slides. They show how to solve a hard problem - mock a GraphQL backend from end-to-end tests. For the test runner they have picked Cypress of course - the open source test runner I work on!

So I was intrigued, especially because we don't have a good recommended way of stubbing GraphQL requests - it is coming as part of network stubbing rewrite. So I took a look at the example project, and I loved what I saw. For this blog I forked the repo to https://github.com/bahmutov/react-boston-2018-lunar-launch because I want to show some other tricks you can play to make e2e tests even better.

Server setup

By default you should start GraphQL API (runs on port 3001) and the front end site (runs on port 3000). First, you see list of users.

List of users

Clicking on any username shows the list of favorite foods. For the first user the list shows

First user

Note that this data is generated by the Faker module in mocks/mocks.js

mocks/mocks.js
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
/* eslint-disable import/no-commonjs */
const {MockList} = require('apollo-server');
const faker = require('faker');

const mocks = {
FoodItem: () => ({
name: faker.helpers.randomize([
'Chocolate Ice Cream',
'Peppers',
'Hummus',
'Sushi',
'Eggs Benedict',
'Pad Se Ew',
]),
}),
User: () => ({
name: faker.name.findName(),
favoriteFoods: () => new MockList(5),
}),
Query: () => ({
users: () => new MockList(3),
}),
};

module.exports = mocks;

Every time the GraphQL server starts, a new list of users is generated, and the order of foods is scrambled, which plays havoc with end-to-end tests.

End-to-end Tests

The tests were written using Cypress.io - which runs a real browser (Electron or Chrome) and shows the test steps on the left. You can find the tests in spec file cypress/integration/AddFavoriteFoodToUser.spec.js file. To execute tests have the application and API running and open Cypress with npx cypress open command. Then click on the spec filename AddFavoriteFoodToUser.spec.js and enjoy the fast running tests.

Running tests

These are true end-to-end tests - they load the app in the browser, click on DOM elements, type into input elements and assert the application shows the expected value after it fetches the data from the server. But the tests always pass, even if the data the server generates changes. How does it do it?

Let us take a look at the very first test - because it is the most interesting one. Here it is:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
it('renders the correct number of food items for the user', () => {
cy.mock({
Query: () => ({
user: () => ({
favoriteFoods: [
{foodItem: {name: 'Spaghetti'}, eatingFrequency: 'WEEKLY'},
{foodItem: {name: 'Coconut Water'}, eatingFrequency: 'DAILY'},
],
}),
}),
});

// Navigate to page
cy.visit('http://localhost:3000/user/1');

// Validate page content
cy.get('h2').contains('favorite foods:');
cy.get('li').should('have.length', 2);
cy.contains('li', 'I like to eat Spaghetti on a weekly basis');
cy.contains('li', 'I like to eat Coconut Water on a daily basis');
});

When the test starts, the test sends a GraphQL mock to the server using custom cy.mock command. Here is what this command looks like (from cypress/support/commands.js file)

cypress/support/commands.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
const host = 'http://localhost:3001';

Cypress.Commands.add('store', method => {
cy.request('POST', `${host}/store/${method}`);
});

Cypress.Commands.add('mock', mocks => {
const serializedMocks = Object.keys(mocks).reduce(
(packet, key) => Object.assign(packet, {[key]: mocks[key].toString()}),
{}
);

cy.request('POST', `${host}/store/mock`, serializedMocks);
});

The application GraphQL server loads lunar-express middleware, which exposes /store/<method> and /store/mock endpoints. Before each test our tests invoke cy.store('reset') command from cypress/support/index.js file

cypress/support/index.js
1
2
3
import './commands';

beforeEach(() => cy.store('reset'));

and before we visit the page we send mock GraphQL definitions with cy.mock to the server adding them to the resolvers.

1
2
3
4
5
6
7
8
9
10
11
// inside a test
cy.mock({
Query: () => ({
user: () => ({
favoriteFoods: [
{foodItem: {name: 'Spaghetti'}, eatingFrequency: 'WEEKLY'},
{foodItem: {name: 'Coconut Water'}, eatingFrequency: 'DAILY'},
],
}),
}),
});

When the app asks for data, it receives this mock data, the same 2 foods every time, no matter what the user id is. This is why the test can visit page /users/1 and expect the DOM to always contain "Spaghetti" and "Coconut Water", assuming the application logic uses the GraphQL response correctly.

1
2
3
4
5
6
7
8
9
// after cy.mock
// Navigate to page
cy.visit('http://localhost:3000/user/1');

// Validate page content
cy.get('h2').contains('favorite foods:');
cy.get('li').should('have.length', 2);
cy.contains('li', 'I like to eat Spaghetti on a weekly basis');
cy.contains('li', 'I like to eat Coconut Water on a daily basis');

Great! Let us improve this test a little bit. And these improvements are not because the above code is bad - not at all. The mocking and tests are 100% solid, I love them. The improvements are further refinements that will make the tests a little bit tighter, but not fundamentally different.

Show GraphQL requests

When we look at the command report on the left side of Cypress, we can see that there were two requests to the server using cy.request and there was an XHR call to /sockjs-node. Cool, but where is the GraphQL request our application under test made? To see what happens, open DevTools and rerun the tests.

XHR calls

Notice that the call we do see to /sockjs-node is of type xhr, while the GraphQL call is of type json. Well, the json calls are invisible to Cypress for now, just like any non-xhr call. So we need a way around it. The simplest way is to remove window.fetch from our browser and force GraphQL client to fall back to XHR. There is an entire Cypress recipe showing different ways of doing this. In our case we can react to window:before:load event in cypress/support/index.js file.

cypress/support/index.js
1
2
3
4
5
6
7
8
9
10
import './commands';

// Cypress does not stub "fetch" yet, only XHR
// here we delete window.fetch on every page load
// GraphQL client is thus forced to polyfill with XHR
Cypress.on('window:before:load', win => {
delete win.fetch
})

beforeEach(() => cy.store('reset'));

Now "magically" the POST /graphql calls appear in the command log.

GraphQL XHR call

Wait on GraphQL call

Now that our tests can "see" the GraphQL call from the application, we can make the test more precise. We can make our test wait for that call to happen, before we start checking the DOM. We will start observing XHR calls using cy.server and will setup spy on route POST /graphql using cy.route

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
it('renders the correct number of food items for the user', () => {
// cy.mock here

// spy on GraphQL call
cy.server()
cy.route('POST', '/graphql').as('graphql')

// Navigate to page
cy.visit('http://localhost:3000/user/1');

// wait for GraphQL call to happen
cy.wait('@graphql')

// Validate page content
cy.get('h2').contains('favorite foods:');
// the rest ...
});

The command log has a new section on the top with all XHR spies and shows when the matching request happens

Wait on GraphQL XHR call

Show GraphQL response

We can see the WAIT @graphql command in the log, but when we click on that command (while DevTools is open) we don't really get to inspect the response. It is just a Blob object

Response is a Blob

I would like to print the decoded returned value in the DevTools when I click on WAIT @graphql command. To do this, we can add a custom command like this

cypress/support/commands.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Cypress.Commands.add('blob', alias => {
return cy.wait(alias, {log: false})
.then(r => Cypress._.get(r, 'response.body'))
.then(Cypress.Blob.blobToBase64String)
.then(x => atob(x))
.then(JSON.parse)
.then((x) => {
Cypress.log({
name: 'wait blob',
displayName: `Wait ${alias}`,
consoleProps: () => {
return x
}
})
return x
})
})

Note: I am using .then(r => Cypress._.get(r, 'response.body')) and not .its('response.body') because .then calls do NOT show up in the command log, while .its calls always do. Since I want to only show Cypress.log output, I am using the longer callback function to avoid extra printing.

Now when we see Wait @graphql in the command log, we can click on it and see the response object

Decoded Blob

Using decoded Blob values

Now that we have decoded response from the Blob, we can actually confirm that the Lunar mock server is sending the expected values. For example, we could extract the names and frequencies of foods from the Blob and check them.

1
2
3
4
5
6
7
8
9
// wait for GraphQL call to happen and check food items
cy.blob('@graphql').its('data.user.favoriteFoods')
.then(list => Cypress._.map(list, x =>
({name: x.foodItem.name, eatingFrequency: x.eatingFrequency})))
// should be same values as in GraphQL mock
.should('deep.equal', [
{name: 'Spaghetti', eatingFrequency: 'WEEKLY'},
{name: 'Coconut Water', eatingFrequency: 'DAILY'}
])

It is just massaging the data using Lodash helpers bundled in Cypress._.

Or we can just grab the user name and use it to validate the heading text

1
2
3
4
5
6
7
8
9
10
// wait for GraphQL call to happen and grab user the name
cy.blob('@graphql').its('data.user.name')
.then(name => {
// Validate page content
cy.contains('h2', `${name}'s favorite foods:`);
})

cy.get('li').should('have.length', 2);
cy.contains('li', 'I like to eat Spaghetti on a weekly basis');
cy.contains('li', 'I like to eat Coconut Water on a daily basis');

Note that we get the name value in the callback, and then use it to validate h2 element, but we don't need to put all other commands like cy.get or cy.contains into the .then callback. Cypress commands are queued automatically into the same queue. cy.get('li') will only run after cy.then(...) completes, which completes only when cy.contains('h2', ...) passes.

Use returned name to verify DOM

Limits

The custom command cy.mock serializes the given mock object and sends it to the backend. Thus it can pass value like name for example if it is hard-coded property.

1
2
3
4
5
6
7
8
9
10
11
cy.mock({
Query: () => ({
user: () => ({
name: 'Joe Smith',
favoriteFoods: [
{foodItem: {name: 'Spaghetti'}, eatingFrequency: 'WEEKLY'},
{foodItem: {name: 'Coconut Water'}, eatingFrequency: 'DAILY'},
],
}),
}),
});

but not if it is a variable outside the function's immediate closure

1
2
3
4
5
6
7
8
9
10
11
12
const name = 'Joe Smith'
cy.mock({
Query: () => ({
user: () => ({
name,
favoriteFoods: [
{foodItem: {name: 'Spaghetti'}, eatingFrequency: 'WEEKLY'},
{foodItem: {name: 'Coconut Water'}, eatingFrequency: 'DAILY'},
],
}),
}),
});

Cannot use variables from outside the closure

So you are limited to getting the data from the decoded GraphQL response or duplicate data between the mock and the rest of the test. But wait, there is more! The sent mock functions are actually invoked by the GraphQL server, thus they can use anything from the input parameters server-side. For example

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
cy.mock({
Query: () => ({
user: () => ({
favoriteFoods: [],
}),
}),
Mutation: () => ({
addFavoriteFood: (parent, args) => ({
// parent and args passed by the GraphQL server
foodItem: {
name: args.name,
},
eatingFrequency: args.eatingFrequency,
}),
}),
});

Update 1: Mock client-side

If you do not NOT want to include Lunar in your server code, and would like to completely mock GraphQL endpoint, you can stub the request before it even leaves the Cypress browser. See issue #122 and here is short example.

1
2
3
4
5
6
7
8
9
10
11
12
13
cy.visit(url, {
onBeforeLoad: win => {
cy
// stub `fetch`
.stub(win, 'fetch')

// your graphql endpoint
.withArgs('/graphql')

// call our stub
.callsFake(serverStub)
},
})

The above code visits the page, but then right away overwrites window.fetch in the application iframe. And it only overwrites it using Sinon.js bundled in Cypress when called with argument /graphql. Whenever application code tries to do fetch('/graphql') a fake function serverStub gets executed. Of course puts ALL logic client-side into a function serverStub that you will need to write.

Final thoughts

Absolutely hands down I recommend lunar-core for mocking GraphQL endpoints for end-to-end tests. You can find all above code from this post in https://github.com/bahmutov/react-boston-2018-lunar-launch