ScreenshotNeo

BlogGuides

Cypress End-to-End Testing Lessons for More Reliable Automation

Make Cypress tests more reliable with independent setup, resilient selectors, condition-based waits, focused network control, and intentional retries.

By the ScreenshotNeo team4 October 202611 min read

Cypress tests become more reliable when each test owns its setup, selectors describe the behavior being tested, and synchronization waits for observable conditions instead of elapsed time. Use cy.intercept() for requests that matter to a scenario, keep a real backend path for integration risks, and treat retries as a way to find instability rather than hide it.

A test should establish its preconditions, perform a meaningful user action, then assert on a visible result or a specific request. This gives a failure a clear meaning: the setup failed, the interaction failed, or the expected contract was not met.

1. Make each test independent

Cypress recommends that tests pass alone and in sequence. End-to-end test isolation is enabled by default: before each test Cypress clears the page, cookies, localStorage, and sessionStorage. It does not clear IndexedDB or other browser storage, and it cannot reset your backend database for you. Plan those pieces explicitly. Cypress test isolation documentation

Use controlled setup, not a previous test

Seed the specific server-side state a test needs through a test setup endpoint, fixture, or database helper. Use programmatic login when authentication itself is not the subject of the test. Keep a separate browser journey that proves the user-facing login flow works.

// cypress/e2e/profile.cy.js
describe('profile', () => {
  beforeEach(() => {
    // Example test-support endpoint: seed a known user for this scenario.
    cy.request('POST', '/test-support/reset', { user: 'ada@example.test' })
    cy.request('POST', '/test-support/login', { user: 'ada@example.test' })
  })

  it('shows the seeded profile', () => {
    cy.visit('/profile')
    cy.get('[data-cy="profile-name"]').should('have.text', 'Ada Lovelace')
    cy.get('[data-cy="profile-email"]').should('have.text', 'ada@example.test')
  })
})

The /test-support endpoints above are application-specific examples, not Cypress endpoints. Restrict test-only setup routes to test environments. If using cy.session() to cache authentication, ensure its validation detects expired or invalid sessions.

Storage and isolation edge cases

  • IndexedDB: Cypress does not clear it automatically. Delete the application database or provide a reset path before tests that need it empty.
  • Backend records: browser isolation does not reset server state. Give each test unique records or reset/seed the server state.
  • Disabled isolation: this can preserve browser state within a suite, but creates leakage risk. Use it only with deliberate boundaries and prove tests still run alone.
  • Shared environments: parallel workers can collide if they update the same account or record. Namespace test data by run or worker.

2. Choose selectors that survive refactors

Prefer dedicated selectors such as data-cy or data-testid. CSS classes often describe styling, and visible text can change during copy edits or localization. Use text when the text itself is the behavior under test, such as checking that a button says “Save changes.”

// Markup
<button data-cy="save-profile">Save changes</button>

// Cypress
cy.get('[data-cy="save-profile"]').click()
cy.get('[data-cy="save-status"]').should('contain', 'Saved')

Keep selectors specific to the tested control. Avoid broad selectors such as div, *, or a shared class that matches many unrelated elements. When several identical controls exist, scope within a stable parent, then select the intended control.

3. Wait for conditions, not fixed delays

Linked Cypress queries and assertions retry until the condition passes or a timeout expires. A fixed cy.wait(3000) always spends three seconds and still fails if the app takes longer. Prefer an assertion on the state the user needs to see. Cypress retry-ability documentation

// Avoid: timing depends on machine and network speed
cy.get('[data-cy="load-report"]').click()
cy.wait(3000)
cy.get('[data-cy="report"]').should('be.visible')

// Prefer: wait for the observable result
cy.get('[data-cy="load-report"]').click()
cy.get('[data-cy="report"]').should('be.visible')
cy.get('[data-cy="report-title"]').should('contain', 'Monthly report')

Action commands such as .click() execute once; Cypress does not replay them because doing so could repeat an effect. Put an action at the end of its chain, then start a fresh query for the result. This also avoids relying on an element subject that a framework rerender may detach.

Use a request alias when the request is part of the contract

Register the intercept before the action that triggers it, wait on its alias, and assert on the relevant request or response. This proves the request occurred and its response had the expected shape; it does not automatically prove that the UI rendered correctly, so assert on the UI too when that is the scenario’s purpose.

it('loads and renders the account summary', () => {
  cy.intercept('GET', '/api/account/summary').as('summary')
  cy.visit('/account')

  cy.wait('@summary').its('response.statusCode').should('eq', 200)
  cy.get('[data-cy="account-balance"]').should('be.visible')
})

For a known slow operation, adjust the timeout on the relevant command or wait rather than inserting a fixed sleep. Cypress’s retry guide documents timeout behavior; avoid globally increasing timeouts to mask slow or missing conditions.

4. Decide deliberately between a stub and a real backend

cy.intercept() can observe a request, allow it to reach the backend, stub a response, or modify request and response data. A focused stub makes edge cases repeatable and fast. A real response tests the integrated server behavior. A stub alone cannot establish that the live server returns the expected payload. Cypress network requests guide

Approach What it proves well What it does not prove alone
Real backend request Browser, app, routing, and live endpoint work together for this scenario Every failure case or response variant
Stubbed response UI behavior for controlled success, error, empty, or slow responses That the server produces the stubbed contract
API test Endpoint status, payload, and backend contract without a browser journey That the UI presents the result correctly

A balanced suite keeps a small, intentional set of real integrated journeys for release risks and uses stubs to cover response variants that would be difficult or unreliable to reproduce from a live service. Run against a controllable local development server where test data and behavior can be reset.

// Deterministic UI edge case: the empty-state rendering
it('explains when there are no projects', () => {
  cy.intercept('GET', '/api/projects', {
    statusCode: 200,
    body: [],
  }).as('projects')

  cy.visit('/projects')
  cy.wait('@projects')
  cy.get('[data-cy="empty-projects"]').should('be.visible')
})

// Integration path: no response object means the real server handles it
it('loads projects from the test backend', () => {
  cy.intercept('GET', '/api/projects').as('projects')
  cy.visit('/projects')
  cy.wait('@projects').its('response.statusCode').should('eq', 200)
  cy.get('[data-cy="project-list"]').should('be.visible')
})

Keep interception scope narrow

Match the method and endpoint needed by the test. Broad wildcard interception can add overhead on pages with many assets and third-party calls, and makes it harder to tell which request a test depends on. Requests served from browser cache may not reach the network layer and therefore may not trigger an intercept; do not mistake that for an app request that never happened. See the cy.intercept() API reference.

Cypress 16 native network behavior

Starting in Cypress 16, Chrome, Chromium, and Edge use the browser’s native network path for application traffic. The app can negotiate the server-supported protocol, including HTTP/2 or HTTP/3. The documentation also records observable differences: for example, browser-rejected responses are not observable through the intercept, and revalidated responses can report 200 instead of 304. Teams upgrading should review assertions that depended on the earlier network path. This version-specific behavior is documented in Cypress native network interception.

5. Use retries to reveal instability

Query retry-ability and test retries solve different problems. Query retry-ability is part of ordinary Cypress commands: queries and assertions keep checking an observable condition. Test retries rerun an entire failed test and are disabled by default. If a later attempt passes, the first failure still signals instability. Configure retries intentionally and investigate repeated retries rather than treating them as proof of reliability. Cypress test retries documentation

// cypress.config.js
const { defineConfig } = require('cypress')

module.exports = defineConfig({
  e2e: {
    baseUrl: 'http://localhost:3000',
    retries: {
      runMode: 1,
      openMode: 0,
    },
  },
})

Use retries as a diagnostic signal in CI. Keep their scope and count low enough that a genuinely broken test is still visible promptly. Record which tests retry and address the cause: leaked state, an unstable dependency, an ambiguous assertion, or a resource-constrained runner.

6. Pick the smallest test level that proves the risk

Use end-to-end tests for critical journeys whose value depends on the browser, server, routing, or multiple systems working together. Use component tests for isolated interface states and interactions, and API tests for endpoint contracts. Cypress documents these testing types and their different scopes at testing types.

Choosing a smaller test level for behavior that does not require a full browser journey can reduce setup, network variability, and runtime. Retain end-to-end coverage where integration itself is the risk. Do not move every assertion into one large journey just to increase the number of browser steps covered.

7. A practical reliability checklist

  1. Can every test pass when run alone?
  2. Does the setup seed only the state this test needs?
  3. Are server-side records and persistent browser stores explicitly handled?
  4. Do selectors identify the tested control independently of styling?
  5. Does the test wait for a visible condition or a relevant request rather than a guessed delay?
  6. Are intercepts registered before the action and scoped to the scenario?
  7. Is it clear whether the test uses a stub or a real backend response?
  8. Does each test prove one behavior with an observable result?
  9. Are retries intentional, visible, and followed up when they occur?
  10. Is the CI machine provisioned well enough to run the browser and app without resource contention?

8. Troubleshooting common failures

Symptom Likely cause Fix
Passes alone, fails in the full suite Test depends on prior browser state, shared server data, or execution order Run it alone and in a different order; seed/reset its own backend records and remove shared-state assumptions.
Element not found after a fixed wait Load time varies or the UI condition was not the one being waited for Assert on the actual visible result or wait on the specific request that gates it.
“Detached from the DOM” after an action The app rerendered and replaced the element subject End the action chain after the click/type, then query the updated DOM again.
cy.wait('@alias') times out Intercept registered after the trigger, URL/method mismatch, cached response, or action never triggered the request Register before visiting/clicking; verify the exact request; account for cache; assert that the triggering UI action occurred.
Intercept sees unexpected status or headers after upgrade Cypress 16 native network behavior changes what is observable Check the migration guidance and assert on application-visible error state or response body where appropriate.
Tests disagree about login or consent state Browser state is cleared per test, or a storage type is outside isolation cleanup Set required cookies/storage in setup; reset IndexedDB explicitly; do not assume backend state is cleared.
Flaky only on CI Resource contention, slower dependencies, or assumptions about timing Inspect command logs and runner CPU/memory; replace sleeps with conditions and make external dependencies controlled.
Test is green only after a retry Intermittent setup, app, network, or assertion behavior Keep the failure visible in reporting and fix its root cause; do not interpret the retry pass as a stable first attempt.

9. Performance, reliability, and cost

For an engineering team, Cypress suite cost is mostly the CI time and maintenance needed to keep feedback trustworthy. Reduce unnecessary browser journeys, repeated UI login, broad intercepts, arbitrary waits, and duplicate setup. Measure before optimizing: the current Cypress performance guidance discusses test type, authentication setup, network stubbing, parallelization, and runner resources. Cypress test performance guide

Reliability depends on controlling what the test relies on. A real backend adds valuable integration evidence but can expose service and data variability; a stub makes behavior controlled but narrows what the test proves. Keep both where each answers a distinct release question. Parallel execution also requires isolated records and sufficient machine resources so workers do not compete or collide.

10. Inspect the exact page your test depends on

When a failure appears tied to a third-party page or consent experience, a screenshot can make the state easier to diagnose. You can capture a page manually in Cypress, or use ScreenshotNeo, a website screenshot API and MCP server for developers, to capture a URL without maintaining browser-capture setup.

Manual Cypress screenshot

// Save a screenshot artifact of the current page
cy.visit('/checkout')
cy.get('[data-cy="checkout-heading"]').should('be.visible')
cy.screenshot('checkout-loaded', { capture: 'fullPage' })

Cypress screenshots help inspect a state reached in the test run. Avoid using screenshots as a substitute for assertions that explain what behavior must hold.

Or skip the browser setup

One GET request returns an image or PDF. The parameter names other screenshot APIs use also work, which can make switching easier. See the ScreenshotNeo API documentation.

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}`);
if (!res.ok) throw new Error(`Screenshot request failed: ${res.status}`);
await require('node:fs/promises').writeFile('shot.webp', Buffer.from(await res.arrayBuffer()));
  • Cookie and consent banners are accepted like a visitor; 60+ known consent platforms, newsletter popups, and chat widgets can be removed before capture, with each step optional.
  • Bot checks, blank pages, timeouts, failed loads, and cache hits cost nothing; response headers identify the page verdict and billing status.
  • An MCP server provides take_screenshot, get_page_info, and capture_pdf tools for AI agents such as Claude, Cursor, and other MCP clients.
  • The free plan includes 1,000 screenshots a month with no card; paid plans start at $5 for 3,000. Every feature is on every plan.

Sign up free for 1,000 screenshots a month with no card.

FAQ

Why does a test pass alone but fail in the full suite?

It may depend on browser or server state left by another test, or share data with another test running in parallel. Give it independent setup and unique or resettable records.

Should every Cypress test use a real backend?

No. Use real requests where integration is the risk and stubs where controlled UI behavior is the goal. Keep the evidence each test provides explicit.

Should I increase the global command timeout?

Usually not as a first fix. Identify the slow condition and set a local timeout only when that operation is expected to take longer.

Do retries make a flaky test reliable?

No. They can expose intermittent failures and help CI proceed, but a pass after a failed attempt still warrants investigation.

Does test isolation reset all browser and server state?

No. It clears the page, cookies, localStorage, and sessionStorage for E2E tests by default. IndexedDB and backend data need their own cleanup or setup.