Free Ebook cover Cypress End-to-End Testing Basics: Stable UI Tests for Web Apps

Cypress End-to-End Testing Basics: Stable UI Tests for Web Apps

New course

10 pages

Debugging and Hardening Cypress UI Tests: Common Failure Patterns and Fixes

Capítulo 10

Estimated reading time: 11 minutes

+ Exercise

Reading Cypress error messages and stack traces

When a Cypress test fails, the fastest path to a fix is to classify the failure from the error panel: what command failed, what Cypress was trying to assert, and what the DOM looked like at that moment. Most flaky tests fall into a few buckets (element not found, element detached, assertion timed out, or unexpected navigation/state), and Cypress error output usually contains enough clues to pick the right fix.

What to look for in the error panel

  • Failed command: The command highlighted in the Command Log tells you which step broke (for example, cy.get(), cy.contains(), click(), should()).
  • Assertion vs command failure: A should() timeout indicates the condition never became true; a cy.get() failure indicates the element never appeared (or the selector is wrong).
  • Subject of the command: Cypress shows the element(s) it had at the time. If it’s empty, you likely have a selector/timing issue. If it’s an element that later disappears, you may have a detachment/rerender issue.
  • Application stack trace: If the app threw an exception, the stack trace points to your app code, not Cypress. That’s a product bug or an environment mismatch, not a test synchronization issue.
  • Test stack trace: The stack trace that points into your spec file shows the exact line where the failing command was issued. Use it to find the minimal failing step.

Common Cypress failure messages and what they usually mean

  • Timed out retrying after ...: Expected to find element ... but never found it: selector mismatch, element rendered in a different container, or navigation didn’t happen.
  • Timed out retrying after ...: expected ... to be visible: element exists but is hidden/covered/disabled; could be a loading overlay, animation, or wrong UI state.
  • cy.click() failed because this element is detached from the DOM: the element was found, but the UI rerendered between get and click. Re-query right before the action or click a stable ancestor.
  • Expected ... to have text ... but the text was ...: assertion is too strict (formatting, whitespace, localization) or you asserted too early on a transient state.

Step-by-step: triage a failing test in under 2 minutes

  • Step 1: Identify the failing command in the Command Log.
  • Step 2: Decide whether it’s a selector problem (element never found), a timing problem (eventually found but too late), or a state problem (wrong page/user/data).
  • Step 3: Inspect the DOM snapshot at the failure step (Cypress shows the subject and the page at that time).
  • Step 4: If the app threw an exception, read the app stack trace first; don’t “fix” the test until you know whether the app is broken.
  • Step 5: Reduce the test to the smallest failing sequence (comment out later steps) so you can iterate quickly.

Using .debug(), .pause(), screenshots, and videos

Cypress gives you interactive debugging tools that are more effective than adding arbitrary waits. Use them to answer: “What did Cypress see?” and “What state was the app in when the test failed?”

.debug(): inspect the current subject in DevTools

.debug() yields the current subject and triggers a debugger statement so you can inspect the element in the browser’s DevTools.

cy.get('[data-cy=checkout-button]').debug().click()
  • Use this when you suspect the element exists but is not interactable (covered, disabled, wrong element matched).
  • In DevTools, inspect computed styles, bounding box, and whether another element overlays it.

.pause(): stop the test and interact with the app

.pause() halts the run so you can manually click around, open menus, and confirm the UI state. Then resume step-by-step from the Cypress runner.

cy.visit('/settings') cy.pause() cy.get('[data-cy=save]').click()
  • Use this to confirm whether the test’s assumptions about navigation, authentication, or data are correct.
  • While paused, use the Command Log to step forward and see exactly when the UI diverges.

Screenshots: capture the failure state

Cypress automatically takes screenshots on failure (when configured). You can also take targeted screenshots at key checkpoints to compare “good” vs “bad” runs.

Continue in our app.

You can listen to the audiobook with the screen off, receive a free certificate for this course, and also have access to 5,000 other free online courses.

Or continue reading below...
Download App

Download the app

cy.get('[data-cy=cart]').screenshot('cart-before-checkout') cy.get('[data-cy=checkout-button]').click() cy.get('[data-cy=order-confirmation]').screenshot('confirmation')
  • Use targeted screenshots when failures are intermittent and you need evidence of which UI state occurred.
  • Name screenshots by intent (for example, after-login, before-submit) so they’re searchable in CI artifacts.

Videos: understand timing and transitions

Videos are most useful for flaky failures that involve animations, route changes, or overlays. Watch for: double navigation, spinners that never disappear, clicks that happen before the UI is ready, or modals that appear late.

  • Use video to identify the first moment the UI diverges from expectations.
  • Pair video with the Command Log timestamps to locate the exact step where the mismatch starts.

Identifying selector brittleness vs timing issues vs state leakage

Hardening tests starts with correctly identifying the failure pattern. The same symptom (element not found) can come from three different root causes: brittle selectors, timing/synchronization, or leaked state from previous tests.

Pattern 1: selector brittleness

Symptoms: tests fail after UI refactors; failures are consistent across runs; the element is present but the selector matches the wrong node or nothing at all.

  • Check: In the failure snapshot, search the DOM for the selector target. If the element exists but attributes/classes changed, it’s a selector issue.
  • Fix: Prefer stable hooks (for example, data-cy) and avoid chaining through fragile DOM structure (like .parent().children().eq(2)).
  • Fix: If multiple elements match, scope the query to a container that represents the component under test.
// brittle: depends on DOM structure cy.get('.product > div > button').click() // hardened: scoped to a stable container cy.get('[data-cy=product-card][data-id="123"]').within(() => {   cy.get('[data-cy=add-to-cart]').click() })

Pattern 2: timing/synchronization issues

Symptoms: test passes locally but flakes in CI; reruns sometimes pass; failures mention timeouts, visibility, or detachment.

  • Check: Does the UI render asynchronously (loading states, transitions, delayed hydration)? Does the element appear but become detached?
  • Fix: Assert on a stable “ready” signal before interacting (for example, a container exists, a spinner is gone, a button becomes enabled).
  • Fix: Re-query elements right before an action if the UI rerenders frequently.
// wait for readiness signal, then act cy.get('[data-cy=profile-page]').should('be.visible') cy.get('[data-cy=loading]').should('not.exist') cy.get('[data-cy=save]').should('be.enabled').click()
// avoid detached element by re-querying right before click cy.contains('[data-cy=item-row]', 'Laptop')   .find('[data-cy=remove]')   .should('be.visible')   .click()

Pattern 3: state leakage between tests

Symptoms: a test passes alone but fails when run with the full suite; failures depend on test order; unexpected authentication state, cached data, or leftover UI state (like open modals).

  • Check: Run the spec with a single test (it.only) and then run the full file. If it only fails in the full run, suspect leakage.
  • Check: Look for shared state: cookies, localStorage/sessionStorage, service worker caches, server-side data created by previous tests.
  • Fix: Reset client state in beforeEach and ensure each test sets up its own prerequisites.

Resetting state between tests (cookies, localStorage) in a controlled way

Reliable UI tests start from a known baseline. The goal is not to “nuke everything blindly,” but to reset the specific state that can leak across tests while keeping setup fast and intentional.

Baseline reset in beforeEach

Use a consistent baseline for every test: clear cookies and storage, then visit a known route. This prevents hidden dependencies on previous tests.

beforeEach(() => {   cy.clearCookies()   cy.clearLocalStorage()   // If your app uses sessionStorage, clear it via window   cy.window().then((win) => {     win.sessionStorage.clear()   })   cy.visit('/') })

Preserve only what you intentionally want to keep

Sometimes you want to keep authentication to speed up tests, but still avoid leaking feature flags, onboarding banners, or partial form state. In that case, clear everything and restore only the minimal auth tokens you control.

// Example: restore only auth token you control (token value comes from a helper) const setAuth = (token) => {   cy.window().then((win) => {     win.localStorage.setItem('auth_token', token)   }) } beforeEach(() => {   cy.clearCookies()   cy.clearLocalStorage()   cy.visit('/')   setAuth(Cypress.env('AUTH_TOKEN')) })
  • Keep the “restore” logic in one place so it’s easy to audit what state is being carried.
  • If your app stores more than one key, explicitly set only the keys required for the scenario.

Reset server-side state when needed

Client resets won’t fix failures caused by server-side leftovers (for example, an item created in a previous test). If your environment allows it, add a controlled reset step via an API endpoint or a task. The key is to make it deterministic and fast.

// Example pattern: call a reset endpoint (implementation depends on your app) beforeEach(() => {   cy.request('POST', '/test/reset')   cy.clearCookies()   cy.clearLocalStorage() })

Guard against UI leftovers

Even with storage cleared, UI leftovers can persist within a test due to modals, toasts, or route transitions. Add assertions that confirm you’re on the expected page and that blocking overlays are gone before proceeding.

cy.get('[data-cy=modal]').should('not.exist') cy.get('[data-cy=toast]').should('not.exist') cy.location('pathname').should('eq', '/dashboard')

Making assertions resilient (asserting outcomes instead of transient UI)

Flaky tests often assert on intermediate UI states (spinners, temporary text, animation frames) rather than the final outcome the user cares about. Resilient assertions focus on stable, user-visible results and avoid coupling to implementation details.

Prefer outcome assertions over transient UI

  • Transient: asserting that a loading indicator appears, or that a button text changes momentarily.
  • Outcome: asserting that the saved value is displayed, the URL changed to the expected route, or the new row appears in a table.
// transient (more fragile) cy.get('[data-cy=save]').click() cy.contains('Saving...').should('be.visible') // outcome (more stable) cy.get('[data-cy=save]').click() cy.get('[data-cy=save-success]').should('be.visible') cy.get('[data-cy=profile-name]').should('have.value', 'Ada Lovelace')

Assert on stable semantics: URL, page identity, and key content

When a click triggers navigation, assert that you reached the correct page using a stable identity marker (route + page container) before asserting deeper UI details.

cy.get('[data-cy=go-to-billing]').click() cy.location('pathname').should('eq', '/billing') cy.get('[data-cy=billing-page]').should('be.visible') cy.get('[data-cy=plan-name]').should('contain', 'Pro')

Use existence/visibility/enabled checks to prevent premature actions

Before typing or clicking, assert that the element is ready for interaction. This reduces flakes from overlays, disabled states, or delayed rendering.

cy.get('[data-cy=email]').should('be.visible').and('not.be.disabled').type('user@example.com') cy.get('[data-cy=submit]').should('be.enabled').click()

Avoid over-specific text assertions

Exact text matches can be brittle due to whitespace, punctuation, localization, or minor copy changes. When the exact string is not the product requirement, assert on meaningful substrings or structured outcomes.

// brittle cy.get('[data-cy=error]').should('have.text', 'Password must be at least 12 characters.') // more resilient cy.get('[data-cy=error]').should('contain', 'at least 12')

Handle rerenders by asserting on the right element

If a component rerenders, a previously captured element can become stale. Prefer assertions that re-query by a stable selector rather than storing elements and reusing them after major UI transitions.

// better: re-query after action cy.get('[data-cy=add-to-cart]').click() cy.get('[data-cy=cart-count]').should('have.text', '1')

Stability checklist: systematically harden any flaky test

  • Classify the failure: selector mismatch, timing/synchronization, or state leakage?
  • Find the first divergence: in the Command Log, identify the earliest step where the UI is not what you expect.
  • Confirm page identity: assert route (cy.location) and a stable page/container marker before interacting.
  • Make elements interaction-ready: assert be.visible and be.enabled; ensure blocking overlays/spinners are gone.
  • Re-query before actions: avoid acting on elements that may detach after rerenders; query as close as possible to the click/type.
  • Scope selectors: if multiple matches exist, use within() on a stable container to reduce ambiguity.
  • Assert outcomes, not transitions: verify final user-visible results (new row, updated value, success state) rather than temporary text or animations.
  • Reset state intentionally: clear cookies and storage in beforeEach; restore only the minimal required state (like auth token) in a controlled way.
  • Eliminate order dependence: run the test alone and in the full suite; if it only fails in the suite, fix leakage or shared server data.
  • Capture evidence: add targeted screenshots at key checkpoints; use video to spot overlays, double navigations, and premature clicks.
  • Minimize the reproduction: reduce to the smallest failing sequence before applying changes; re-run multiple times to confirm stability.

Now answer the exercise about the content:

A Cypress test passes when run alone but fails when run with the full suite, and the failure seems to depend on test order. What is the most likely underlying issue to investigate first?

You are right! Congratulations, now go to the next page

You missed! Try again.

If a test passes alone but fails in the full run and depends on order, that pattern commonly indicates leaked state between tests (cookies/storage, cached data, or leftover UI like modals).

Download the app to earn free Certification and listen to the courses in the background, even with the screen off.