A Cypress `.should()` callback collects room rates into an array and ends up with duplicates. Why?
answer
- Retries throw the whole attempt away
- The outer array outlives the attempt
- Side effects run once per attempt
- Recompute inside, never accumulate outside
- It only shows up when a retry happens
basics
~20 sBecause the callback is the retry unit. Cypress re-runs the whole callback body on every attempt until nothing inside throws, so a push into an array declared outside it happens once per attempt rather than once per test.
solid answer
~40 sA `.should()` callback is retried: Cypress discards a failing attempt, re-queries the subject and runs the body again from the first line until no assertion throws or the timeout expires. Locals inside the callback are rebuilt each pass, but an array declared **outside** survives, so it accumulates one batch of rates per attempt — and the failure then reports a count the page never had. The fix is to keep the callback pure: build the array from the subject inside the callback and assert on that. Anything that must happen exactly once — `cy.task()` seeding, `cy.writeFile()`, capturing a value for later — belongs in `.then()`, which runs once. The same trap in quieter form is any value read before the chain: retries re-read the page, never your snapshot.
code
javascript · 16 lines// WRONG: rates survives every attempt, so it grows one batch per retry
const rates = []
cy.get('[data-cy=room-card]').should(($rooms) => {
// jQuery's .each() over the subject, pushing into state that outlives the retry
$rooms.each((i, el) => rates.push(Number(el.dataset.rate)))
expect(rates).to.have.length(4)
})
// RIGHT: rebuild from the subject on every attempt, no outer state
cy.get('[data-cy=room-card]').should(($rooms) => {
const found = $rooms.toArray().map((el) => Number(el.dataset.rate))
expect(found, 'four room rates').to.have.length(4)
expect(found).to.deep.equal([180, 210, 240, 320])
})go deeper
Remember the shape of the rule: a should callback may compute and assert, but it must not change anything that lives outside it. Build what you need inside the callback.
Explain the mechanism — a failed attempt is discarded and the whole body re-runs against a fresh subject, while closure variables persist across those attempts.
An interviewer wants the diagnosis: a count larger than the page ever showed, appearing only under load, points at state accumulating across retries rather than at the application.
Own the standard that prevents it. Decide what a retried callback is allowed to touch, and how review catches a block that would misbehave if it ran five times.
## The callback is the retry unit When Cypress retries a `.should(cb)` it does not resume the callback where it failed and it does not diff anything. It throws the attempt away and runs the whole body again from the first line, against a freshly queried subject, and it keeps doing that until no assertion inside throws or `defaultCommandTimeout` (4000 ms by default) expires. The documentation states the constraint directly: be sure not to include any code that has side effects in the callback function, because the callback will be retried over and over again until no assertions within it throw. An array declared **outside** the callback takes no part in that discarding. It is an ordinary closure variable that lives for the whole test, so each attempt appends to whatever the previous attempt left behind. ## Walking through the duplicate 1. **Attempt 1.** The hotel room list has rendered three of its four cards. The callback pushes three rates into the outer array, then `expect(rates).to.have.length(4)` throws. 2. **Attempt 2.** Cypress re-queries `[data-cy=room-card]` and calls the callback again. The fourth card has arrived, so this pass pushes four more rates. The array now holds seven. 3. **Attempts 3, 4, 5…** The assertion is now failing on the accumulator rather than on the page, so it keeps failing until the timeout, and the reported length climbs with every pass. The tell is that the number in the failure message is larger than anything the page ever contained, and that it differs from run to run. ## The general form: values captured outside the retry loop Accumulation is the loud version of the bug. The quiet version is a value **read** outside the loop: - a `let rate` assigned in an earlier `.then()` and compared inside a later `.should(cb)` — retries re-read the page but never re-read `rate`, so the comparison is always against a snapshot; - a total computed from fixture data before the chain, asserted against a page that is still updating; - a counter incremented in the callback and then asserted on — it counts attempts, not rooms. One rule covers all of them: **anything a retried callback reads must be recomputed inside the callback, and anything it writes must be safe to write many times.** ## Keeping the callback pure - **Recompute, never accumulate.** Build the array from the subject on every pass — `const rates = $rooms.toArray().map(...)`, using jQuery's `.toArray()` — and assert on that local. Each attempt starts from empty, so the assertion measures the page rather than the history of the test. - **Move once-only work into `.then()`.** Seeding through `cy.task()`, writing with `cy.writeFile()`, capturing a booking reference for a later step: all of that belongs in `.then()`, which runs once. As of Cypress 16 a Cypress command inside a `.should()` callback throws outright, with the reason spelled out — retrying would add commands to the queue multiple times — but plain JavaScript side effects are invisible to that guard, and they are the ones that bite. - **Do not gate on an attempt number.** `Cypress.currentRetry` reports how many times the whole **test** has been retried under the `retries` config; it does not move between assertion attempts, so it cannot tell you which pass of a callback you are on. - **Assert on the subject, not on your bookkeeping.** If an assertion mentions a variable the page does not own, ask what refreshes it. ## Why it presents as flake On a fast local run the first attempt usually succeeds, the callback runs exactly once, the array is correct and the bug is invisible. It surfaces only when a retry actually happens, which is a function of machine load, network latency and how much of the room list has rendered by the time the assertion starts. So the spec is green for weeks, fails once in CI with a count nobody can reproduce, gets re-run, and passes. Filing that as infrastructure noise leaves a real defect in the suite. It is not only that the failure message is wrong: after the first pass, the check is no longer measuring the page at all. The honest repair is to make the callback idempotent, and the honest review question when a `.should()` callback grows past a few lines is simply *what does this do that I would not want done five times?*
- How would you rewrite the collecting block so it still checks every rate?Build the array inside the callback from the subject on each pass — `const rates = $rooms.toArray().map((el) => Number(el.dataset.rate))` — then assert on `rates`. Every attempt starts from an empty local, so the assertion measures the page rather than the history of the test. If you genuinely need those values later, capture them in a `.then()` placed after the assertion has settled.
- Does `Cypress.currentRetry` tell you which attempt of a `.should()` callback you are in?No. `Cypress.currentRetry` is the current **test** retry count under the `retries` config, and it holds still across the assertion attempts inside one test. There is no supported counter for callback attempts, which is rather the point: a retried callback is meant to be indistinguishable from one that ran once, and any code that needs to know is code that should not be in there.
A .should() callback is a take, not a rehearsal: Cypress reshoots the scene from the first line every time, so anything the scene changes for real — a note added to a list outside the set — gets changed once per take.
saying these in an interview costs you the question
- Blames the app for rendering rows twice
- Says a should callback runs exactly once
- Uses Cypress.currentRetry to count callback attempts
- Deduplicates the array instead of removing the side effect
- Calls it CI flake and just re-runs the job