ScreenshotNeo

BlogHow-to

How to Add Conditional Checks in Cypress

Learn reliable Cypress conditional checks, when DOM branching is safe, and how to avoid flaky tests caused by asynchronous rendering.

By the ScreenshotNeo team30 September 20268 min read

How to Add Conditional Checks in Cypress

In Cypress, add a conditional check by making the state deterministic first, then branching from a stable source. Prefer server state, cookies, local storage, or an explicit application signal. Inspecting the DOM synchronously is safe only when you know rendering has finished and cannot change during the decision.

For a known expected state, use a retryable assertion:

cy.get('[data-cy=welcome-modal]').should('be.visible')

Use a one-shot branch only when the page is guaranteed to be settled:

cy.get('body').then(($body) => {
  if ($body.find('[data-cy=welcome-modal]').length) {
    cy.get('[data-cy=welcome-modal]').should('be.visible')
  } else {
    cy.get('[data-cy=main-content]').should('be.visible')
  }
})

Cypress documents conditional testing as a constrained technique because a client-rendered page can change immediately after your test observes it. See the official conditional testing guide.

1. Decide whether you need a condition

Many apparent conditionals are really waits for a known state. Assertions retry until they pass or time out, while a .then() callback runs once.

// Good: wait for the expected state
cy.get('[data-cy=account-page]').should('be.visible')

// Good: wait until a loading indicator disappears
cy.get('[data-cy=loading]').should('not.exist')
cy.get('[data-cy=results]').should('be.visible')

If both outcomes are valid, identify a stable signal before branching. Useful signals include:

  • A response from a controlled API request.
  • A cookie or local-storage value set by the test setup.
  • A deterministic fixture or database record.
  • An explicit attribute such as data-cy="state-ready".
  • A feature flag configured before the page loads.

2. Prefer deterministic application state

Branch from an intercepted response

cy.intercept('GET', '/api/account').as('account')
cy.visit('/dashboard')

cy.wait('@account').then(({ response }) => {
  const account = response?.body

  if (account?.needsOnboarding) {
    cy.get('[data-cy=onboarding]').should('be.visible')
  } else {
    cy.get('[data-cy=dashboard]').should('be.visible')
  }
})

The response is a better decision source than guessing from a partially rendered page. Keep the response shape controlled by fixtures when possible.

Choose a stable application signal before branching in Cypress.
Choose a stable application signal before branching in Cypress.

Set cookies or local storage before visiting

beforeEach(() => {
  cy.setCookie('experiment', 'control')
  cy.visit('/pricing')
})

it('shows the control variant', () => {
  cy.get('[data-cy=pricing-control]').should('be.visible')
})
cy.visit('/dashboard', {
  onBeforeLoad(win) {
    win.localStorage.setItem('tour-complete', 'true')
  }
})

cy.get('[data-cy=dashboard]').should('be.visible')
cy.get('[data-cy=product-tour]').should('not.exist')

Expose a stable state marker

// Application markup after data and rendering are complete:
// <main data-cy="page-ready" data-state="authenticated">...</main>

cy.get('[data-cy=page-ready]')
  .should('have.attr', 'data-state', 'authenticated')

cy.get('[data-cy=account-content]').should('be.visible')

This marker should be set only after the application has completed the work that affects the branch. A marker that appears before asynchronous rendering finishes does not make the test deterministic.

3. The constrained DOM branching pattern

When the page is static or otherwise guaranteed to be stable, inspect the body in a one-shot callback:

A one-shot DOM snapshot can race with client-side rendering.
A one-shot DOM snapshot can race with client-side rendering.
cy.visit('/welcome')

cy.get('body').then(($body) => {
  if ($body.find('[data-cy=welcome-modal]').length > 0) {
    cy.get('[data-cy=welcome-modal]')
      .should('be.visible')
      .find('[data-cy=close]')
      .click()
  } else {
    cy.get('[data-cy=main-content]').should('be.visible')
  }
})

The body query itself is not a durable assertion that the element will remain present. If React, Vue, or another client renderer can insert or remove the element after the callback runs, the branch can choose the wrong path or act on a detached subject.

4. .should() versus .then()

Construct Retries? Use it for Important rule
.should('be.visible') Yes A known state that should eventually be true Let Cypress retry the query and assertion
.should(callback) Yes Repeated, idempotent assertions The callback can run more than once; do not enqueue Cypress commands or perform one-time side effects
.then(callback) No A decision from already stable data Commands inside run after the callback enqueues them; the callback itself is one-shot

Example of a safe assertion callback:

cy.get('[data-cy=results]').should(($results) => {
  expect($results).to.have.length(1)
  expect($results.text()).to.contain('Ready')
})

Do not do this:

// Avoid: the callback may run repeatedly and the click is a side effect
cy.get('[data-cy=menu]').should(($menu) => {
  if ($menu.hasClass('closed')) {
    cy.wrap($menu).click()
  }
})

5. Re-query after a re-render

Cypress retries linked queries until assertions pass. After an assertion, later commands may operate on the previously yielded subject. If a framework replaces that element during a render, the subject can become detached. Split the chain and query the document again.

// More resilient after a render that replaces the list
cy.get('[data-cy=save]').click()
cy.get('[data-cy=toast]').should('be.visible')
cy.get('[data-cy=items]').should('have.length', 3)

// Re-query instead of continuing with a possibly detached subject
cy.get('[data-cy=item-row]').first().click()

6. Common conditional scenarios

Element may or may not exist

If its existence is the expected result, assert it directly:

cy.get('[data-cy=error]').should('not.exist')
cy.get('[data-cy=success]').should('be.visible')

If either outcome is valid, use a stable API or application flag. A body snapshot is acceptable only when the DOM cannot change.

A/B tests

cy.intercept('GET', '/api/experiment').as('experiment')
cy.visit('/landing')

cy.wait('@experiment').then(({ response }) => {
  const variant = response?.body?.variant

  if (variant === 'new') {
    cy.get('[data-cy=new-hero]').should('be.visible')
  } else {
    cy.get('[data-cy=control-hero]').should('be.visible')
  }
})

For repeatable tests, set the experiment assignment in the fixture or test data instead of accepting a random assignment.

Different text leads to different actions

cy.get('[data-cy=status]').invoke('text').then((status) => {
  if (status.trim() === 'Pending') {
    cy.get('[data-cy=refresh]').click()
  } else {
    cy.get('[data-cy=continue]').click()
  }
})

Only use this when the status cannot change while the decision is made. Otherwise, obtain the status from the API and branch from that response.

7. Why failed-command recovery is usually the wrong approach

A missing cy.get() fails the test; Cypress does not provide a general try/catch recovery mechanism for failed commands. Avoid deliberately issuing a command that may fail just to detect absence. Use a controlled state signal, a non-failing body inspection in a stable page, or an API response instead.

8. Test retries do not make an unstable branch deterministic

Cypress test retries are disabled by default and must be configured. Retries can rerun a failed test, but they do not fix a race where the test observes the wrong intermediate DOM state. Make the state deterministic first, then use retries to handle genuinely intermittent infrastructure or environment failures. See Cypress test retries.

9. Troubleshooting

Symptom Likely cause Fix
The wrong branch runs intermittently The DOM was inspected before client rendering settled Wait on a controlled API call or stable readiness marker; avoid an early body snapshot
cy.get() fails while checking whether something exists A failing command was used as a presence test Use a deterministic flag, API response, or stable body.find() branch
A command inside .should(callback) runs multiple times Assertion callbacks are retried Keep callbacks assertion-only; move one-time actions to a one-shot callback after state stabilization
Detached DOM element error A framework re-render replaced the yielded element End the chain and query the element again
Assertions time out The expected state never becomes true, or the selector is wrong Inspect the network response and application state; verify selectors and timeout configuration
Tests pass locally but fail in CI Different timing, data, feature flags, or network behavior Control fixtures and flags, wait on named requests, and remove random assignments

10. Performance, reliability, and cost

  • Performance: Prefer one API wait or readiness assertion over repeated polling loops and arbitrary delays. Fixed sleeps slow every run and still do not prove that the correct state is ready.
  • Reliability: Use stable selectors such as data-cy, deterministic fixtures, and explicit feature-flag setup. Keep conditional callbacks idempotent.
  • Timeouts: Increase a timeout only when the application genuinely needs more time. A longer timeout cannot correct an incorrect branch condition.
  • Debugging: Log the response or state value used for the decision. This makes a failed branch explainable without relying on a screenshot of a transient DOM.
  • Cost: Cypress test execution costs depend on your chosen CI and Cypress Cloud setup. The conditional-check patterns themselves add no external service cost.

Or skip the browser setup

If you need a screenshot of a page or branch outcome rather than a full Cypress browser workflow, ScreenshotNeo provides a single request for a PNG, JPEG, WebP, or PDF. Cookie and consent banners, newsletter popups, and chat widgets are removed before capture; bot checks, blank pages, failed loads, timeouts, and cache hits are not billed. Its MCP server lets Claude, Cursor, and other MCP clients call screenshot tools directly. The free plan includes 1,000 screenshots per month with no card, and paid plans start at $5 for 3,000 shots.

See the ScreenshotNeo API documentation for all options.

curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp
import requests
r = requests.get("https://api.screenshotneo.com/v1/shot", params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"}, timeout=90)
open("shot.webp", "wb").write(r.content)
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);

Create a free ScreenshotNeo account with 1,000 screenshots a month and no card.

FAQ

Can I branch directly on page text?

Yes, when the text is stable. Prefer the API or application state that produced the text when it can change during rendering.

Should I use a fixed cy.wait(2000) before branching?

Usually no. Wait for a named request or readiness assertion so the test proceeds as soon as the required state exists.

Can a .should() callback contain Cypress commands?

No. Cypress may retry the callback, so commands and one-time side effects can run repeatedly. Keep it for repeatable assertions.

Does enabling test retries solve flaky conditional tests?

No. Retries rerun failures; they do not make an unstable DOM observation reliable.

When is synchronous DOM inspection acceptable?

When the application state is known to be settled and cannot change during the decision, such as a static page or a deliberately frozen fixture.

Where can I learn about Cypress retry behavior?

Read the official retry-ability documentation and the should API reference.