skip to content

Why can a Cypress .should('not.exist') check pass even when the feature is broken?

level: seniorimportance: should knowfreq 46%

answer

  1. absence is the page's starting state
  2. the check passes on attempt one
  3. a bigger timeout changes nothing here
  4. anchor on a positive fact first
  5. not.exist also matches never rendered

basics

~20 s

A negative chainer is satisfied the moment the thing is absent, including before the app has rendered anything at all. Cypress stops retrying on the first pass, so the check can succeed on an untouched page rather than on the state you meant to verify.

solid answer

~40 s

Negative assertions are satisfied by absence, and absence is the page's state before the app has done anything. After clicking search on a hotel booking page, `cy.get('[data-cy=availability-spinner]').should('not.exist')` passes on its first evaluation — the spinner has not been rendered yet — so Cypress stops retrying and the test never observes the search at all. The same trap hides in `not.have.class` and `not.have.length`, which also pass when the list rendered empty or failed to render. The repair is to anchor on a positive fact that can only become true after the work happened: assert `should('be.visible')` on the spinner, then `should('have.length', 8)` on the room cards, and only then `should('not.exist')` on the spinner. A larger timeout fixes nothing, because the assertion already passed on attempt one.

code

javascript · 12 lines
javascript
// passes before the search has even started
cy.get('[data-cy=search-rooms]').click()
cy.get('[data-cy=availability-spinner]').should('not.exist')

// anchored: each positive fact pins down the state the absence is read in
cy.get('[data-cy=search-rooms]').click()
cy.get('[data-cy=availability-spinner]').should('be.visible')
cy.get('[data-cy=room-card]').should('have.length', 8)
cy.get('[data-cy=availability-spinner]').should('not.exist')

// same trap on a form: this is true of a guest form that never rendered
cy.get('[data-cy=guest-email]').should('not.have.class', 'invalid')

go deeper

for a junior

Recall that a check for something being absent can pass before the application has done anything, so it needs a positive assertion in front of it.

for a middle

Explain why the retry window is irrelevant once an assertion passes on its first evaluation, and what not.exist, not.be.visible and have.length zero each really claim.

for a senior

An interviewer expects you to spot this in review: a suite that stayed green through a broken booking flow, traced to a negative assertion with nothing anchoring the state it read.

for a principal

Own the standard that absence is only ever asserted after a positive fact, so a green suite is evidence about the product rather than about how fast the assertion ran.

## Why a negative check is so cheap to satisfy A positive assertion has to wait for a specific fact to become true. `should('have.length', 8)` cannot pass until eight room cards exist. A **negative** assertion is satisfied by the *absence* of a fact, and absence is the state the page is in before anything has happened at all. ```javascript cy.get('[data-cy=search-rooms]').click() // passes instantly on the page that has not started searching yet cy.get('[data-cy=availability-spinner]').should('not.exist') ``` Cypress stops retrying the moment an assertion passes. Here it passes on the very first evaluation, long before the availability request has been issued, so the test never observes the behaviour it claims to test. Nothing is wrong with the assertion itself — `not.exist` did exactly what it says. The test is simply asking the wrong question. ## Three ways a booking spec passes for the wrong reason 1. **Too early.** The check runs before the app changed anything, as above. The spinner never existed at that instant, so its absence is trivially true. 2. **Too broad.** `cy.get('[data-cy=room-card]').should('not.have.class', 'sold-out')` passes if the room list rendered without that class — and also if the room list rendered empty, or failed to render at all, because a negative chainer over an empty set has nothing to contradict it. 3. **Right absence, wrong cause.** `should('not.have.length', 2)` after adding a room to a booking passes when the list grew to three, and equally when the app deleted the list, dropped a room, or inserted a blank one. Only one of those is the behaviour under test. The common thread is that a negative assertion accepts a very large family of application states, and the intended one is a single member of it. A positive assertion narrows to a much smaller family, which is why it fails when the product is broken. ## Anchoring: assert a positive fact first The repair is not a longer timeout — a longer window on an already-passing assertion changes nothing. It is to make the test observe the transition, by asserting something that can only become true *after* the work happened, and only then asserting the absence. ```javascript cy.get('[data-cy=search-rooms]').click() cy.get('[data-cy=availability-spinner]').should('be.visible') // it started cy.get('[data-cy=room-card]').should('have.length', 8) // it finished cy.get('[data-cy=availability-spinner]').should('not.exist') // and cleaned up ``` Each statement is its own `cy.get()`, so each re-queries the page, and the negative check is now evaluated in a state the earlier assertions have pinned down. Anchoring on the *result* rather than on the spinner is usually stronger still: `have.length` on the room cards cannot pass on an untouched page, whereas `be.visible` on a spinner can be a race if the search is fast. ## Choosing the right negative chainer | Chainer | Passes when | Use it for | | --- | --- | --- | | `not.exist` | the selector matches nothing in the DOM | an element that is removed, such as a spinner or a dismissed banner | | `not.be.visible` | every matched element is present but hidden | an element kept in the DOM and hidden with CSS | | `have.length, 0` | the query resolved to an empty set | a list you want to report as empty rather than missing | Picking `not.be.visible` when the app actually removes the node makes the test fail rather than falsely pass, so the sharper risk sits with `not.exist` — it is satisfied both by "removed" and by "never rendered", and those two look identical from the assertion's point of view. ## Where candidates go wrong - Calling this flake. A test that passes when the feature is broken is not flaky; it is wrong every single run, and it will go on being green after the feature is deleted. - Raising the timeout to "make the negative check more reliable". It already passed at the first attempt, so a larger window is never consulted. - Reasoning that a green suite proves the absence was caused by the app. It proves only that the condition did not hold at the instant Cypress first looked. - Reaching for `{ timeout: 0 }` as a fix. Removing the retry makes the too-early problem strictly worse; it is the tool for asserting an absence you have already anchored, such as immediately after a server-rendered page loads. - Anchoring on another negative. Two absences in a row narrow nothing, because both are satisfied by the same empty page.

  • Would raising defaultCommandTimeout make a false-passing negative assertion more reliable?
    No. The timeout is the maximum a failing assertion is retried for, and this assertion passed on its very first evaluation, so the larger window is never consulted. Raising it only slows down genuine failures elsewhere in the suite. The only repair is to change what the test observes: assert a positive fact that can only hold after the work happened, then assert the absence.
  • When would you choose should('have.length', 0) over should('not.exist') in Cypress?
    Use `have.length, 0` when the container is expected to be present and empty — an availability panel that rendered with no matching rooms — because the assertion then also confirms the query resolved rather than the page being blank. Use `not.exist` when the element is genuinely removed from the DOM, such as a dismissed spinner. The two describe different application behaviours and are not interchangeable.

saying these in an interview costs you the question

  • Calls a permanently-green false pass a flaky test
  • Raises the timeout to make a negative check reliable
  • Thinks not.exist proves the app removed the element
  • Uses a bare negative check with nothing asserted before it
  • Reads not.exist and not.be.visible as interchangeable