skip to content

A Cypress test that borrows the last copy of a book passes, then fails on every retry. Why?

level: seniorimportance: must knowfreq 42%

answer

  1. A retry repeats the test, not the run
  2. Not every hook is repeated
  3. What ran once stays run
  4. The browser is not the whole system
  5. Where the seeding lives matters

basics

~20 s

A Cypress retry repeats only the test and its beforeEach and afterEach hooks. Suite-level seeding in a before hook is not repeated, and the backend is not rewound, so the second attempt runs against a catalogue whose last copy is already on loan.

solid answer

~40 s

Test retries repeat the failed test, its `beforeEach` hooks and its `afterEach` hooks — nothing else. Mocha's `before` hook, where suite-level seeding usually lives, runs once for the suite, and Cypress resets the browser between attempts but cannot rewind anything outside it. So a test that borrowed the only copy really did borrow it: the row is committed, and attempt two opens a catalogue where the borrow button is disabled and fails for a completely different reason than attempt one. Retrying only cures failures whose cause was transient and in-browser — a late render, a slow response. It cannot undo an effect the first attempt already committed. Either make setup repeat per attempt (`beforeEach` with `cy.task()` seeding, or uniquely-named data), or accept that the test is not safely retryable.

code

javascript · 16 lines
javascript
describe('borrowing a copy', { retries: { runMode: 2, openMode: 0 } }, () => {
  // Mocha's before: runs ONCE for the suite, never on a retry.
  before(() => {
    cy.task('seedCatalogue', { title: 'Dune', copies: 1 })
  })

  // Mocha's beforeEach: runs again at the start of every attempt.
  beforeEach(() => {
    cy.visit('/catalogue?q=Dune')
  })

  it('borrows the last copy', () => {
    cy.get('[data-testid="borrow"]').click()
    cy.get('[data-testid="loan-banner"]').should('contain', 'On loan to you')
  })
})

go deeper

for a junior

Remember what an attempt covers: the test plus its beforeEach and afterEach hooks. Suite setup and anything already written to the server are not undone.

for a middle

Be ready to explain why the second attempt can fail with a different error, and which hook the seeding has to move to for a retry to be meaningful.

for a senior

Expect to diagnose this from a CI report alone and to say which tests in a suite are safely retryable and which are consuming something they cannot recreate.

for a principal

Own the rule for the suite: what setup must guarantee before retries are allowed to be switched on, and how the team decides a specification opts out.

## What a Cypress retry actually repeats When `retries` allows another attempt, Cypress re-runs the **test**, not the run and not the spec. An attempt consists of: - every Mocha `beforeEach` hook that applies to the test, in declaration order; - the `it` body, from its first command; - every Mocha `afterEach` hook, once the attempt ends. Everything else stays as it was. Mocha's `before` and `after` hooks belong to the suite and run once; a failure *inside* `before` or `after` does not even trigger a retry. Cypress resets the browser between attempts the way it does between tests, but the browser is the only thing it can reset. Your API, your database, your message queue and your seeded fixtures are all outside the runner's reach. ## Why the second attempt fails differently 1. Attempt one runs `before`, which seeds a catalogue with exactly one copy of the title. 2. The test clicks **Borrow**. The request succeeds; the loan row is committed on the server. 3. The assertion on the confirmation banner fails — say the banner renders late and the wrong selector was used. 4. Cypress starts attempt two. `beforeEach` runs, the page is reloaded, `before` does **not** run. 5. The catalogue now shows zero copies. The Borrow button is disabled, so the click fails actionability and the error is nothing like the one that started the retry. The retry has not given you a second chance at the same test; it has given you a first run of a different one. ## What retrying can and cannot cure | Failure cause | Cured by a test retry? | |---|---| | A render that arrived after the assertion first ran | Yes, usually | | A backend response that was unusually slow once | Yes, usually | | A wrong selector or a real regression | No — fails identically every attempt | | An effect the first attempt already committed | No — and the retry fails for a new reason | | A failure raised inside a `before` or `after` hook | No — such failures are never retried | ## Making a test survive its own retry - **Move consumable setup into `beforeEach`.** What a retry re-runs is the per-test hook, so seeding through `cy.task()` there gives every attempt its own data. - **Make setup idempotent or unique.** Seed a fresh title with a generated identifier per attempt rather than reusing one row that a failed attempt may already have consumed. - **Keep irreversible steps out of the middle of a long test.** The later a committed effect sits, the more of the test is unrepeatable after it. - **Read the retry's error, not just the first one.** A different error on attempt two is a strong signal that the test is state-dependent rather than merely slow. - **Use `Cypress.currentRetry` to recognise the situation**, not to paper over it: logging that a test is on attempt two is useful, weakening its assertions on attempt two is not. ## What `afterEach` can and cannot undo Because Mocha's `afterEach` runs at the end of **every** attempt, including the failed ones, it is the natural place for cleanup that makes the next attempt viable — returning the borrowed copy or deleting the loan row through `cy.task()`. Two limits matter before you lean on it: - A failure raised *inside* `afterEach` does not trigger a retry, and it is reported against the test, so fragile cleanup turns one problem into two. - Cleanup only helps where the effect already exists. A test that fails before it borrows anything and one that fails after it has borrowed leave different states behind, so the cleanup has to tolerate both without asserting. That asymmetry is the reason per-attempt seeding in `beforeEach` is usually the sturdier half of the pair: creating what this attempt needs is unconditional, while undoing what a previous attempt may or may not have done is guesswork. ## When the honest answer is "do not retry this test" Some tests consume something that cannot be recreated inside the attempt: a one-time token, a limited stock item, an external sandbox with a shared identity. For those, a retry is not a neutral safety net — it converts one honest failure into a second, misleading one, and it costs a full test run of wall-clock time to do it. Setting `retries` low or zero for that specification, and fixing the underlying seam so setup can repeat, is a better trade than a count that hides the first failure behind a second.

  • How would you rewrite that suite so the retry has a fair chance?
    Seed inside `beforeEach` instead of `before`, so `cy.task('seedCatalogue', ...)` runs again for every attempt, and give the seeded title a unique identifier per attempt so a leftover loan from the failed run cannot collide. Then a retry starts from the same state the first attempt did.
  • How can you tell from a report that a retry failed for a new reason?
    Compare the errors and the artefacts per attempt. Cypress suffixes failure screenshots with `(attempt 2)`, so a different error message or a screenshot showing a disabled control rather than a missing banner tells you the retry ran against mutated state rather than re-testing the original failure.

saying these in an interview costs you the question

  • Assumes a retry resets the application's data
  • Thinks before hooks run again for each attempt
  • Believes a failing before hook is retried like a test
  • Treats any pass-on-retry as proof the app is fine
  • Raises the retry count when the second error differs from the first