skip to content

In a Cypress component test, why does an aliased spy assertion retry when `expect(spy)` does not?

level: middleimportance: must knowfreq 62%

answer

  1. Only some things in Cypress retry
  2. Queries re-run; utilities never do
  3. Chai runs the moment it is reached
  4. The @alias is re-read on every attempt
  5. defaultCommandTimeout bounds the retrying

basics

~20 s

cy.get('@alias') is a Cypress query, so the chained assertion is re-run against the spy until it passes or defaultCommandTimeout expires. A bare expect(spy) inside .then() is plain Chai: it runs once, at that instant, and loses the race whenever the callback is late.

solid answer

~40 s

Retry-ability in Cypress belongs to **queries and the assertions chained onto them**. `cy.get('@onDismiss')` is a query: Cypress re-invokes it and re-runs `should('have.been.calledOnce')` against the spy's live call log until it passes or `defaultCommandTimeout` — 4000 ms out of the box — is exhausted. `cy.spy()` itself is a synchronous utility, not a command or query, and the object it returns is an ordinary Sinon spy. So `cy.mount(...).then(() => expect(onDismiss).to.have.been.calledOnce)` evaluates Chai exactly once, when that callback runs, and passes only if the component happened to have called the prop already. The moment the component defers the call — a `setTimeout`, a debounce, a state update that re-renders first — the direct `expect` becomes a race and the aliased form does not.

go deeper

for a junior

Learn the habit before the theory: alias the spy, then assert with cy.get('@alias'). Knowing that this form waits and the other does not is enough at this level.

for a middle

An interviewer expects you to name the categories — query, action, assertion, utility — and say which of them Cypress re-runs and which it does not.

for a senior

Be ready to diagnose from a CI log: a spy assertion that passes locally and fails on a loaded runner is a non-retrying assertion, not an unstable component.

for a principal

Own the standard that keeps this out of the suite: aliasing at creation, no assertions chained off actions, and per-assertion timeouts rather than a global one.

Retry-ability is why Cypress tests survive asynchronous UIs, and it is also why two assertions that look interchangeable behave completely differently. Only **queries and the assertions chained onto them** retry. Everything else — actions, utilities, plain JavaScript — runs once and is done. ## What retries, and what does not - **Queries** such as `cy.get()`, `cy.contains()`, `.find()`, `.its()` and `.invoke()` are re-invoked from the last non-query command until the assertions behind them pass. - **Assertions** — `.should()` and `.and()` — are re-evaluated on every retry of the query in front of them. - **Actions** such as `.click()`, `.type()` and `.select()` run once. Chaining an assertion off an action retries nothing useful, which is why "do not chain anything off a command" is standing Cypress advice. - **Utilities** — `cy.spy()`, `cy.stub()`, `cy.clock()`, `cy.tick()` — are not commands or queries at all. They are synchronous, they cannot time out, and they never retry. - **Plain Chai** — an `expect(...)` inside a `.then()` callback — is ordinary JavaScript that runs when the callback runs. ## Why `expect(spy)` is a snapshot `cy.spy()` returns a Sinon spy whose `callCount` and `args` grow as the component calls it. `expect(onDismiss).to.have.been.calledOnce` reads that object once. If the toast invokes its close handler in the same tick as the click, the assertion happens to pass. If the component debounces, waits a frame, defers behind a `setTimeout`, or re-renders before dispatching, the assertion reads a call count of zero and fails — and it fails *non-deterministically*, because the outcome depends on how fast the machine was that day. This is the pattern Cypress's own retry-ability guide calls out, with the alias form as the recommended replacement. ## The alias path, step by step 1. `.as('onDismiss')` registers the spy under an alias name for the duration of the test. 2. `cy.get('@onDismiss')` is a **query**: it resolves the alias back to that same spy object. 3. `.should('have.been.calledOnce')` runs sinon-chai against the spy's current state. 4. If it fails, Cypress re-invokes the query and re-runs the assertion, repeatedly, until it passes or `defaultCommandTimeout` — `4000` ms unless the project raises it — is exhausted. Nothing about the spy changed between attempt one and attempt nine. The assertion simply got asked again, against a call log that had time to grow. ## The two forms side by side | | `cy.get('@onDismiss').should(...)` | `.then(() => expect(onDismiss)...)` | | --- | --- | --- | | Retries | yes, until the assertion passes | no, evaluated exactly once | | Bounded by | `defaultCommandTimeout` | nothing | | A callback that lands late | passes | fails intermittently | | Failure output | names the alias and links the calls in the Command Log | a bare Chai diff | | Per-assertion timeout | `cy.get('@onDismiss', { timeout: 10000 })` | not available | ## Where this bites a component suite - A toast that calls `onDismiss` from inside a `setTimeout`, so the callback lands milliseconds after the click. - A data grid that batches selection state and emits `onSelectionChange` only after the re-render commits. - A rating widget whose `onRate` fires from a transition callback rather than from the click handler itself. All three pass on a developer laptop and fail on a loaded CI runner. The direct `expect()` did not *become* flaky; it was always a race, and CI merely lost it more often. ## Doing it right - Alias every spy and stub you intend to assert on, at the moment you create it. - Assert through `cy.get('@alias')`, never through the closure variable. - Keep the action and the assertion in separate chains rather than hanging a `.should()` off `.click()`. - If a callback is legitimately slow, widen that one assertion — `cy.get('@onDismiss', { timeout: 10000 })` — instead of raising `defaultCommandTimeout` for the whole project. - When several assertions must retry together as a unit, put them inside a `.should(callback)`, which Cypress re-runs whole. - Do not paper over the race with a fixed `cy.wait(500)`; it slows every run and still loses on a bad day.

  • Does aliasing make the spy itself retry?
    No. The spy is an ordinary Sinon object with a call log that grows. What retries is `cy.get('@onDismiss')` plus the assertion chained to it: Cypress re-reads the same spy and re-evaluates sinon-chai against its current `callCount` and `args`. Nothing about the spy is special — the assertion is simply asked again.
  • Is `expect(spy)` ever acceptable in a Cypress component test?
    Yes, when the call is guaranteed to have happened by the time the assertion runs — inside a `.should(callback)` that Cypress itself retries, or after a `cy.tick()` that has already flushed the timer. Even then the aliased form gives a better failure message and links the calls in the Command Log, so most teams keep `expect` for non-spy values.

A direct expect(spy) is glancing at the doorstep once and declaring nobody came; asserting through the alias is watching the doorbell camera until someone rings or your patience runs out.

saying these in an interview costs you the question

  • Claiming every Cypress command retries its assertions
  • Chaining assertions off .click() and expecting them to retry
  • Thinking cy.spy() accepts a timeout option
  • Adding a fixed cy.wait() to fix a spy assertion that fired late
  • Raising defaultCommandTimeout project-wide for one slow callback