There is a new super powerful command in Cypress v3 - and that is cy.task
. This command allows your tests to "jump" from the browser context to Node and run any code before returning (asynchronously) the result back to the test. Let me show how to use this command for "deeper" server-side validation.
Let me take a TodoMVC application as an example. This is the same web application I have tested a lot in Testing Vue web applications with Vuex data store & REST backend blog post. You can find all code from this blog post in bahmutov/cypress-task-demo repository.
The TodoMVC application sends each new todo item entered by the user to the backend server (via XHR calls). The backend is json-server that saves the data as a plain JSON file called data.json
. Good. So how can we assert that all parts of the system are working as expected?
Testing the UI
The most obvious thing that everyone writing end-to-end tests should do is to only exercise the application via its user interface. For example, we can add a new todo item, and then confirm that the text of the item appears in the list below.
1 | import { enterTodo, resetDatabase } from './utils' |
Here is this test in action; I am hovering over "CONTAINS" command and Cypress highlights the new item in the DOM snapshots at that moment in test.
The new item appears in the list, but was it really sent to the server? We could write another test to spy on the XHR call to observe the new item being sent to the server. But was the item really saved? Hmm, we are going deeper in the implementation details here. Why not avoid testing the implementation of the application and the server and instead test the external state - in this case the file "database" where the server saves data?
Testing the database
So every time the user enters new Todo item, it should be saved in the file "data.json" like this
1 | { |
We can write a test that adds an item via UI, but then checks the database to make sure the new record has been added. There is cy.readFile
that can read file contents, but it is not powerful enough:
cy.readFile
fails the test if the file does not exist- we want to write general code that can actually query any database, not just read a file.
The new command cy.task
allows us to do anything. This is an "escape" hatch - a way for the end-to-end test running in the browser to run code in Node environment.
So let's write a new task - and all it has to do is to find the new text in the database. We will write this code inside cypress/plugins/index.js
file - that is the place for all Node code inside Cypress tests.
1 | const fs = require('fs') |
We can "call" cy.task
passing arguments (which should be serializable).
1 | import { enterTodo, resetDatabase } from './utils' |
Our test is passing!
The terminal where I started Cypress test runner shows the console log from the cy.task
command, which happens after posting the item
1 | POST /todos 201 8.135 ms - - |
Everything is great!
Or is it?
If at first you don't succeed ...
"Dust yourself off and try again", right?
We have a problem - we really assume that the item is saved before we check for it. But in the real world things are delayed, items are buffered before being sent or saved, etc. That is why Cypress is retrying all its commands - because nothing happens instantly!
To simulate the problem, let me change the TodoMVC application and add a 2 second delay when adding an item.
1 | // addTodo event listener |
If you rerun the ui.js
test file, the test still passes - the cy.contains
just waits for 2 seconds; it keeps observing the DOM and passes as soon as the new item text is found in the list.
But if you run spec.js
the assertion cy.task('hasSavedRecord', title).should('equal', true)
fails!
Notice in the screenshot that the failed assertion is placed before the POST XHR request from the web application to the server. This gives us a clue that we checked the database too early.
The cy.task
command does not retry. Because Cypress has no idea what your task is going to do - it probably is NOT idempotent action. For example cy.get
is idempotent command; it does not change the state of the application, unlike cy.type
or cy.click
that do. Just like Cypress cannot automatically retry cy.click
Cypress cannot retry cy.task
command.
But we can!
Let us wrap the code that is checking the database file with a loop. We are going to keep checking the file until we find the record or hit the time limit. Instead of returning a boolean value, we are going to return a promise, and cy.task
will automatically wait for the promise. Here is the plugins/index.js
code that just keeps chaining promises, checking the file every 50 milliseconds.
1 | const findRecord = title => { |
Our test now passes - beautiful!
Notice that the assertion task(...).should('equal', true)
passes AFTER the web application sends XHR to the server.
Making it beautiful
User should know when the application is busy doing something, and the user should be notified when an action either succeeded or failed. Cypress command timeline shows a blue spinner while an action is being retried, and it uses icons and colors to show test commands that passed and failed. Our Node code should do the same thing for the tasks. Luckily this can be added using a nice spinner library ora.
I will change my plugins
code to wrap custom promise-returning code with Bluebird promise that will control a CLI spinner.
1 | const ora = require('ora') |
Here is the result - the spinner working in the terminal, showing a message on success.
Here is the spinner when the task fails.
Final thoughts
When writing Cypress tests automatic retries are sooo convenient - you just don't have to think at all when exactly the things inside your application happen. You don't have to put wait(5000)
in order to predict delays, yet the tests keep flying because they never have to wait longer than necessary. cy.task
gives us a tremendous power to run any Node code, but we have to wrap it with retries ourself. Luckily it is simple to do so.
Bonus 1: cy.task fails if the promise is resolved
If the plugins code returns a rejected promise, then the cy.task
command fails. For example, here is the plugins file with the task that always rejects
1 | on('task', { |
This test always fails
1 | it('fails when cy.task rejects', () => { |