Functional Helpers For Cypress Tests

Using functional programming to write retry-able assertion callbacks for checking if the table column is sorted.

This blog post will teach you how to write short and expressive Cypress tests using a library of tiny functional utilities cypress-should-really. Using this library you will be able to also write single functional callbacks to take advantage of Cypress built-in command retry-ability.

The sorted table

Imagine you have a table that can be sorted by a column. You can find such application (which is really just a static HTML file) at bahmutov/sorted-table-example. The table gets sorted when you click a button, but there is a slight delay between the click and the page update as the application is "crunching some numbers".

Sorting the table by clicking the buttons

The first test

How would you confirm the table is really sorted using a Cypress test? First, let's confirm it is NOT sorted. Let's get all date cells using CSS nth-child selector.

1
2
3
4
5
6
7
beforeEach(() => {
cy.visit('app/table.html')
})

it('is not sorted at first', () => {
cy.get('tbody td:nth-child(2)')
})

We can confirm the test picks the right cells by hovering over the command in the Cypress Command Log.

We got the right page elements

We need to extract the text from each cell, convert the YYYY-MM-DD strings into Date objects, then to timestamps, then check if the timestamp numbers are not sorted. Each step can be done using a separate cy.then command.

1
2
3
4
5
6
7
8
9
10
cy.get('tbody td:nth-child(2)')
.should('have.length', 4)
.then(($cells) => Cypress._.map($cells, 'innerText'))
.then((strings) => Cypress._.map(strings, (s) => new Date(s)))
.then((dates) => Cypress._.map(dates, (d) => d.getTime()))
.then((timestamps) => {
// check if the numbers are sorted by comparing to the sorted array
const sorted = Cypress._.sortBy(timestamps)
expect(timestamps).to.not.deep.equal(sorted)
})

Ughh, ok. Does it work? Yes - it confirms the timestamps are not sorted on the initial page. Just in case, let's look at the last assertion in the DevTools console.

Last assertion prints the arrays when we click it

chai-sorted

I believe readable tests are better than unreadable tests. Thus I love using additional Chai plugins to make the assertions clearly express what the test is trying to confirm. Thus I will use chai-sorted plugin in this test.

1
2
$ npm i -D chai-sorted
+ [email protected]

I will change the last assertion to should('not.be.sorted')

1
2
3
4
5
6
cy.get('tbody td:nth-child(2)')
.should('have.length', 4)
.then(($cells) => Cypress._.map($cells, 'innerText'))
.then((strings) => Cypress._.map(strings, (s) => new Date(s)))
.then((dates) => Cypress._.map(dates, (d) => d.getTime()))
.should('not.be.sorted')

Using chai-sorted assertion

The problem

Let's click on the "Sort by date" button and check if the table gets sorted. We can copy the above commands into the new test.

1
2
3
4
5
6
7
8
9
it('gets sorted by date', () => {
cy.contains('button', 'Sort by date').click()
cy.get('tbody td:nth-child(2)')
.should('have.length', 4)
.then(($cells) => Cypress._.map($cells, 'innerText'))
.then((strings) => Cypress._.map(strings, (s) => new Date(s)))
.then((dates) => Cypress._.map(dates, (d) => d.getTime()))
.should('be.ascending')
})

Ughh, the test fails

The sorted by date test fails

Our test fails miserably - it does not even wait for the table to be sorted after the click. If we add a three second delay between the click and the check, the test passes.

1
2
cy.contains('button', 'Sort by date').click().wait(3000)
...

Adding 3 second wait fixes the test

Of course, we do not want to use a hard-coded wait, we want the test to retry getting the DOM elements, convert them into timestamps, and check if the suddenly are sorted. Why isn't this happening?

Cypress only retries certain commands, like querying ones cy.get, cy.contains, cy.its. It does not retry the commands that generally have side-effects, like cy.click, cy.task, or cy.then. Cypress also retries only the current command with its assertions. It does not go "back" along the chain of commands, even if those commands are safe to retry normally. This is why the retry-ability guide suggests merging multiple cy.get commands into one, or mixing commands and assertions.

1
2
3
4
5
6
7
8
9
10
// might be flaky, only the last command
// ".find('td:nth-child(2)')" is going to be retried
cy.get('table')
.find('tbody')
.find('td:nth-child(2)')
.should('have.length', 4)
// BEST PRACTICE 👍
// use a single query with an assertion
cy.get('tbody td:nth-child(2)')
.should('have.length', 4)

In our case, the assertion is at the end of the long of chain of cy.then commands, and the execution never tries to get the table cells again. If we want to retry querying the page using the cy.get('tbody td:nth-child(2)') command, we need to somehow add the should('be.ascending') assertion to the cy.get command. Hmm, how can we do this?

Callback function as assertion

By passing a callback function to the should(callback) assertion. Inside the callback function we can use code to transform the elements returned by the cy.get command before checking if they are sorted. Here is the test where I moved all individual steps into the should(callback).

1
2
3
4
5
6
7
8
9
10
11
12
it('gets sorted by date', () => {
cy.contains('button', 'Sort by date').click()
cy.get('tbody td:nth-child(2)')
.should('have.length', 4)
// use a callback function as an assertion
.and(($cells) => {
const strings = Cypress._.map($cells, 'innerText')
const dates = Cypress._.map(strings, (s) => new Date(s))
const timestamps = Cypress._.map(dates, (d) => d.getTime())
expect(timestamps).to.be.ascending
})
})

The above code retries calling the cy.get command while the callback callback throws an error in the expect(timestamps).to.be.ascending line.

The test retries getting the table cells

Nice - yet the test is less readable than before :( Luckily, we can rewrite the code in the callback function to be much clearer using a few helpers from cypress-should-really NPM module.

Mapping and invoking

If you look at the .and($cells) function, it does the same common things again and again: mapping a list of values into another list, constructing Date objects, and invoking methods on each object. Right now we are using Lodash library that is bundled with Cypress to map jQuery object, etc. While Lodash is good, other libraries do a much better job of allowing you to compose common data transformations. We could use Ramda but even that excellent library can have rough edges while working with a mixture of plain and jQuery objects. This is why I wrote cypress-should-really and plan to expand it in the future if I find I need some other little utility to make the tests simpler to write. Let's see it in action.

1
2
$ npm i -D cypress-should-really
+ [email protected]

First, let's rewrite our initial test using map helper.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// BEFORE
it('is not sorted at first', () => {
cy.get('tbody td:nth-child(2)')
.should('have.length', 4)
.then(($cells) => Cypress._.map($cells, 'innerText'))
.then((strings) => Cypress._.map(strings, (s) => new Date(s)))
.then((dates) => Cypress._.map(dates, (d) => d.getTime()))
.should('not.be.sorted')
})
// AFTER
import { invoke, map, toDate } from 'cypress-should-really'
it('is not sorted at first', () => {
cy.get('tbody td:nth-child(2)')
.should('have.length', 4)
.then(map('innerText'))
.then(map(toDate))
.then(invoke('getTime'))
.should('not.be.sorted')
})

Utilities like map and invoke are optimized for point-free programming, they return a function that is waiting for the data to be yielded by the Cypress command; the data is typically a jQuery object yielded by cy.get command, or an Array yielded by the previous cy.then command. I hope the test is readable:

1
2
3
.then(map('innerText'))   // extract property "innerText" from each object
.then(map(toDate)) // call function "toDate" with each item
.then(invoke('getTime')) // invoke method "getTime" on each object

But using individual steps inside cy.then is going to cause the problem, because they are not retried. We need to use the helpers inside the assertion callback function. Luckily cypress-should-really has a few trick to help with constructing the single assertion callback too!

Assertion function

Let's rewrite our retry-able assertion function using the helpers.

1
2
3
4
5
6
7
8
9
10
11
12
it('gets sorted by date', () => {
cy.contains('button', 'Sort by date').click()
cy.get('tbody td:nth-child(2)')
.should('have.length', 4)
// use a callback function as an assertion
.and(($cells) => {
const strings = map('innerText')($cells)
const dates = map(toDate)(strings)
const timestamps = invoke('getTime')(dates)
expect(timestamps).to.be.ascending
})
})

The test passes like before. Let's eliminate all temporary variables like strings, dates, and timestamps - after all, they are used once just to pass the result to the next line.

1
2
3
4
5
6
7
8
9
10
11
12
cy.contains('button', 'Sort by date').click()
cy.get('tbody td:nth-child(2)').should(($cells) => {
expect(
invoke('getTime')(
map(toDate)(
map('innerText')(
$cells
)
)
)
).to.be.ascending
})

Notice the interesting thing: inside the expect we have a function calling another function, that calls another function, with the argument $cells. Each function is pure, just takes the input and produces output value. Thus these 3 functions can be combined into an equivalent single function to be called with $cells argument. We have a little helper to do just that in cypress-should-really called pipe.

1
2
3
4
5
6
7
8
import { invoke, map, toDate, pipe } from 'cypress-should-really'
cy.contains('button', 'Sort by date').click()
cy.get('tbody td:nth-child(2)').should(($cells) => {
// pipe: the data will first go through the "map('innerText')" step,
// then through "map(toDate)" step, finally through the "invoke('getTime')"
const fn = pipe(map('innerText'), map(toDate), invoke('getTime'))
expect(fn($cells)).to.be.ascending
})

🎓 I have made a few presentations about the above functional way of writing JavaScript, find the slide decks at slides.com/bahmutov/decks/functional.

The function fn constructed above is sitting, waiting for data. Once the data is passed in, the fn($cells) is computed and passed to the assertion expect(...).to ... for evaluation.

1
2
const fn = pipe(map('innerText'), map(toDate), invoke('getTime'))
expect(fn($cells)).to.be.ascending

Piping the data through a series of functions to be fed to the assertion expect(...).to Chai chainer is so common, that cypress-should-really has a ... helper for this. If you want to transform the data and run it through a Chai assertion use really function. It construct a should(callback) for you:

1
2
3
4
5
6
7
import { invoke, map, toDate, pipe, really } from 'cypress-should-really'
it('gets sorted by date: really', () => {
cy.contains('button', 'Sort by date').click()
cy.get('tbody td:nth-child(2)').should(
really(map('innerText'), map(toDate), invoke('getTime'), 'be.ascending'),
)
})

If you have any arguments for the assertion, place it after the chainer string. The same test can be written as

1
2
3
4
5
6
cy.contains('button', 'Sort by date').click()
cy.get('tbody td:nth-child(2)').should(
really(
map('innerText'), map(toDate), invoke('getTime'), 'be.sorted', { descending: false, }
),
)

The tests asserts the column is really sorted

Reusing pipes

The application can sort the table in ascending and descending order. To avoid code duplication, just store the pipe(step1, step2, ...) function.

1
2
3
4
5
6
7
8
9
10
11
// use functional utilities from this NPM library
// https://github.com/bahmutov/cypress-should-really
import { invoke, map, toDate, pipe, really } from 'cypress-should-really'
it('sorts twice', () => {
// reusable data transformation function
const fn = pipe(map('innerText'), map(toDate), invoke('getTime'))
cy.contains('button', 'Sort by date').click()
cy.get('tbody td:nth-child(2)').should(really(fn, 'be.ascending'))
cy.contains('button', 'Reverse sort').click()
cy.get('tbody td:nth-child(2)').should(really(fn, 'be.descending'))
})

Checking the sorted order twice using the same data transformation pipe

Aside: a better solution

In my opinion, the application should indicate somehow that it has received the user click and it is doing something with the table. For example, the application can disable the buttons and only enable them after the table has finished sorting.

app.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function disableButtons() {
document.getElementById('sort-by-date').setAttribute('disabled', 'disabled')
document.getElementById('sort-reverse').setAttribute('disabled', 'disabled')
}

function enableButtons() {
document.getElementById('sort-by-date').removeAttribute('disabled')
document.getElementById('sort-reverse').removeAttribute('disabled')
}

document.getElementById('sort-by-date').addEventListener('click', function () {
disableButtons()
// sort the table after some random interval
...
enableButtons()
})

This would make the tests much simpler to write without accidental flake.

1
2
3
4
5
6
7
8
9
10
11
// use functional utilities from this NPM library
// https://github.com/bahmutov/cypress-should-really
import { invoke, map, toDate, pipe, really } from 'cypress-should-really'
it('uses disabled attribute', () => {
// reusable data transformation function
const fn = pipe(map('innerText'), map(toDate), invoke('getTime'))
cy.contains('button', 'Sort by date').click().should('not.be.disabled')
cy.get('tbody td:nth-child(2)').then(really(fn, 'be.ascending'))
cy.contains('button', 'Reverse sort').click().should('not.be.disabled')
cy.get('tbody td:nth-child(2)').then(really(fn, 'be.descending'))
})

Notice the above test is using built-in retry via .should('not.be.disabled') which applies to the button yielded by the previous command. Once the button is enabled, we can simply check once if the table has been sorted already. We do not even need the .should(callback) and instead we apply our pipe transformation using .then(callback) that only is executed once.

1
2
cy.contains('button', 'Sort by date').click().should('not.be.disabled')
cy.get('tbody td:nth-child(2)').then(really(fn, 'be.ascending'))

It all works beautifully

Relying on the disabled attribute to signal when the table is ready to be checked

Nice!

🎁 Find the example application and the shown tests at bahmutov/sorted-table-example. Find the plugin at bahmutov/cypress-should-really.

See also