Cypress Pagination Challenge

Various solutions to the table pagination challenge.

Recently I put a challenge to the Cypress users: can you click the "Next" button in the paginated table below until you reach the end of the table?

Paginated table. You need to reach its end by clicking the Next button

You can find the introduction to the challenge in the free lesson Lesson n1: Cypress Pagination Challenge of my πŸŽ“ Cypress Plugins course. In this blog post I will post my solutions to the challenge.

🎁 You can find the repo with the code challenge at bahmutov/cypress-pagination-challenge. Clone it, install dependencies, and start solving!

When evaluating a solution, we should think how robust the test is against the web application challenges:

  • will the solution work if the table starts with a few items and shows the last page?
  • will the solution work if the table takes a little bit of time to transition from one page to the next?
  • will the solution work if clicking on the Next button leads to the entire table re-render (including the Next button) or even a full page reload?

Solution 1: using plain Cypress syntax

We do not know how many times (if any) we need to click the "Next" button, it depends on the size of the table. We know that once the "Next" button has attribute disabled, we should stop - we reach the end. Thus we use recursion based on the button's attribute.

cypress/e2e/solution-plain.cy.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
26
27
28
29
30
31
beforeEach(() => {
cy.visit('public/index.html')
})

function maybeClickNext() {
// the Next button is always present
cy.get('[value=next]').then(($button) => {
// look at the attribute "disabled"
// to see if we reached the end of the table
if ($button.attr('disabled') === 'disabled') {
cy.log('Last page!')
} else {
// not the end yet, sleep half a second for clarity,
// click the button, and recursively check again
cy.wrap($button).wait(500).click().then(maybeClickNext)
}
})
}

it('clicks the Next button until we get to the last page', () => {
// the HTML table on the page is paginated
// can you click the "Next" button until
// we get to the very last page?
// button selector "[value=next]"

maybeClickNext()

cy.log('**confirm we are on the last page**')
cy.get('[value=next]').should('be.disabled')
cy.get('[value=last]').should('be.disabled')
})

Let's see the solution in action:

πŸ“Ί You can watch this solution derived in the free lesson Lesson n2: Table pagination solution of my πŸŽ“ Cypress Plugins course.

Of course, we could have used a different syntax to implement the same test. For example, we could get the attribute disabled using Cypress cy.invoke command and get the Next button explicitly again after checking if we are on the last page:

cypress/e2e/solution-plain-attribute.cy.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function maybeClickNext() {
// the Next button is always present
// let's get its attribute "disabled"
cy.get('[value=next]')
.invoke('attr', 'disabled')
.then((disabled) => {
if (disabled === 'disabled') {
cy.log('Last page!')
} else {
// not the end yet, sleep half a second for clarity,
// click the button, and recursively check again
cy.get('[value=next]').wait(500).click().then(maybeClickNext)
}
})
}

The attribute solution

The above solutions work correctly when the page starts on the last page.

Clean up the Command Log

Right now our Cypress Command Log looks pretty busy.

The Command Log is full of internal commands

Let's clean it up. We can hide the intermediate commands in the recursive loop, limiting the Log to "Page N" and "Last page!" messages. We need to pass the current page number to the recursive function.

cypress/e2e/solution-plain-log.cy.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function maybeClickNext(page = 1) {
// the Next button is always present
// let's get its attribute "disabled"
cy.get('[value=next]', { log: false })
.invoke({ log: false }, 'attr', 'disabled')
.then((disabled) => {
if (disabled === 'disabled') {
cy.log('Last page!')
} else {
cy.log(`Page ${page}`)
// not the end yet, sleep half a second for clarity,
// click the button, and recursively check again
cy.get('[value=next]', { log: false })
.wait(500, { log: false })
.click({ log: false })
.then(() => maybeClickNext(page + 1))
}
})
}

Tip: cy.invoke command puts the options in the first argument, since the method you are calling might take an unknown number of arguments.

The Log looks so much better now

Solution 2: using cypress-recurse

The above solution is a recursive one. Thus we can write it even simpler using my plugin cypress-recurse.

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
import { recurse } from 'cypress-recurse'

beforeEach(() => {
cy.visit('public/index.html')
})

it('clicks the Next button until we get to the last page', () => {
// the HTML table on the page is paginated
// can you click the "Next" button until
// we get to the very last page?
// button selector "[value=next]"

// click on the "next" button
// until the button becomes disabled
recurse(
() => cy.get('[value=next]'),
// check if the button is disabled using jQuery ".attr()" method
// https://api.jquery.com/attr/
($button) => $button.attr('disabled') === 'disabled',
{
log: 'The last page',
delay: 1000, // wait a second between clicks
timeout: 10_000, // max recursion for 10 seconds
post() {
// if the button is not disabled, click it
cy.get('[value=next]').click()
},
},
)

cy.log('**confirm we are on the last page**')
cy.get('[value=next]').should('be.disabled')
cy.get('[value=last]').should('be.disabled')
})

The test runs as we expect.

You can find the derivation of the solution in the lesson Lesson n3: Pagination using cypress-recurse which I plan to make public in October.

Solution 3: using cypress-if

We can better represent "if the button is enabled, click on it" using cypress-if plugin.

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
// https://github.com/bahmutov/cypress-if
import 'cypress-if'

function maybeClickNext() {
cy.get('[value=next]')
.if('enabled')
.wait(1000)
.log('clicking next')
// because we used "cy.log"
// we removed the button subject, so need to query again
.get('[value=next]')
.click()
.then(maybeClickNext)
.else()
.log('last page')
}

beforeEach(() => {
cy.visit('public/index.html')
})

it('clicks the Next button until we get to the last page', () => {
// the HTML table on the page is paginated
// can you click the "Next" button until
// we get to the very last page?
// button selector "[value=next]"

maybeClickNext()

cy.log('**confirm we are on the last page**')
cy.get('[value=next]').should('be.disabled')
cy.get('[value=last]').should('be.disabled')
})

You can find the derivation of the solution in the lesson Lesson n5: Pagination using cypress-if which I plan to make public in October.

Solution 4: using cypress-await

We can write "normal" JavaScript code by adding async / await support to Cypress using my cypress-await preprocessor. Just set it up in the cypress.config.js file

cypress.config.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const { defineConfig } = require('cypress')

// https://github.com/bahmutov/cypress-await
const cyAwaitPreprocessor = require('cypress-await/src/preprocessor')

module.exports = defineConfig({
e2e: {
// baseUrl, etc
supportFile: false,
fixturesFolder: false,
setupNodeEvents(on, config) {
// implement node event listeners here
// and load any plugins that require the Node environment
on('file:preprocessor', cyAwaitPreprocessor())
},
},
})

And use the await keyword before getting value from the page, like the disabled attribute

cypress/e2e/spec.cy.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
beforeEach(() => {
cy.visit('public/index.html')
})

async function maybeClick() {
const disabled = await cy.get('[value=next]').invoke('attr', 'disabled')
if (disabled !== 'disabled') {
await cy.wait(1000)
await cy.get('[value=next]').click()
await maybeClick()
}
}

it('clicks the Next button until we get to the last page', async () => {
await maybeClick()

cy.log('**confirm we are on the last page**')
cy.get('[value=next]').should('be.disabled')
cy.get('[value=last]').should('be.disabled')
})

You can find the derivation of the solution in the lesson Lesson n6: Paginate using the await keyword which I plan to make public in October.

Solution 5: using cypress-await synchronous mode

Using cypress-await plugin is cool, but writing await cy.... everywhere is noisy. The plugin includes another spec file preprocessor that lets you not write await in front of every cy command.

cypress.config.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const { defineConfig } = require('cypress')

// https://github.com/bahmutov/cypress-await
const cyAwaitPreprocessor = require('cypress-await/src/preprocessor-sync-mode')

module.exports = defineConfig({
e2e: {
// baseUrl, etc
supportFile: false,
fixturesFolder: false,
setupNodeEvents(on, config) {
// implement node event listeners here
// and load any plugins that require the Node environment
on('file:preprocessor', cyAwaitPreprocessor())
},
},
})

Look at the simplicity in this spec

cypress/e2e/spec.cy.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
beforeEach(() => {
cy.visit('public/index.html')
})

function maybeClick() {
const disabled = cy.get('[value=next]').invoke('attr', 'disabled')
cy.log(`disabled ${disabled}`)
if (disabled !== 'disabled') {
cy.wait(1000)
cy.get('[value=next]').click()
maybeClick()
}
}

it('clicks the Next button until we get to the last page', () => {
maybeClick()

cy.log('**confirm we are on the last page**')
cy.get('[value=next]').should('be.disabled')
cy.get('[value=last]').should('be.disabled')
})

Simple and powerful. You can find the derivation of the solution in the lesson Lesson n7: Paginate using synchronous code which I plan to make public in October.

Solution 6: Plain DOM methods

Let's use while loop and JavaScript DOM methods to interact with the elements on the page. First, we get the button using cy.document command and document.querySelector. Then we can send mouse event "click" to the button until the DOM element gets disabled.

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
beforeEach(() => {
cy.visit('public/index.html')
})

it('clicks the Next button until we get to the last page', () => {
// the HTML table on the page is paginated
// can you click the "Next" button until
// we get to the very last page?
// button selector "[value=next]"
cy.document().then((document) => {
while (
document.querySelector('[value=next]').getAttribute('disabled') !==
'disabled'
) {
cy.log('clicking')
document
.querySelector('[value=next]')
.dispatchEvent(new MouseEvent('click'))
}
})

cy.log('**confirm we are on the last page**')
cy.get('[value=next]').should('be.disabled')
cy.get('[value=last]').should('be.disabled')
})

πŸ“Ί Watch this solution explained in the video Table Pagination Solution Using Plain DOM Methods

Coming Soon

  • jQuery + cy.should solution
  • overview of user-submitted solutions vs possible sources of flake

I will post a lesson with each solution derivation on my πŸŽ“ Cypress Plugins course. At first the lesson will be private. After a week, I will make the lesson public and will upload the video to my YouTube channel.

See also