This blog post explains how Cypress.io test runner can test an application that uses GraphQL to load / save data on the server. The backend will be done using json-graphql-server and the E2E tests will load that server into the browser. You can find the source code at https://github.com/bahmutov/todo-graphql-example. Let's start.
- GraphQL backend
- Application frontend
- First test
- Continuous Integration
- Testing the loading message
- Adding Todo mutation
- Network control limits
- Run json-graphql-server in the browser
- Testing GraphQL logic
- Reset fetch mock on page reload
- Related info
GraphQL backend
My demo application is a regular TodoMVC application with React frontend and GraphQL backend. To simplify the backend programming, I am using json-graphql-server. The entire "database" file db.js
has only 2 todo items initially
1 | module.exports = { |
Running json-graphql-server db.js
gives me instantly a GraphQL API. I can open localhost:3000
and play with GraphQL queries. For example to load all todo items:
1 | query { |
which returns JSON response
1 | { |
If I accidentally try to reference an invalid field, the response will contain "errors" list
1 | query { |
1 | { |
Application frontend
To write frontend to my application I picked React with apollo-boost and react-apollo. To serve the application I will use Parcel bundler.
1 | { |
The src/index.html
just includes placeholder element with id=root
and index.js
bundle.
1 |
|
1 | import React from 'react' |
The "App" component instantiates the Apollo client and inserts it into React system as a provider.
1 | import ApolloClient from 'apollo-boost' |
The markup of the app mostly follows TodoMVC with Redux code from Testing Redux Store blog post I have written earlier. The Todos.jsx is fetching the initial list of Todo items for example, and then creating a pure functional component TodoItem.jsx
for each returned item.
1 | const Todos = () => ( |
If we start the GraphQL backend and the application, and browse to localhost:1234
we can see the initial items.
You can see that the app renders the loaded items correctly, and that there is the correct GraphQL query at the start of the application.
First test
Let us confirm that our application renders the initial list of Todos correctly. I will use Cypress.io to write my end-to-end tests. First, because it is incredibly powerful. Second, because I spent last year and a half making it more powerful and developer-friendly.
I will install Cypress test runner using npm i -D cypress
and will write my first test - just checking if two items load initially. You can find this code in this pull request
1 | /// <reference types="cypress" /> |
A few notes about the above test code
- I have added the special comment
/// <reference types="cypress" />
that allows editors that understand this comment syntax (like VSCode, WebStorm) to load TypeScript definitions included with Cypress NPM module. Then if I hover over Cypress commands, I get to see code completion and documentation. For example when I am hovering overshould('have.length', 2)
assertion, VSCode editor shows information about this specific assertion. More information.
- The test visits
/
url, because I have placed thehttp://localhost:1234
base url incypress.json
settings file
1 | { |
- The test passes, all seems good
Continuous Integration
Before writing any more tests, I will set up continuous integration server to make sure each commit is fully tested. There are Cypress CI examples for many systems, but the simplest one to use is via Cypress CircleCI Orb. I will add the repo bahmutov/todo-graphql-example
to CircleCI build and will drop this circle.yml
file into my repo
1 | version: 2.1 |
Note: in order to use 3rd party orbs (like the Cypress CircleCI Orb), you need to go to the organization (or user) Security settings on CircleCI page and enable this setting
The configuration file uses the [Cypress Circle Orb][cypress orb registry] that comes from CircleCI registry. Because I am using a specific version cypress-io/[email protected]
of the orb, I am isolated from any configuration changes until I decide to upgrade the orb. The orb takes care of installing Cypress, caching its binary and running cypress run
command - I don't have to configure these things from my project!
Hmm, but how will the CI know that it needs to
- start GraphQL endpoint with
npm start
command - then start the application using
npm run app
and wait for port 1234 to respond - before running Cypress tests?
To start the server in the background before running the tests, and wait for port 1234 to respond, we can use parameters defined in the Cypress orb. I will add another script to the package.json
to start both the GraphQL endpoint and the bundler
1 | { |
The Circle config will start the server (in the background) and wait for local url http://localhost:1234
to respond before starting the Cypress tests
1 | version: 2.1 |
Great, the test passes on CircleCI - see the result here todo-graphql-example/3
Testing the loading message
When Todos.jsx
is loading the initial set of todos, it is showing the "Loading..." message.
1 | const Todos = () => ( |
Let us test it. We need to delay the server response somehow in order for this message to appear. We need network control which Cypress provides except for window.fetch
calls currently. The network rewrite is in progress which will add support for window.fetch
spying and stubbing. For now, we can force GraphQL request from Apollo client to drop from window.fetch
to using XMLHttpRequest
protocol that is stubbed. Let us do this
1 | it.only('shows loading message', () => { |
Notice in the screenshot below that when Apollo client uses XHR to make GraphQL request, Cypress "sees" it. The XHR details appear in the Command Log on the left, and if you click on the (XHR)
command it shows the details of the call in the DevTools console.
Also, do you see that Cypress is showing DOM snapshot when you click on the (XHR)
command? Because Cypress takes DOM snapshots while running the tests, it can travel back in time and show how the application looked during each command of the test. In this case, our application did in fact show "Loading..." message, but it was kind of quick. Let us delay the server response and confirm that the user interface shows this message. To delay the response I need to stub it, and return some mock data. I will use cy.route
for it
1 | it.only('shows loading message', () => { |
The test passes - and I can check that the Loading...
goes away too. Because React re-renders the affected DOM tree, the element "Loading..." should disappear from the DOM
1 | ... |
note: checking the loader using its text content is less maintainable than using a test data attribute. See Selecting Elements section in our Best Practices guide.
While we are working with XHR stub, we can move the data to be returned by the stub out from the spec file and into its own fixture file. I will create cypress/fixtures/empty-list-no-errors.json
with the response
1 | { |
In the cy.route
response I can use the name of the fixture file directly
1 | cy.route({ |
The test passes the same way.
Adding Todo mutation
Let us add a new todo. We will wire the TodoTextInput.jsx
component to send a mutation to the GraphQL endpoint.
1 | import gql from 'graphql-tag' |
Whenever there is keyDown
event, if the key is Enter (which: 13
), we call the mutation ADD_TODO
passed as an argument to the handleSubmit
method. The mutation can be seen in the Network tab
Notice that the text of the query is sent separately from the variable values. Also notice that the application is NOT showing the new Todo item "baz" in the list. Because we just sent it to the server, but have not told the Apollo Client to refresh the list. The easy way to refetch the data after sending a mutation is to ... tell the client to refetch a specific query after sending a mutation!
Here is our query to get all todo items - I am exporting it now:
1 | export const ALL_TODOS = gql` |
And I am adding refetchQueries
property to the Mutation
node
1 | import { Mutation } from 'react-apollo' |
The new item will now appear in the list when we enter it in the input field. The Network DevTools tab shows the mutation followed by the query all todos fetch.
Network control limits
Our previous tests were rudimentary. Cypress network stubbing is working in the browser by spying and stubbing the XMLHttpRequest object, and it is very limited. For example, one cannot selectively stub calls based on request body - only based on the HTTP method and url. So our stubbing can tell apart calls like these
cy.route('GET', '/todos')
fromcy.route('POST', '/todos')
because they have different HTTP methodcy.route('POST', '/todos')
fromcy.route('POST', '/todos/1')
because they have different url
But all GraphQL requests share the same method and url - so they all look the same to the cy.route
. Only the request bodies are different - and cy.route
does not look at request body when matching the route 🙁.
Future enhancement #687 will allow much more flexible stubbing, but for now we need a different work around.
Run json-graphql-server in the browser
If we have our simple GraphQL server based on JSON object using json-graphql-server, then we can load the same server right in the browser, and mock HTTP calls directly using another library!
So, we will load the json-graphql-server
directly from the test, and we will use fetch-mock to replace window.fetch
inside application's iframe with a polyfill that will point at that in-browser GraphQL server.
1 | import fetchMock from 'fetch-mock' |
The test works - our GraphQL server is running in the browser!
Testing GraphQL logic
Now that we can just load our "backend" server right in the browser under test, we can write complex tests. But first, we should move the server setup logic into the common hook to run before each test.
1 | beforeEach(() => { |
We can confirm that a new item gets added to the list - all using in-browser JSON GraphQL server
1 | it('adds an item', () => { |
Note: our beforeEach
contains a bug. Right now we are loading a single object data
that the JsonGraphlServer
will mutate if a test adds a new item for example. Thus to make the tests independent we need to clone this object before passing it to the constructor.
1 | onBeforeLoad (win) { |
Next, let's confirm that GraphQL requests really happen when we add a new Todo item. We need a reference to the win.fetch
mock sandbox. We can save it in the scope as let fetches
variable.
1 | let fetches |
Our test checks the fetches.calls()
array's length after the DOM gets updated, using cy.then(cb)
to schedule the assertion.
1 | it('tracks number of GraphQL calls', () => { |
What if we want to see and test the actual GraphQL queries from our application? No problem, we can extract the important GraphQL information from each call and assert the expected data. We just need to see what the call fetches.lastCall()
returns. Because Cypress runs the real browser, just open the DevTools, add debugger
keyword to pause the execution and inspect the returned value:
Perfect, we can write a tiny helper function to extract GraphQL operation name and variables - the important parts of the query. To make accessing nth
call easier we need a helper method, and it should make it convenient to grab calls that have happened last.
1 | /** |
Now we can confirm the calls that our web application sends to GraphQL endpoint
1 | it('uses expected GraphQL operations', () => { |
Hmm, the test fails - because our application generates a random ID for each item.
Luckily, we can make our test deterministic using the same approach as in this post. We can simply reach into the application's context and override Math.random
method during tests. Then each new item will get a nice deterministic id our tests can compare against.
1 | it('uses expected GraphQL operations', () => { |
We could even assert that the random generator stub was really called, if we want, because Cypress keeps track of all cy.spy
and cy.stub
methods.
Reset fetch mock on page reload
If we decide to test how the web application "remembers" the new item on window reload, we will hit a problem. Imagine we add a new test:
1 | it('shows new item after reload', () => { |
The test fails to load mocked GraphQL server - because cy.reload
deletes our stubbed window.fetch
method we have set in cy.visit
call. We need to set window.fetch = ...
again, but cy.reload
does not take an options object with onBeforeLoad
callback like cy.visit
. How can we attach the mocked fetch
to the window object?
By using cy.on('window:before:load', ...)
event. Here is an updated test that passes
1 | it('shows new item after reload', () => { |
There is an entire catalogue of evens in Cypress documentation.
- source code for this post in bahmutov/todo-graphql-example and the direct link to the cypress/integration/spec.js file
- my other GraphQL blog posts
- Testing Redux Store
- Testing Vue web applications with Vuex data store & REST backend
- json-graphql-server is super useful for quickly making a demo GraphQL server based on simple data