In the following test, we are using the contains
command to select a card that contains the text 'Jun 26 2023'.
/// <reference types="cypress" />
it('chaining and retrying', () => {
cy.visit('/board/1')
cy.contains('[data-cy=card]', 'Jun 26 2023')
});
If we go to our Cypress timeline, we can see the first card with the text 'milk' and the date 'Jun 26 2023' is highlighted.
We actually have another card here with the same date, and that's the card with the name 'shampoo'.
Our contains
command will only select the first element that it will find, but this doesn't mean we cannot use it to select the other element.
Whenever we use commands in Cypress, we can create so-called command chains.
In these chains, commands pass information from one to another and we have used them already.
For example, when we use the cy.get
command, we can then use cy.click
to click on the element that the previous command has given us.
Same goes for type
and should
, and in general, we call these commands child commands because click
, type
, or should
first need to have a querying command before it.
As you might have already guessed it, we also have parent commands.
Anything that we usually start off with cy.
is a parent command.
So, for example, cy.get
over here, cy.contains
, also cy.visit
.
There is also a third group of commands which are called hybrid commands.
These will change their behavior based on whether they are starting the chain or are just continuation of a chain.
Our contains
command is a great example of this.
I'm going to duplicate this command, but now instead of calling the contains
command directly, I'm going to type cy.get
and I will select data-cy=list
, which will now select two elements because we have a 'groceries' list and a 'drugstore' list.
Then, we're going to filter the second list.
That will be .eq(1)
and then we're going to use the contains
command.
/// <reference types="cypress" />
it('chaining and retrying', () => {
cy.visit('/board/1')
cy.contains('[data-cy=card]', 'Jun 26 2023')
cy.get('[data-cy=list]')
.eq(1)
.contains('[data-cy=card]', 'Jun 26 2023')
});
Watch what happens - when I save my test and hover over our contains
command, we can see that the 'shampoo' card is highlighted because it also has the text of 'Jun 26, 2023'.
But now, we are searching only within the context of the previous command.
So, we have created a chain that would first select two elements.
Then, from those two elements, we would select the second one, and then use contains
only within the scope of this element.
Whenever we want to take a look into what each command is doing, the Console is our friend.
When I open my Console and click on any of these items in the timeline - so, for example, with our get
command - we can see what the command has yielded.
In other words, what the command has passed on to the next one.
Right here, we can see two elements.
If we go to the eq
command, we can see that it has been applied to these two elements.
And again, it's yielding something.
From those two elements, it has given us the second one.
Finally, when we use contains
, we can see that it was applied to the second list.
And again, it has yielded something.
Following this information from the Console can be really useful to get a deeper understanding into what's actually happening in Cypress.
Chaining is embedded into every Cypress command, as is implicit timeout.
Let me show you what I mean.
I'm going to change my test now.
Instead of selecting a specific card, I'm going to select all of them.
I'll type data-cy=card
, and save my test.
/// <reference types="cypress" />
it('chaining and retrying', () => {
cy.visit('/board/1')
cy.get('[data-cy=card]')
});
I can see that this command has found four of my cards.
I am now going to introduce something in my tests.
It's a function that I have built for myself - it's called cardsLoadSlowly
.
I'm going to import that from a file that's called evilCode
.
/// <reference types="cypress" />
import { cardsLoadSlowly } from '../../evilCode'
it('chaining and retrying', () => {
cardsLoadSlowly(3000)
cy.visit('/board/1')
cy.get('[data-cy=card]')
});
What this is going to do is it's going to make my cards load slowly.
You can see that they appear after a longer period of time.
That period of time is actually defined as an argument in this function.
This will actually translate to three seconds.
You can see that this has done no harm to my test.
If I run my test, although I see the loading animation, eventually when the cards load, our test will pass.
However, if I change the interval to - for example, five seconds - then my query command fails and with it, the whole test fails too.
But eventually, our cards will appear. And of course, this is not what we want.
Usually we want our application to be fast, but we know that's not always the case. Sometimes we need to adjust our test.
In Cypress, these can be done in three places.
We can go to our cypress.config.js
and change the default timeout.
If I set up defaultCommandTimeout
to - for example, six seconds - save it and run my test, you'll see that this time our get
command will wait for a little longer, and eventually, our test will pass.
Besides changing our config, we can actually change this option just for our test.
I'll use the same key, defaultCommandTimeout
, set it to six seconds, save everything.
/// <reference types="cypress" />
import { cardsLoadSlowly } from '../../evilCode'
it('chaining and retrying', {defaultCommandTimeout:6000 }, () => {
cardsLoadSlowly(5000)
cy.visit('/board/1')
cy.get('[data-cy=card]')
});
Again, you can see that the progress bar goes a little bit slower, and eventually we'll load all of our cards.
If we don't want to make the whole test wait for six seconds, we can just do it on the command level.
We can pass an object and say that the timeout
of this command will be six seconds.
/// <reference types="cypress" />
import { cardsLoadSlowly } from '../../evilCode'
it('chaining and retrying', () => {
cardsLoadSlowly(5000)
cy.visit('/board/1')
cy.get('[data-cy=card]', { timeout: 6000 })
});
As you can see, this timeout is actually an upper limit.
Right now, our test is passing in five seconds and the timeout is six.
So even if we were to set it to something ridiculous like one minute and save our test, this will not make our test run for one minute.
Cypress is optimized for speed, and that means that once the command is done with its work, it will move on to the next command.
Cypress will try to execute all of the commands as fast as possible.
One important notion about these timeouts - you can look at them as the time it will take for your test to fail.
If we put this into the context of a large test suite which has thousands of tests, this means that every test that fails will take one minute to fail in this case.
It's important to choose a wise strategy when setting timeouts because they can make a huge difference on whether you will get the feedback from your test suite earlier or later.
I want to show you one more thing that will put the concepts of retriability and chaining together.
I will slightly change my test to select the last of our cards, then click on it, and once we do, we would like to assert the text of this heading.
We are going to click
on the card and then select data-cy=card-detail-title
, which is the data attribute for this element.
Then, since this is an input, we want to make an assertion that this should
have the value of 'shampoo'.
/// <reference types="cypress" />
import { cardsLoadSlowly } from '../../evilCode'
it('chaining and retrying', () => {
cy.visit('/board/1')
cy.get('[data-cy=card]')
.last()
.click()
cy.get('[data-cy=card-detail-title]')
.should('have.value', 'shampoo')
});
I'll remove cardsLoadSlowly
, check my test, and it seems to be passing.
However, I'm going to introduce one more thing from my evilCode
and that's a function called cardsLoadRandomly
.
I'm going to use this function, give it an interval of four seconds, and now when I run my test, it's going to fail.
/// <reference types="cypress" />
import { cardsLoadRandomly } from '../../evilCode'
it('chaining and retrying', () => {
cardsLoadRandomly(4000)
cy.visit('/board/1')
cy.get('[data-cy=card]')
.last()
.click()
cy.get('[data-cy=card-detail-title]')
.should('have.value', 'shampoo')
});
If I run it again, it's going to pass, and depending on how many times I run the test, it's either going to pass or fail, which is a typical example of a flaky test.
What makes this test pass or fail?
We are asserting that the text is 'shampoo' and if we take a look into our application, we can see that from the four cards, the last one is the 'shampoo', right?
So why does it happen that when we run our test sometimes we see the text 'milk'?
Well, if we go back to our timeline and see what the last
element actually was, we can see that our cards in the 'drugstore' list were still loading while cards in the 'groceries' list were already loaded.
As I mentioned, Cypress is optimized for speed, and it will move on to the next command as soon as it is finished with doing its thing.
This means that if the cards in the list 'groceries' load faster, the last
card will be the card with the text 'milk', so that is the one we click on.
If the cards in the 'drugstore' list will get loaded first, then our test will pass because the text of the last
card will be 'shampoo', which ultimately will be displayed on the card detail.
This is something we need to take into account when we are testing.
We need to make sure that in our test we are actually clicking the right card and we can do it with a very simple fix.
We're going to add an assertion and assert that our card element is going to contain text 'shampoo'.
/// <reference types="cypress" />
import { cardsLoadRandomly } from '../../evilCode'
it('chaining and retrying', () => {
cardsLoadRandomly(4000)
cy.visit('/board/1')
cy.get('[data-cy=card]')
.last()
.should('contain.text', 'shampoo')
.click()
cy.get('[data-cy=card-detail-title]')
.should('have.value', 'shampoo')
});
When I now save my test and run it a couple of times, it's now going to always pass.
The reason for that is how the commands in Cypress actually work.
In Cypress, we have querying commands - so our get
command will actually be an example of a query.
Our last
command will also be a query.
Then, we have assertions, and finally, we have actions.
Whenever we have an assertion in our test, Cypress is going to retry commands that go before it, but it will only retry our queries, which means that when we load our application and we have only two cards loaded and the last one does not have the text 'shampoo', Cypress is going to execute the get
command again and then again the last
command, and then finally, it will make that assertion again.
It will do that until the assertion passes or until we hit the timeout.
This is a tremendous help if we are dealing with a flaky DOM situation, but it's also important that we make this retrying and chaining play nicely for us.
By using this simple assertion, I have made my test more stable because before we did the action, we have actually asserted that we are selecting the right element.
TIP
Important notice for anyone who's using a version that's earlier than version 12
Before version 12, the should
command would only make the previous command retry and not the whole query chain, which seems like a small change, but it was a game changer when it was introduced.
Retrying the whole query chain makes much more sense.
So put together, chaining and retrying are going to help you a lot in making your test stable.
Just make sure you get familiar with querying commands and assertions.
Play with them, try it out, and remember, the Console is your friend.
It can provide you with valuable information into what your command is actually finding.