A Better cy.each Iteration

How to iterate over elements and even perform an early stop.

🎁 You can find these tests in the repo bahmutov/better-cypress-each-example.

Imagine you have an application where you have bunch of table rows. After you click a button in a row, it reveals a random number from 0 to 9. Your goal is to click the buttons until you get the number 7.

Clicking on the button

Can we write a Cypress test that would iterate over potentially all buttons, but stop when it finds 7?

Cypress cy.each command

Cypress has cy.each command. This command is nice for doing the same set of actions for each element. For example, you can quickly click every button on the page

cypress/integration/spec01.js
1
2
3
4
5
6
7
it('clicks every button', () => {
cy.visit('index.html')
// https://on.cypress.io/each
cy.get('tbody button').each(($button) => {
cy.wrap($button).click()
})
})

Clicking each button

You can see me writing the above spec in the video Iterate Over Table Rows And Click A Button In Each Row Using cy.each Command and below:

The test does not stop, even if the number 7 is clearly visible. The test does not even wait for the number to be revealed before moving to the next table row and clicking the next button. Not good. We can make the test wait, of course. We need to grab the next cell and confirm it shows a single digit number.

cypress/integration/spec02.js
1
2
3
4
5
6
7
8
9
10
11
12
it('clicks every button waits for the number', () => {
cy.visit('index.html')
cy.get('tbody button').each(($button) => {
cy.wrap($button)
.click()
.parent()
.parent()
.contains('td', /\d/)
.invoke('text')
.then((s) => cy.log(`got ${s}`))
})
})

But what about stopping if we see the number 7? We still to implement this.

Stop the iteration early

If you use a simple synchronous callbacks, you can return false to stop the iteration early. For example, the next step that iterates over strings will stop when it sees the 3rd string.

cypress/integration/spec03.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
it('cy.each stops iteration when returning false', () => {
const fruits = ['apples', 'bananas', 'oranges', 'pears']
cy.wrap(fruits)
.each((fruit, k) => {
console.log(k, fruit)
if (k === 2) {
return false
}
cy.log('fruit', fruit)
})
// cy.each yields the original subject
// even if you stop the iteration early
.should('equal', fruits)
})

Can we return or yield false to stop the iteration if we use Cypress commands in the each(callback) function? Something like this does NOT work:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 🚨 JUST FOR DEMO, INCORRECT TEST
// this does not stop the iteration
cy.visit('index.html')
cy.get('tbody button').each(($button, k) => {
console.log('button', k)
cy.wrap($button)
.click()
.parent()
.parent()
.contains('td', /\d/)
.invoke('text')
.then(Number)
.then((n) => {
return n === 7 ? false : true
})
})

Unfortunately, the above test does not work. It quickly iterates over all buttons, queuing up all Cypress commands inside the each(callback). The queue after finding the buttons looks like this:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
- visit
- get
- wrap button 1
- click
- ....
- wrap button 2
- click
- ....
- wrap button 3
- click
- ....
- wrap button 16
- click
- ....

Even if you yield false from the first button, there are all the commands already queued up and they cannot be removed. We need a way to avoid queuing unnecessary commands. To do this, we can queue cy.then(callback) inside the each(callback). Inside the then(callback) we can decide if we need to queue up more Cypress commands, or simply do nothing. Instead of returning or yielding false, we will use a local variable to signal an early exit.

cypress/integration/spec04.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
it('stops when it sees 7', () => {
cy.visit('index.html')
let shouldStop = false
cy.get('tbody button')
.each(($button, k) => {
cy.then(() => {
if (shouldStop) {
return
}
console.log('button', k)
cy.wrap($button)
.click()
.parent()
.parent()
.contains('td', /\d/)
.invoke('text')
.then(Number)
.then((n) => {
if (n === 7) {
shouldStop = true
}
})
})
})
// cy.each yields the original subject
// even if you stop the iteration early
.should('have.length', 16)
})

You can see the derivation of the above test in the video Stop cy.each Iteration When Using Cypress Commands Inside The Callback Function and below:

Tip: in the video, I use the cypress-command-chain to visualize the already queued commands. Read the blog post Visualize Cypress Command Queue for more details.

Reusable each function

The boilerplate logic for iteration and early exit can be abstracted into a library. In fact, it looks so much like my recursive iteration in the plugin bahmutov/cypress-recurse that I just added the each as one of its features. Import the each into your spec and provide the callback function and the optional predicate function that tells each when to stop. Without the predicate function, it simply iterates over the each subject item. To stop early, for example when we see the number 7, we write the test like this one:

cypress/integration/spec05.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// https://github.com/bahmutov/cypress-recurse
import { each } from 'cypress-recurse'

it('stops when it sees 7 using each from cypress-recurse', () => {
cy.visit('index.html')

cy.get('tbody button').then(
each(
($button) => {
return cy
.wrap($button)
.click()
.parent()
.parent()
.contains('td', /\d/)
.invoke('text')
.then(Number)
},
(n) => n === 7, // predicate function
),
)
})

We use the each function to create the callback to the cy.then callback which will yield the list of items to iterate over. This is how the test looks in action:

Stop the iteration when we see the number 7

You can see me writing the above test in the video Use each Function From cypress-recurse Plugin To Iterate And Stop or below:

Replace the cy.each command

Finally, we can replace the existing cy.each command with the each from cypress-recurse. Here is how the spec could do this.

cypress/integration/spec06.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
// https://github.com/bahmutov/cypress-recurse
import { each } from 'cypress-recurse'

Cypress.Commands.overwrite(
'each',
(originalFn, items, itemCallback, stopPredicate) => {
return each(itemCallback, stopPredicate)(items)
},
)

it('overwrites cy.each to find 7 and stop', () => {
cy.visit('index.html')

cy.get('tbody button').each(
($button) => {
return cy
.wrap($button)
.click()
.parent()
.parent()
.contains('td', /\d/)
.invoke('text')
.then(Number)
},
(n) => n === 7,
)
})

Watch the video Overwrite The cy.each Command to see me writing the above test.