Retry-ability

A core feature of Cypress that assists with testing dynamic web applications is retry-ability. Like a good transmission in a car, it usually works without you noticing it. But understanding how it works will help you write faster tests with fewer run-time surprises.

Queries, commands and assertions

There are three types of methods you can call in your Cypress tests: queries, commands and assertions. For example, there are 4 queries, 2 commands and 2 assertions in the test below.

it('creates an item', () => {
  cy.visit('/') // command

  cy.focused() // query
    .should('have.class', 'new-todo') // assertion

  cy.get('.new-todo') // query
    .type('todo A{enter}') // command

  cy.get('.todoapp') // query
    .find('.todo-list li') // query
    .should('have.length', 1) // assertion
})

The Command Log shows queries, commands and assertions, with passing assertions showing in green.

Cypress tests shoing commands and assertions

Let's look at the last chain of queries and an assertion:

cy.get('.todoapp') // query
  .children('.todo-list li') // query
  .should('have.length', 1) // assertion

Because nothing is synchronous in modern web applications, Cypress can't query all the DOM elements matching .todo-list li and check if there is exactly one of them. There are many examples of why this would not work well.

  • What if the application has not updated the DOM by the time these commands run?
  • What if the application is waiting for its back end to respond before populating the DOM element?
  • What if the application does some intensive computation before showing the results in the DOM?

Thus cy.get and cy.find() have to be smarter and expect the application to potentially update. cy.get() queries the application's DOM, finds the elements that match the selector, and then passes them to .find('.todo-list li'). .find() locates a new set of elements, and tries the assertion that follows (in our case should('have.length', 1)) against the list of found elements.

  • ✅ If the assertion that follows cy.find() passes, then the query finishes successfully.
  • 🚨 If the assertion that follows cy.find() fails, then Cypress will requery the application's DOM again - starting from the top of the list of chain. Then Cypress will try the assertion against the elements yielded from cy.get().find(). If the assertion still fails, Cypress continues retrying until the cy.find() timeout is reached.

Retry-ability allows the test to complete each query as soon as the assertion passes, without hard-coding waits. If your application takes a few milliseconds or even seconds to render each DOM element - no big deal, the test does not have to change at all. For example, let's introduce an artificial delay of 3 seconds when refreshing the application's UI below in an example TodoMVC model code:

app.TodoModel.prototype.addTodo = function (title) {
  this.todos = this.todos.concat({
    id: Utils.uuid(),
    title: title,
    completed: false,
  })

  // let's trigger the UI to render after 3 seconds
  setTimeout(() => {
    this.inform()
  }, 3000)
}

My test still passes! cy.get('.todo-list') passes immediately - the todo-list exists - but .children('li').should('have.length', 1) are clearly showing the spinning indicators, meaning Cypress is requerying for them.

Retrying assertion

Within a few milliseconds after the DOM updates, cy.find() finds an element and the should('have.length', 1) assertion passes

Multiple assertions

Queries and assertions are always executed in order, and always retry 'from the top'. If you have multiple assertions, Cypress will retry until each passes before moving on to the next one.

For example, the following test has .should() and .and() assertions. .and() is an alias of the .should() command, so the second assertion is really a custom callback assertion in the form of the .should(cb) function with 2 expect assertions inside of it.

it('creates two items', () => {
  cy.visit('/')

  cy.get('.new-todo').type('todo A{enter}')
  cy.get('.new-todo').type('todo B{enter}')

  cy.get('.todo-list li') // command
    .should('have.length', 2) // assertion
    .and(($li) => {
      // 2 more assertions
      expect($li.get(0).textContent, 'first item').to.equal('todo a')
      expect($li.get(1).textContent, 'second item').to.equal('todo B')
    })
})

Because the second assertion expect($li.get(0).textContent, 'first item').to.equal('todo a') fails, the third assertion is never reached. The command fails after timing out, and the Command Log correctly shows that the first encountered assertion should('have.length', 2) passed, but the second assertion and the command itself failed.

Retrying multiple assertions

Built-in assertions

Often a Cypress command or query has built-in assertions that will cause the previous queries to be retried. For example, the .eq() query will be retried even without any attached assertions until it finds an element with the given index.

cy.get('.todo-list li') // query
  .should('have.length', 2) // assertion
  .eq(3) // query
Retrying built-in assertion

Commands cannot be retried, but most still have built-in waiting. For example, as described in the "Assertions" section of .click(), the click() command waits to click until the element becomes actionable, including re-running the query chain leading up to it in case the page updates while we're waiting.

Cypress tries to act like a human user would using the browser.

  • Can a user click on the element?
  • Is the element invisible?
  • Is the element behind another element?
  • Does the element have the disabled attribute?

The .click() command will automatically wait until multiple built-in assertion checks like these pass, and then it will attempt to click just once.

Timeouts

By default each command and query that retries does so for up to 4 seconds - the defaultCommandTimeout setting.

Increase time to retry

You can change this timeout for all commands and queries. See Configuration: Overriding Options for examples of overriding this option.

For example, to set the default command timeout to 10 seconds via the command line:

cypress run --config defaultCommandTimeout=10000

We do not recommend changing the command timeout globally. Instead, pass the individual command's { timeout: ms } option to retry for a different period of time. For example:

// we've modified the timeout which affects default + added assertions
cy.get('[data-testid="mobile-nav"]', { timeout: 10000 })
  .should('be.visible')
  .and('contain', 'Home')

Cypress will retry for up to 10 seconds to find a visible element of class mobile-nav with text containing "Home". For more examples, read the Timeouts section in the "Introduction to Cypress" guide.

Disable retry

Overriding the timeout to 0 will essentially disable retrying the command or query, since it will spend 0 milliseconds retrying.

// check synchronously that the element does not exist (no retry)
// for example just after a server-side render
cy.get('[data-testid="ssr-error"]', { timeout: 0 }).should('not.exist')

Commands are not retried

Commands, such as cy.click(), follow different rules than queries. Cypress will retry any queries leading up to a command, and retry any assertions after a command, but commands themselves never retry - nor does anything leading up to them after they've resolved.

Commands are not retried because they could potentially change the state of the application under test. For example, Cypress will not retry the .click() command, because it could change something in the application. After the click occurs, Cypress will also not re-run any queries before .click().

Commands should be at the end of chains, not the middle

The following test might have problems if:

  • Your JS framework re-rendered asynchronously
  • Your app code reacted to an event firing and removed the element

Incorrectly chaining commands

cy.get('.new-todo')
  .type('todo A{enter}') // command
  .type('todo B{enter}') // command after a command - bad
  .should('have.class', 'active') // assertion after a command - bad

Correctly ending chains after a command

To avoid these issues entirely, it is better to split up the above chain of commands.

cy.get('.new-todo').type('todo A{enter}')
cy.get('.new-todo').type('todo B{enter}')
cy.get('.new-todo').should('have.class', 'active')

Writing your tests in this way will help you avoid issues where the page rerenders in the middle of your test and Cypress loses track of which elements it's supposed to be operating or asserting on. Aliases - cy.as() - can help make this pattern less intrusive.

cy.get('.new-todo').as('new')

cy.get('@new').type('todo A{enter}')
cy.get('@new').type('todo B{enter}')
cy.get('@new').should('have.class', 'active')

As another example, when confirming that the button component invokes the click prop testing with the cypress/react mounting library, the following test might or might not work:

Incorrectly checking if the stub was called

const Clicker = ({ click }) => (
  <div>
    <button onClick={click}>Click me</button>
  </div>
)

it('calls the click prop twice', () => {
  const onClick = cy.stub()
  cy.mount(<Clicker click={onClick} />)
  cy.get('button')
    .click()
    .click()
    .then(() => {
      // works in this case, but not recommended
      // because .click() and .then() do not retry
      expect(onClick).to.be.calledTwice
    })
})

The above example will fail if the component calls the click prop after a delay.

const Clicker = ({ click }) => (
  <div>
    <button onClick={() => setTimeout(click, 500)}>Click me</button>
  </div>
)
Expect fails the test without waiting for the delayed stub

The test finishes before the component calls the click prop twice, and without retrying the assertion expect(onClick).to.be.calledTwice.

It could also fail if React decides to rerender the DOM between clicks.

Correctly waiting for the stub to be called

We recommend aliasing the stub using the .as command and using cy.get('@alias').should(...) assertions.

it('calls the click prop', () => {
  const onClick = cy.stub().as('clicker')

  cy.mount(<Clicker click={onClick} />)
  // Good practice 💡: Don't chain anything off of commands
  cy.get('button').click()
  cy.get('button').click()

  // Good practice 💡: Reference the stub with an alias
  cy.get('@clicker').should('have.been.calledTwice')
})
Retrying the assertions using a stub alias

Use .should() with a callback

If you are using commands, but need to retry the entire chain, consider rewriting the commands into a .should(callbackFn).

Below is an example where the number value is set after a delay:

<div class="random-number-example">
  Random number: <span id="random-number">🎁</span>
</div>
<script>
  const el = document.getElementById('random-number')
  setTimeout(() => {
    el.innerText = Math.floor(Math.random() * 10 + 1)
  }, 1500)
</script>
Random number

Incorrectly waiting for values

You may want to write a test like below, to test that the number is between 1 and 10, although this will not work as intended. The test yields the following values, noted in the comments, before failing.

// WRONG: this test will not work as intended
cy.get('[data-testid="random-number"]') // <div>🎁</div>
  .invoke('text') // "🎁"
  .then(parseFloat) // NaN
  .should('be.gte', 1) // fails
  .and('be.lte', 10) // never evaluates

Unfortunately, the .then() command is not retried. Thus the test only runs the entire chain once before failing.

First attempt at writing the test

Correctly waiting for values

We need to retry getting the element, invoking the text() method, calling the parseFloat function and running the gte and lte assertions. We can achieve this using the .should(callbackFn).

cy.get('[data-testid="random-number"]').should(($div) => {
  // all the code inside here will retry
  // until it passes or times out
  const n = parseFloat($div.text())

  expect(n).to.be.gte(1).and.be.lte(10)
})

The above test retries getting the element and invoking the text of the element to get the number. When the number is finally set in the application, then the gte and lte assertions pass and the test passes.

Random number using callback

See also