ScreenshotNeo

BlogEngineering

Common UI Testing Problems and How Cypress Solves Them

Learn how to diagnose flaky UI tests, control network timing, isolate state, choose the right test level, and debug Cypress failures in CI.

By the ScreenshotNeo team4 October 202612 min read

Most unreliable UI tests come from test design and synchronization problems: an assertion runs before the UI is ready, a test depends on uncontrolled network behavior, state leaks between tests, or the test covers more of the application than it needs to. Cypress helps address these problems with retryable queries and assertions, network interception, browser isolation, and test types matched to the behavior under test. These tools reduce avoidable flake; they do not make a flawed test reliable by themselves.

This guide shows how to recognize common failure patterns, write runnable Cypress examples, and decide when to use component, API, or end-to-end coverage. Cypress’s own documentation is the source for Cypress behavior described here.

1. Start with the failure: what kind of problem is it?

Before adding a wait or retry, identify what the test expects and what was actually unfinished or different when it failed. Cypress identifies animations, API calls, test-server or database availability, dependencies, and network conditions as possible sources of unreliable tests. A timeout is a symptom; the fix depends on its cause. See Cypress test retries and debugging in Cypress.

Symptom Likely cause First response
Element occasionally missing Render or network race; selector is wrong or too broad Assert on the UI state that matters; confirm the selector targets one intended element
Test fails around an API-driven view Request is late, failed, or returns variable data Alias the relevant request; wait for its response or stub a defined scenario
Passes locally, fails in CI Different network speed, environment, server readiness, or resource availability Inspect the failing command and request; assert each prerequisite step
Fails when run alone or reordered Shared browser, backend, or persisted state Make setup explicit and tests independently runnable
Suite is slow Excessive end-to-end setup, repeated login, real network calls, or CI constraints Measure first and move coverage to a narrower test level where appropriate

2. Timing races and flaky assertions

A common mistake is treating Cypress commands as ordinary synchronous JavaScript. Cypress queues commands. Queries and assertions in a linked chain retry while Cypress waits for the expected state; an action such as a click is not replayed continuously like a query. A non-query command executes once. Configured test retries are a separate feature that reruns a failed test, and they are off by default. The distinction is explained in Retry-ability and Test retries.

Use state assertions instead of arbitrary sleeps

Prefer an assertion that describes the required state. Cypress will retry the query and assertion until it passes or reaches its timeout. Avoid using a fixed delay as a substitute for knowing what the application is waiting on.

describe('search results', () => {
  it('shows the results state', () => {
    cy.visit('/search?q=shoes')

    cy.get('[data-testid="results-heading"]')
      .should('be.visible')
      .and('contain.text', 'Results')

    cy.get('[data-testid="result-card"]')
      .should('have.length.greaterThan', 0)
  })
})

The example assumes the page route and test IDs exist in your application. A test ID is one possible stable selector convention; choose selectors that express the intent of your test and remain meaningful as styling changes.

Wait for the request when the UI depends on it

Register the interception before the action that triggers the request, wait for the aliased request, and then assert on the rendered result. This makes the dependency explicit and provides request and response details when it fails.

describe('search results', () => {
  it('renders results after the search response', () => {
    cy.intercept('GET', '/api/search*').as('search')
    cy.visit('/search?q=shoes')

    cy.wait('@search').its('response.statusCode').should('eq', 200)
    cy.get('[data-testid="result-card"]').should('have.length.greaterThan', 0)
  })
})

If the application sends the request immediately during startup, install the intercept before navigation. Waiting on a request that was never matched can mean the matcher is wrong, the request did not happen, or the browser served cached content. Cypress intercepts at the network layer; a browser-cached response may not pass through that layer. See the cy.intercept() reference.

Do not mask root causes with test retries

Retry configuration can help expose intermittent failures and may be useful in CI, but a test that passes only on a later attempt is still a signal to investigate. A retry reruns a failed test, potentially including its hooks; it is not equivalent to retrying a query until the page reaches the desired state.

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

module.exports = defineConfig({
  retries: {
    runMode: 1,
    openMode: 0,
  },
})

Use a small, deliberate retry policy if it helps gather evidence or tolerate genuinely transient infrastructure behavior. Keep the test’s state setup repeatable, and track tests that retry rather than treating retries as a permanent repair.

3. Uncontrolled or slow network behavior

cy.intercept() can observe requests, inspect request data, stub responses, and introduce a controlled delay. Choose between a stub and a real server based on what the test must prove:

  • Stub a response when testing a UI state or edge case such as an empty list, server error, or slow response. This makes the scenario repeatable.
  • Use the real service when the integration with that service is part of the behavior being verified. Keep the test data and environment controlled where possible.
  • Mix both approaches when a test needs realistic behavior for one dependency and a controlled response for another.

Do not intercept every request with a broad wildcard without a reason; broad interception adds overhead and makes it harder to tell which dependency matters. Cypress discusses spying, stubbing, and waiting in its network requests guide.

describe('failed search', () => {
  it('shows an error when the search service is unavailable', () => {
    cy.intercept('GET', '/api/search*', {
      statusCode: 503,
      body: { message: 'Service unavailable' },
    }).as('search')

    cy.visit('/search?q=shoes')
    cy.wait('@search').its('response.statusCode').should('eq', 503)
    cy.get('[role="alert"]').should('contain.text', 'Try again')
  })
})

For a delayed-response state, a route handler can set a delay on the static response. Use that to verify a loading indicator’s behavior, not to make every test slower.

cy.intercept('GET', '/api/search*', {
  delay: 800,
  statusCode: 200,
  body: { results: [] },
}).as('slowSearch')

cy.visit('/search?q=shoes')
cy.get('[data-testid="loading-indicator"]').should('be.visible')
cy.wait('@slowSearch')
cy.get('[data-testid="loading-indicator"]').should('not.exist')

4. Tests that pass locally but fail in CI

CI can differ from a developer machine in network speed, available resources, server startup, environment variables, and the order or concurrency of work. Cypress recommends checking whether requests have completed before asserting on dependent UI, adding assertions around required steps, and reviewing whether the CI process changes application state or resource availability. Its debugging guide describes Test Replay in Cypress Cloud as an option for examining a recorded CI run.

  1. Find the first failed assertion, not just the final timeout in the log.
  2. Check whether the expected request occurred and inspect its method, URL, status, and response.
  3. Confirm the application server was ready and that the test is using the intended base URL and configuration.
  4. Verify test data is created for this test and is not removed or changed by parallel work.
  5. Run the test alone and in its normal suite order. A difference points toward shared state, setup, or timing.
  6. Use available screenshots, videos, command logs, or recorded run diagnostics to compare the state at failure.

Make each assertion describe an important milestone. If a test navigates, submits a form, and then checks a result, assert that the page reached the expected state after each meaningful step. That narrows the failure to a transition instead of leaving one long, opaque timeout.

5. Hidden state shared between tests

A test should pass by itself, after another test, and when the suite is reordered. Cypress recommends independent tests. End-to-end test isolation is enabled by default: before each test, Cypress clears the DOM context, cookies, local storage, and session storage. It also resets aliases, intercepts, spies, stubs, clock mocks, and viewport changes. Some storage, including IndexedDB, is not cleared automatically. See Test isolation in Cypress.

Set up the state a test needs inside its own setup path. If authentication is expensive, Cypress provides cy.session() to cache and restore session cookies and storage while maintaining a repeatable login setup. If a test depends on server-side records, seed them explicitly through a controlled API or test fixture strategy.

beforeEach(() => {
  cy.intercept('GET', '/api/profile', { fixture: 'profile.json' }).as('profile')
  cy.visit('/account')
  cy.wait('@profile')
})

it('shows the account name', () => {
  cy.get('[data-testid="account-name"]').should('contain.text', 'Ada')
})

Disabling isolation can make a suite faster in some situations, but it allows state to leak between tests. Cypress cautions that this can make tests order-dependent. Prefer explicit setup unless you have measured a need and verified each test still passes alone.

6. Choosing the wrong test scope

Use the narrowest test that proves the behavior you care about, then retain end-to-end tests for integrated journeys where the integration itself matters. Cypress describes three useful scopes; accessibility checks layer across them rather than replacing them.

Test type Good fit What a pass establishes What it does not establish by itself
Component Rendering, interaction, and states of a focused component The mounted component behaves as asserted in a real browser That routes, backend services, and the whole application integrate correctly
API Endpoint behavior, validation, permissions, and data contracts The HTTP behavior under the tested request and response conditions That the browser UI presents the result correctly
End-to-end Critical user journeys across integrated application layers The tested journey worked in that configured environment Every component state, API edge case, browser, or environment
Accessibility layer Automated rules and explicit accessible behavior assertions The covered rules and behaviors passed That the full interface is accessible to every user

Component tests mount a component in a real browser and give focused feedback. API tests can exercise endpoints without rendering a page. End-to-end tests cover integrated journeys but bring more setup and exposure to environment variation. Cypress’s overview of testing types explains these scopes; its component testing guide covers getting started.

Example: test an API contract directly

it('returns a list of products', () => {
  cy.request('/api/products').then((response) => {
    expect(response.status).to.eq(200)
    expect(response.body).to.be.an('array')
    expect(response.headers).to.have.property('content-type')
  })
})

This is useful for API behavior; it does not replace a UI assertion that confirms products render correctly.

7. Accessibility gaps in automated UI tests

Automated accessibility scans can catch known rule violations such as missing labels, poor contrast, or missing alternative text. Add explicit assertions for application-specific accessible names and semantics, and manually assess areas automation cannot determine. A scan is not proof that an interface is fully accessible. Cypress documents both the uses and limits of automated checks in its accessibility testing guide.

For example, assert the accessible label a user should encounter, and test keyboard behavior in important flows. A role-based locator can make a test easier to read, but choosing an element by role alone does not prove that the complete interaction is accessible.

it('labels the email field and submit action', () => {
  cy.visit('/signup')
  cy.get('input[type="email"]').should('have.attr', 'aria-label', 'Email address')
  cy.get('button[type="submit"]').should('have.attr', 'aria-label', 'Create account')
})

Use a scan plugin or Cypress Accessibility where it fits your workflow, but understand which rules and states are covered. Scans also add runtime, so avoid repeating identical scans without adding coverage.

8. Slow suites: measure, then reduce unnecessary work

Start with actual durations before changing test architecture. Cypress lists wrong test type, repeated login, real network calls, bloated CI setup, and constrained machines among common performance causes. Its test performance guide recommends diagnosing bottlenecks first.

  • Move focused component behavior or endpoint contracts out of end-to-end tests when the wider browser journey is not part of the requirement.
  • Reuse authentication setup safely with cy.session() where appropriate.
  • Stub slow or variable dependencies in tests that do not need to verify those integrations.
  • Keep intercept matchers specific and avoid unnecessary arbitrary delays.
  • Check CI setup and machine resources when all tests are slow rather than one spec.
  • Use Cypress Cloud analytics if available to identify slow and frequently retrying tests.

Stubbing improves determinism for a UI scenario but gives up evidence about the real dependency in that test. Keep separate integration coverage for important service boundaries. Parallelization can distribute work, but it does not fix tests that contend for shared data or depend on order.

9. A practical troubleshooting reference

Error or symptom Common cause Fix to try
Timed out retrying: Expected to find element Wrong selector, wrong page state, or prerequisite request not complete Confirm the route and selector; assert on the relevant request or state transition
cy.wait('@alias') times out Intercept registered too late, matcher mismatch, request never fired, or response came from cache Register before navigation/action; inspect method and URL; check browser caching
Expected request status is wrong Real service returned an error or environment differs Inspect response body and environment; stub the response if testing UI error handling
Passes only after a retry Timing race, leaked state, or transient dependency Find the first attempt’s divergent state; repair setup or synchronization and track recurrence
Works in a suite but fails alone Earlier test prepared hidden state Move required data and browser setup into this test’s hooks
UI shows stale data despite a successful request Assertion checks the wrong rendered state or application update is asynchronous Assert the specific visible result and verify it corresponds to the response
Suite slowdown after adding scans or intercepts Repeated expensive scans or broad interception Scan meaningful states once and narrow routes to relevant requests

10. Capture a page artifact for visual debugging

A screenshot can help document a visual state alongside a failing test, especially when a developer needs to inspect a route outside the test runner. It complements Cypress’s own failure evidence; it does not replace assertions or prove that the test passed. For a quick reference capture of a page, ScreenshotNeo provides a website screenshot API and MCP server.

Or skip the browser setup

Use the ScreenshotNeo API when you need a page image without wiring up a browser capture script. See the ScreenshotNeo API documentation for options and setup.

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 Bun.write('shot.webp', res);

Cookie banners, popups, and chat widgets are removed before the shot. Bot checks, blank pages, and failed loads are never billed. An MCP server lets AI agents take screenshots. The free plan includes 1,000 screenshots a month with no card; paid plans start at $5 for 3,000. These captures are useful as visual artifacts, while Cypress should remain responsible for determining whether your test assertions pass.

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

11. Reliability and cost considerations

Reliable suites make their dependencies and state explicit. Use real services where integration behavior is the subject of the test; stub them where the test is about a controlled UI response. Isolate tests and avoid retries as a substitute for root-cause fixes. In CI, runtime and machine usage are practical costs, so optimize measured bottlenecks and keep coverage at the level that proves the behavior.

Retry attempts increase runtime when a test fails, while broad network interception and repeated accessibility scans add work. Conversely, a targeted request wait can avoid wasted time from blind sleeps. There are no universal timing thresholds: application behavior, environment, and test scope determine sensible timeouts.

12. Frequently asked questions

Does Cypress automatically fix flaky tests?

No. Retryable query and assertion chains handle changing UI state, but test design, data, external dependencies, and CI differences can still cause flake. Find the cause and make the required state explicit.

Should every API call be stubbed?

No. Stub dependencies when you need deterministic UI scenarios; use real requests when the integration is what the test must verify. A suite can include both.

Can component tests replace end-to-end tests?

No. Component tests provide focused coverage, while end-to-end tests verify selected integrated journeys. Use each for the claim it can establish.

Do accessibility scans certify a page as accessible?

No. They catch a bounded set of known violations. Add explicit behavior assertions and manual evaluation for gaps.

Why might an intercept not catch a request?

The request may not have occurred, the route matcher may not match, the intercept may be registered too late, or the browser may have served a cached response.

Sources and further reading