Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Some links on this page are affiliate links: if you buy through them we may earn a commission, at no extra cost to you.

In Cypress, use JavaScript forEach() to create separate tests from data already available when the spec loads, Cypress .each() to process elements yielded by a query, and bounded recursion to repeat asynchronous Cypress commands until a condition is met. Avoid ordinary while loops around Cypress commands: those commands are queued, so a synchronous loop can keep adding work before Cypress has executed the first attempt.

Why ordinary loops behave differently in Cypress

Cypress commands look like synchronous JavaScript, but they do not run immediately. As Cypress explains, “Each Cypress command (and chain of commands) returns immediately, having only been appended to a queue to be executed at a later time.” The test’s JavaScript can therefore continue running while Cypress commands wait in the queue.

A normal JavaScript loop does not pause for that queue. It can enqueue the same commands repeatedly before the first query or assertion has run. A loop condition that expects a Cypress callback to change a variable is especially dangerous: the callback cannot run until the current synchronous JavaScript finishes.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
let found7 = false
while (!found7) {
  cy.get('#result')
    .should('not.be.empty')
    .invoke('text')
    .then((text) => Number.parseInt(text, 10))
    .then((number) => {
      if (number === 7) found7 = true
      else cy.reload()
    })
}

This is not a reliable wait. The while condition is checked before Cypress runs the queued commands that might set found7; the queue can grow until the browser becomes unresponsive or the test crashes. Choose the loop pattern based on what is being repeated and when its data is available.

Choose the right iteration pattern

Need Use When the data exists
Create a distinct test for each case JavaScript forEach() around it() Synchronously, while the spec is loaded
Check each element returned by a Cypress query Cypress .each() After the query yields its subject
Repeat a query or action until a condition is met Recursion scheduled through Cypress commands During test execution, between attempts
Branch based on whether a UI state is present An authoritative settled source such as a cookie or stable data attribute Once the state can be read reliably

Generate one test per data row with JavaScript forEach()

Use an ordinary JavaScript array when its contents are known as the spec file loads. Each call to it() is registered before Cypress runs the tests, giving every scenario its own result and failure location.

const scenarios = [
  {
    title: 'valid login',
    username: 'alice',
    password: 'correct',
    expected: 'Dashboard',
  },
  {
    title: 'invalid login',
    username: 'alice',
    password: 'wrong',
    expected: 'Invalid credentials',
  },
]

describe('login scenarios', () => {
  scenarios.forEach((scenario) => {
    it(scenario.title, () => {
      cy.visit('/login')
      cy.get('[data-testid="username"]').type(scenario.username)
      cy.get('[data-testid="password"]').type(scenario.password)
      cy.get('[data-testid="submit"]').click()
      cy.contains(scenario.expected).should('be.visible')
    })
  })
})

The key boundary is test definition versus test execution. The synchronous forEach() registers the tests; Cypress commands inside each it() run later in that test’s queue. This pattern is suitable for literal arrays and data synchronously imported into the spec.

It cannot use the result of cy.fixture() or cy.task() to create the it() blocks: those are asynchronous Cypress commands, and the tests need to be registered before such commands execute. If cases must come from asynchronous setup, change where or how the spec obtains the data rather than trying to define tests inside a Cypress callback.

What’s actually slowing this PC down?

Pick the symptom - the matching free tool is one click away.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Iterate over elements with Cypress .each()

Use .each() when a Cypress query has found a collection and you want to check or act on its members. The callback receives the current element, its index, and the complete list. Wrap a yielded DOM element in cy.wrap() to chain Cypress assertions or commands from it.

cy.get('[data-testid="nav-link"]').each(($link, index, $list) => {
  cy.wrap($link)
    .should('have.attr', 'href')
    .and('not.be.empty')
})

Here Cypress first queries the links, then invokes the iteration callback for the yielded collection. That is different from generating tests: this is one test working through elements found during its execution, not a new it() per link.

Be cautious when an action can cause the application to render a new version of the page. A saved element may then be detached from the DOM. Keep an action at the end of its chain and query again for elements needed after the change:

cy.get('.row').each(($row) => {
  cy.wrap($row).find('[data-testid="open"]').click()
  cy.get('[data-testid="toast"]').should('be.visible')
})

The toast is queried from cy after the click rather than through the prior row subject. For a subsequent assertion about a list that may have re-rendered, start a new query as well:

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
cy.get('.list').find('li').should('have.length', 3)
cy.get('.list').find('li').eq(2).should('contain', 'Header')

Repeat asynchronous work with bounded recursion

If each attempt must run through Cypress before deciding whether to try again, put the next attempt behind a Cypress callback. The following helper checks for the number 7 after each page load, reloads when it has not appeared, and stops after a finite number of attempts.

function checkAndReload(attempt = 0) {
  if (attempt >= 10) {
    throw new Error('Number 7 was not found after 10 attempts')
  }

  cy.get('#result')
    .should('not.be.empty')
    .invoke('text')
    .then((text) => Number.parseInt(text, 10))
    .then((number) => {
      if (number === 7) {
        cy.log('found 7')
        return
      }

      cy.reload()
      checkAndReload(attempt + 1)
    })
}

checkAndReload()

The first check runs through Cypress’s queue. If it does not match, the callback queues a reload and calls the helper for another check; the next check is therefore scheduled from the executing Cypress chain, rather than from a synchronous while loop. The attempt limit is essential: without it, a condition that never becomes true can keep the test running indefinitely.

This example counts at most ten checks, beginning with attempt zero. Reaching the limit throws a clear error rather than silently passing or retrying forever. Adjust the bound to the application’s expected behavior, and ensure the check itself has a meaningful assertion or condition.

Use conditionals only with a dependable source of truth

Conditional logic is useful when an optional state is genuinely optional, such as a wizard that appears only for some users. It is unreliable to decide based on a DOM snapshot that may still be changing while scripts or network responses load. Cypress recommends branching only when the relevant state has settled, or anchoring the branch to a stable source such as server state, a cookie, local storage, or a predictable data attribute.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Branch on a cookie

If the cookie is the application’s authoritative signal for whether the wizard should appear, read it and then conditionally act:

cy.getCookie('showWizard').then((cookie) => {
  if (cookie) {
    cy.get('#wizard').contains('Close').click()
  }
})

Branch on a stable DOM attribute

An always-present attribute that identifies the settled state can also be a useful signal:

cy.get('html')
  .should('have.attr', 'data-wizard')
  .then((wizard) => {
    if (wizard) cy.get('#wizard').contains('Close').click()
  })

The assertion establishes that the attribute is present before the callback makes the decision. Use an attribute whose value actually distinguishes the relevant states; an attribute that can be set before the page finishes changing does not solve the timing problem.

Do not use a failed cy.get() as an if/else probe. Cypress commands do not provide built-in catch-and-continue recovery for a failed query: it fails the test and stops the remaining commands. If absence is a valid state, branch from an independent reliable signal rather than deliberately failing the command that would look for the optional element.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Understand retries, actions, and stale subjects

Cypress query commands and assertions retry together until their assertions pass or the timeout is reached. An action such as .click() executes once; it is not repeatedly performed as an assertion retry. This difference is important when deciding whether to wait, branch, or query again.

  • Use a query followed by .should() when you need Cypress to wait for a condition on the page.
  • Do not expect an action command to repeat until the application changes. Arrange for the target state to become actionable, then perform the action once.
  • After an action that can trigger a re-render, do not assume the old subject still refers to a live DOM element. Start a fresh query for the next check.
  • Use .should() and .and() as retryable assertions, but split a chain when a later assertion would rely on an element that may have been replaced.

For example, checking a list’s length and then querying the third item separately avoids carrying a potentially stale subject through both checks:

cy.get('.list').find('li').should('have.length', 3)
cy.get('.list').find('li').eq(2).should('contain', 'Header')
Independent reader supportYour contribution helps us test, update, and keep practical guides available for everyone.Support on Ko-Fi

Troubleshoot common loop and branching failures

Symptom Likely cause Fix
The test hangs, the browser slows down, or the command queue grows A synchronous while loop is adding Cypress commands before callbacks execute. Replace the loop with bounded recursion that schedules another attempt from a Cypress callback.
Tests are missing when using fixture or task data The it() blocks are being created from an asynchronous Cypress result after the spec has already been loaded. Use data available synchronously at spec load to register tests; do not build the test suite inside cy.fixture() or cy.task().
An optional-element check fails the whole test A failed cy.get() is being treated as a recoverable false result. Branch on a settled cookie, storage value, server-provided state, or stable data attribute instead.
A later command reports a detached element An earlier action caused a re-render, leaving the chain with a stale DOM subject. End the action chain and query again from cy before checking the updated interface.
A repeat-until test never ends The stopping condition is never reached and the retry logic has no explicit bound. Set a maximum attempt count and throw a descriptive error when it is reached.
A click is not repeated while the page is still changing Actions execute once; only queries and assertions retry. Wait for a suitable retryable condition before the click, then re-query as needed after the action.

Or skip the browser setup

If your goal is to capture a page rather than exercise application behavior in a Cypress test, ScreenshotNeo provides a screenshot API and MCP server. One GET request returns a screenshot or PDF; its [sic] screenshot options are documented at ScreenshotNeo’s API docs.

curl -G "https://api.screenshotneo.com/v1/shot" 
  -d access_key=YOUR_API_KEY 
  --data-urlencode url=https://stripe.com 
  -o shot.webp

ScreenshotNeo accepts cookie or consent banners like a visitor and removes more than 60 known consent platforms, newsletter popups, and chat widgets before capture; each cleanup step can be turned off. Bot checks and CAPTCHAs, blank pages, timeouts, failed loads, and cache hits are not billed, with response headers reporting the page verdict and billing status. Its MCP server offers take_screenshot, get_page_info, and capture_pdf to AI agents and other MCP clients. The free plan includes 1,000 screenshots per month with no card; paid plans start at $5 for 3,000 shots. See ScreenshotNeo and sign up free to get 1,000 screenshots a month with no card.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Frequently Asked Questions

Can I use `cy.fixture()` to create one `it()` test for every fixture entry?

No. Cypress fixture commands run asynchronously, after the spec’s tests have been registered. Use data available synchronously during spec loading to generate tests.

Can I use `.each()` to create a separate test for every matching element?

`.each()` iterates the yielded elements within a running test. To create separate tests, define them synchronously with JavaScript iteration around `it()`.

Should I use a Cypress loop to keep clicking until a button appears?

No. First wait for a retryable query or assertion that establishes the button is ready, then click once. For repeated attempts that must run through Cypress, use bounded recursion.

Product prices and availability are accurate as of the date/time indicated and are subject to change. Any price and availability information displayed on Amazon at the time of purchase will apply.

Free tools Windows power users keep installed

One-click scans. No signup required.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.