ProductHow it worksPricingBlogDocsLoginFind Your First Bug
Cypress test suite showing stable data-cy selectors, retry-ability patterns, and anti-pattern warning signs
TestingCypressE2E Testing+1

Cypress Best Practices That Actually Survive a UI Redesign

Tom Piaggio
Tom PiaggioCo-Founder at Autonoma

Cypress best practices center on stable selectors, proper retry-ability, and test independence. Use data-cy or data-testid attributes instead of CSS classes, replace cy.wait(ms) with intercepted-request aliases, cache login with cy.session(), and keep each test fully isolated. The hardest part is not writing the right patterns once; it is keeping those selectors alive when the UI changes.

Here is a scenario most Cypress maintainers recognize. The app goes through a design refresh. New component library, renamed CSS classes, a few reorganized sections. The suite that ran green in CI for six months now fails across a third of your specs. You spend half a sprint updating selectors instead of shipping features. You fix them, they go green. Three months later, another round of the same.

The anti-patterns that create this cycle are well-known but easy to rationalize in the moment. This article covers the canonical Cypress best practices, the specific anti-patterns that cause long-term rot, and then the maintenance reality that even a correct suite faces as soon as the UI moves.

Autonoma enters that conversation because selector discipline only reduces the maintenance tax; it does not remove it when the component itself changes. Autonoma regenerates tests from behavior and code changes, so the suite is not defined by a selector snapshot that must be patched after each redesign.

Selector best practices: data-* over CSS and XPath

Selectors are where most Cypress suites go wrong first. The default instinct, grab a CSS class or an element by text, works until the design or copy changes. Then the selector is wrong even though the user-facing behavior is exactly the same.

Cypress's own guidance is explicit: test selectors should not be tied to CSS classes, IDs that change, or JavaScript behavior. The right approach is to add a dedicated attribute to the elements your tests interact with, typically data-cy, data-test, or data-testid, and query with cy.get('[data-cy=submit]'). That attribute exists solely for testing. Designers can rename classes. Engineers can refactor IDs. The data-cy attribute stays until someone deliberately removes it.

Here is how the strategies compare:

Selector strategyExampleStabilityCoupling
data-* attribute[data-cy=submit]HighDecoupled from styles and JS
Accessible role / labelcy.findByRole('button')HighTied to accessibility contract
CSS class.btn-primaryLowBreaks on any style refactor
Text contentcy.contains('Submit')Low-mediumBreaks on copy changes
XPath//button[1]Very lowBreaks on any DOM reorder

Comparison of Cypress selector strategies showing data-cy attributes and accessible roles as stable versus CSS classes, text content, and XPath as brittle

Selectors coupled to the test contract survive style refactors; selectors coupled to implementation break on any change.

The second-best option when you cannot add a custom attribute is an accessible role or label query, typically via the Testing Library companion or cy.findByRole(). These couple to the accessibility contract rather than to visual styling, which is a reasonable tradeoff. What to avoid is chaining on positional XPath, deep CSS selectors like .container > .row:nth-child(2) > button, or any selector that would change if the layout grid shifted.

One practical note: adding data-cy attributes requires buy-in from the frontend team. The attributes need to go in during feature development, not bolted on after the fact. A simple convention in your component style guide, "every interactive element used in tests gets a data-cy attribute," eliminates most selector rot at the source.

Retry-ability: stop using cy.wait(ms)

One of Cypress's strongest features is built-in retry-ability. When Cypress executes a command followed by an assertion, it will retry the assertion automatically until it passes or times out. That single mechanism eliminates a large class of flaky tests that plague Selenium and Playwright suites built without explicit waits.

The anti-pattern that undermines this entirely is cy.wait(5000). A fixed millisecond wait is a hard sleep. If the app responds in 300ms, the test wastes 4.7 seconds. If the app takes 6 seconds under load, the test fails. Neither situation is what you want, and both happen regularly in CI environments with variable resource availability.

The right approach depends on what you are waiting for. For network requests, the pattern is to intercept and alias the request before triggering the action, then wait on the alias. Something like: intercept the POST /api/save call with cy.intercept('POST', '/api/save').as('saveCall'), trigger the save action, then cy.wait('@saveCall'). Cypress will wait precisely until that network response arrives, no guessing required.

For DOM-only assertions, retry-ability handles the timing automatically. Instead of sleeping and then asserting, simply assert on the expected state: cy.get('[data-cy=success-banner]').should('be.visible'). Cypress will retry the get and assertion chain until the banner appears or the default timeout expires. Increasing the default timeout via defaultCommandTimeout in cypress.config.js is a much better lever than scattering fixed waits through specs.

Timeline comparison of a fixed cy.wait hard sleep wasting seconds versus cy.wait on an aliased request continuing the moment the response arrives

A fixed wait is always too short or too long; an aliased wait tracks the real network response.

The one legitimate use of cy.wait() with a number is when you genuinely need to pause for an animation that has no detectable DOM side effect. Even then, prefer asserting on a post-animation state. Fixed waits are a test maintenance debt: they are always either too short or too long, and they accumulate silently.

Autonoma does not make a fixed wait smarter; it avoids preserving that wait as the source of truth. When the app behavior changes, the regenerated test follows the current signal instead of keeping a hard sleep from an old recording.

Write independent, repeatable tests

Test isolation is not optional in a maintainable suite. When tests share state, a failure in test three can cause test seven to fail for a completely unrelated reason. Debugging that is expensive and demoralizing.

In modern Cypress (v12 and later), testIsolation is on by default, which clears cookies, local storage, and session storage between tests. This is the right baseline. What it does not handle is authentication: if every test navigates to the login page and performs a full UI login, the suite becomes slow without being more thorough.

cy.session() is the correct tool here. It takes a unique identifier and a setup function. The first time a session is requested with a given ID, Cypress runs the setup function (your login flow), caches the resulting cookies and storage, and marks the session as ready. On subsequent calls with the same ID, Cypress restores the cached state instead of rerunning the login. Tests stay isolated (each test gets a fresh restore), but the authentication cost is paid once per spec run rather than once per test.

Beyond login caching, each test should set up its own preconditions. If a test validates that an admin can delete a user, that test should create the user it will delete, not assume a user exists from a previous test. cy.request() is useful here: seeding data through the API is faster and more reliable than driving the UI to create it.

The principle is simple: a test that can run in any order, on a fresh environment, with no dependency on test execution history is a test you can trust. A test that only works when run after another specific test is a liability.

The anti-patterns that rot Cypress suites

Beyond selectors and waits, several patterns accumulate into a suite that is hard to maintain and gradually unreliable.

Conditional testing is the most common one. Writing if (cy.get('[data-cy=banner]').length > 0) to branch test logic based on current DOM state is fragile. Cypress commands are asynchronous and do not return synchronous values. The pattern produces undefined behavior and masks the real problem: if the DOM state is unpredictable at that point, the test preconditions are wrong. Fix the setup so the state is deterministic.

Chaining off cy.contains() for functional assertions makes tests copy-dependent. cy.contains('Submit').click() breaks the moment the button label changes to "Save" or "Confirm". Use a data-cy attribute for interactive elements. Reserve cy.contains() for asserting that visible text content is correct, not for navigation.

Visiting external sites you do not control is architecturally wrong. Cypress runs in-browser and has meaningful constraints with cross-origin navigation. While cy.origin() extends support for multi-origin flows, driving a third-party login page you do not own is fragile: the external site can change its selectors, add CAPTCHAs, or throttle automation without warning. Stub or mock third-party auth in tests; reserve real external flows for narrow manual verification.

One giant test instead of focused tests makes failures unhelpful. A 200-step test that covers onboarding from registration through first purchase tells you something broke somewhere, not where. Small, focused tests with clear assertions at each step give you precise failure signals and faster reruns.

Assigning Cypress command return values to variables is a JavaScript misunderstanding that surfaces often. Cypress commands are enqueued, not immediately executed. Doing const text = cy.get('[data-cy=label]').invoke('text') does not give you the text synchronously. Use .then() callbacks or aliases to work with values from commands inside the queue.

Why even good selectors rot (and what to do about it)

Here is the part the official documentation does not address: you can follow every selector best practice and still face a maintenance crisis after a redesign.

data-cy attributes are stable, but only while the element they sit on exists. When a component is removed entirely, split into two, or replaced with a different implementation during a major UI overhaul, the attribute disappears with it. The selector is gone. The test fails. You have to find the new element and re-anchor the test. Multiply this across a suite of 200 specs after a component library migration and you have weeks of work.

This problem has sharpened recently because AI coding agents now rewrite markup faster than humans used to. A Cursor or Claude session can restructure a component, rename props, and change DOM hierarchy in minutes. The test suite that was green at 9am is broken at 11am, not because of a bug, but because the implementation changed in ways that were invisible to the test until CI ran.

Locator-level self-healing tools reduce flake from timing. They do not fix the problem when the markup itself changes. They guess a replacement selector. Sometimes the guess is right. Sometimes it clicks the wrong button and the suite stays green while your app is broken.

There is a conceptual distinction worth making here. Most "self-healing" tools operate at the locator level: when a selector fails, they try to find another element that looks similar and substitute it. That is useful for minor drift. It is a guess for structural changes. And it creates a different risk: a healed test that clicked the wrong thing reports a green build while an actual regression is live in production. For the deeper analysis of why locator-level healing has limits, the self-healing test automation overview covers the mechanism in detail.

Autonoma sits at a different level. Rather than maintaining selectors, it generates tests from the intended behavior of the app by reading the codebase. Four agents handle the work: the Planner reads routes, components, and user flows and generates test cases from code; the Executor runs those test cases against a live preview environment; the Reviewer classifies results into real bugs, agent errors, and test mismatches; and the Diffs Agent runs on every PR, analyzing code changes to add, deprecate, and update test cases as the codebase evolves. When the UI is restructured, the Diffs Agent does not guess a new selector. It reads the new code and regenerates the test from the updated behavior.

To be explicit about scope: Autonoma is not a Cypress plugin and is not built on Cypress. It is framework-agnostic. It does not replace your assertions library or your CI. It is a verification layer that generates and maintains the test suite alongside your code, so you are not manually repairing selectors after every component change.

For teams weighing the framework question directly, the Playwright vs Cypress comparison is a useful reference. For the locator maintenance problem specifically as it applies to Playwright, playwright-locators covers the same terrain in that ecosystem. If you have already decided to move off Cypress, the Cypress to Playwright migration guide covers the practical steps.

Diagram showing how data-cy selectors break when UI is restructured versus Autonoma regenerating tests from the new codebase behavior

A selector-level test guesses a replacement after a redesign; a behavior-level test regenerates from the new code.

FAQ

Use dedicated data attributes like data-cy or data-testid. These decouple tests from styling and JavaScript behavior, so refactoring CSS classes or JS events does not break your selectors. Avoid CSS class selectors, XPath, and text-based selectors as your primary strategy. They all couple tests to implementation details that change for reasons unrelated to user behavior.

Replace fixed cy.wait(ms) calls with cy.intercept() aliases and cy.wait('@alias') for network-dependent assertions. Let Cypress's built-in retry-ability do the work for DOM assertions: assert on the expected state rather than sleeping. Also ensure tests are fully independent with no shared state, use cy.session() to cache login, and give each test its own setup rather than relying on leftover state from the previous test.

Not with a fixed millisecond value. cy.wait(5000) is a hard sleep: it either wastes time when the app is fast or still fails when the app is slow. The right pattern is to intercept the request with cy.intercept(), alias it, then call cy.wait('@alias') to wait for the actual network response. For DOM-only assertions, let Cypress retry-ability handle the wait by asserting on the expected state.

Use cy.session() to cache and restore the authenticated session across tests and specs. cy.session() takes a unique ID and a setup function; Cypress runs the setup once, caches the session cookies and storage, and restores them on subsequent calls rather than re-running the login UI every time. This dramatically reduces suite runtime while keeping each test independent. For modern Cypress (v12+), testIsolation: true is on by default, so cy.session() is the correct way to share auth state without sharing DOM state.

Most post-redesign failures come from selectors tied to CSS classes, text content, or DOM structure rather than stable data attributes. Even correct data-cy selectors break when the element they target is removed, renamed, or restructured in a redesign. The underlying problem is that tests are maintained at the selector level rather than at the behavior level. Tools that self-heal at the locator level guess a replacement selector; Autonoma takes a different approach by generating tests from the intended behavior of the app and regenerating the suite when the codebase changes, so there is no selector to manually repair.

Use cy.intercept() for anything network-dependent: alias the request with cy.intercept('POST', '/api/save').as('saveCall'), trigger the action, then cy.wait('@saveCall') to wait for the real response. Reserve cy.wait(ms) only for animations with no detectable DOM side effect. Fixed millisecond waits are always either too slow or too short; intercept aliases wait exactly as long as the network takes.

Related articles

Three generations of automated E2E testing: record-and-replay, coded frameworks, and AI-native test agents on an evolution timeline

Automated E2E Testing: Tools, Frameworks, and the Shift to AI

Automated E2E testing in 2026: Selenium vs Cypress vs Playwright vs AI-native tools compared, plus a decision framework for CI/CD and preview environments.

Cypress alternatives comparison showing top E2E testing frameworks and modern web automation tools

8 Best Cypress Alternatives in 2026

Compare 8 Cypress alternatives: Playwright, Selenium, Puppeteer, and AI-powered tools. Code examples, pricing, and migration guides for 2026.

Quara the frog mascot inspecting a dark browser recording console with a codegen snapshot freezing in place while the live UI behind it continues to change

How to Use Playwright Codegen (and Why Recorded Tests Rot)

Complete guide to Playwright codegen: run the Inspector, record authenticated sessions, generate assertions in 5 languages, and avoid the test-rot trap.

Quara comparing a disconnected mocked-auth shortcut lane with a complete real-browser authentication lane containing blank login, callback, route-guard, and session props

Test Authentication Without Mocking: Mock vs Real

Should you mock authentication in tests? Mocking auth is fast but verifies your mock, not your login. Here's the honest decision framework for when real-browser auth testing is worth it.