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

Selectors That Keep Cypress UI Tests Stable (data-* Attributes, Roles, and Text)

Capítulo 3

Estimated reading time: 8 minutes

+ Exercise

1) Selector stability principles

Stable Cypress UI tests depend on selectors that reflect user-facing meaning (what the element is for) rather than implementation details (how it is laid out). Layout and styling change frequently; intent changes less often. A stable selector strategy aims to:

  • Prefer intent over structure: target elements by purpose (e.g., “Submit order” button) instead of DOM position.
  • Be resilient to CSS/layout refactors: avoid selectors tied to class names used for styling, nested containers, or grid structure.
  • Be unique and explicit: a selector should identify one element in the current view; ambiguity leads to flaky tests.
  • Match how users interact: roles, labels, and accessible names often align with real user actions and are less likely to change than markup structure.
  • Keep selectors close to the feature: add test hooks (data-* attributes) on interactive elements and key state indicators.

In practice, you’ll usually combine a stable query with an assertion that confirms you found the right element (e.g., it’s visible, enabled, has expected text). This reduces false positives when the UI changes.

2) Preferred selector order

Use a consistent priority order so the whole team writes tests the same way. A practical order that resists CSS/layout changes is:

A. data-* attributes (data-cy / data-testid)

Best for stability because they are dedicated test hooks and can remain unchanged even if the UI is redesigned. Put them on interactive elements (buttons, inputs, links) and important state containers (alerts, toasts, tables).

// HTML (app code)
<button data-cy="checkout-submit">Place order</button>

// Cypress
cy.get('[data-cy="checkout-submit"]').click()

If your org already uses data-testid, keep it; don’t mix multiple conventions without a plan.

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

B. Semantic roles and labels (accessible queries)

When you can’t add a data attribute (third-party components, legacy code), prefer selectors based on semantics: labels, ARIA attributes, and roles. These are tied to accessibility and user intent.

If you use @testing-library/cypress, you can query by role/label in a user-centric way:

// Example with Testing Library commands
cy.findByRole('button', { name: /place order/i }).click()
cy.findByLabelText(/email/i).type('user@example.com')

Without Testing Library, you can still use stable label associations:

// HTML
<label for="email">Email</label>
<input id="email" name="email" />

// Cypress (fallback)
cy.get('label[for="email"]').should('contain', 'Email')
cy.get('#email').type('user@example.com')

Prefer for/id label connections and meaningful aria-label or aria-labelledby over brittle DOM traversal.

C. Minimal fallback options (only when necessary)

Use these when you cannot add test hooks and semantic queries aren’t available:

  • Stable IDs (only if they are not generated and not reused): #billing-zip
  • Form attributes: input[name="email"]
  • Text-based selection (carefully): cy.contains('button', 'Save') when the text is stable and not duplicated
// Minimal fallback examples
cy.get('input[name="email"]').type('user@example.com')
cy.contains('button', /^Save$/).should('be.enabled').click()

When using text, reduce ambiguity by scoping to a container:

cy.get('[data-cy="profile-form"]').within(() => {
  cy.contains('button', /^Save$/).click()
})

3) Brittle selectors to avoid (and why)

These selectors often break after harmless refactors (wrapping elements, changing grid layout, renaming classes, adding a new item to a list).

A. Deep CSS chains

// Avoid: depends on exact nesting and class names
cy.get('.checkout-page .right-column .summary .actions button.primary').click()

Why it’s brittle: any wrapper div, class rename, or layout change breaks the chain.

Better:

cy.get('[data-cy="checkout-submit"]').click()

B. nth-child / positional selectors

// Avoid: breaks if a new item is inserted or order changes
cy.get('ul.products > li:nth-child(3) button').click()

Better: select by a stable identifier on the item, or by its name within a scoped container.

// HTML
<li data-cy="product" data-product-id="sku-123">
  <h3>Coffee Beans</h3>
  <button data-cy="add-to-cart">Add</button>
</li>

// Cypress
cy.get('[data-cy="product"][data-product-id="sku-123"]').within(() => {
  cy.get('[data-cy="add-to-cart"]').click()
})

C. Styling classes (especially from CSS frameworks)

// Avoid: classes are for styling and change often
cy.get('.btn.btn-primary.w-full.rounded-lg').click()

Better:

cy.get('[data-cy="save-profile"]').click()

D. Traversal-heavy selectors

// Avoid: fragile traversal based on current markup
cy.contains('Email').parent().next().find('input').type('user@example.com')

Better: connect label to input via id/for, or add a test hook.

cy.get('[data-cy="email"]').type('user@example.com')

4) Implementing a consistent selector convention across the app

Stability improves when the app and tests agree on a simple convention. Decide on one attribute name and a naming scheme, then apply it consistently.

A. Choose one attribute: data-cy (recommended) or data-testid

  • data-cy: common in Cypress projects and easy to read in tests.
  • data-testid: common across multiple test tools; fine if already established.

Pick one and standardize it in code review.

B. Naming rules that scale

Use names that describe intent and location, not styling. A practical pattern is {feature}-{element}-{action} (or similar), for example:

  • login-email, login-password, login-submit
  • cart-item, cart-remove, cart-checkout
  • profile-save, profile-cancel

For repeated items, add a stable identifier via an additional attribute (not by index):

// HTML
<tr data-cy="order-row" data-order-id="100045">...</tr>

// Cypress
cy.get('[data-cy="order-row"][data-order-id="100045"]').click()

C. Where to place test hooks

  • Interactive controls: buttons, links, inputs, selects, toggles.
  • State indicators: error messages, success toasts, loading spinners, empty states.
  • Containers for scoping: forms, dialogs, side panels, tables.

Prefer placing hooks on the element you interact with (e.g., the actual <button>), not only on a wrapper.

D. Guardrails: avoid coupling to copy unless it’s intentional

Text changes are common (UX tweaks, localization). If your product is localized, text-based selectors can become brittle. In that case, rely more on data-cy and roles/labels that are stable across locales (or use stable aria-label keys if your team supports that).

5) Create small helper commands for common queries

Helper commands reduce repetition and make your selector strategy easy to follow. Keep them small and predictable.

A. A getBySel helper (data-cy)

// cypress/support/commands.js
Cypress.Commands.add('getBySel', (value, ...args) => {
  return cy.get(`[data-cy="${value}"]`, ...args)
})
// Usage
cy.getBySel('login-email').type('user@example.com')
cy.getBySel('login-submit').should('be.enabled').click()

B. A getBySelLike helper (prefix/suffix matching)

Useful for lists where you encode IDs into the attribute value (only if you can’t add a separate data attribute).

// cypress/support/commands.js
Cypress.Commands.add('getBySelLike', (partial, ...args) => {
  return cy.get(`[data-cy*="${partial}"]`, ...args)
})
// Usage
cy.getBySelLike('order-row-100045').click()

C. A withinBySel helper for scoping

// cypress/support/commands.js
Cypress.Commands.add('withinBySel', (value, fn) => {
  return cy.get(`[data-cy="${value}"]`).within(fn)
})
// Usage
cy.withinBySel('profile-form', () => {
  cy.getBySel('profile-first-name').clear().type('Sam')
  cy.getBySel('profile-save').click()
})

D. Optional: add a role-based helper if you use Testing Library

// cypress/support/commands.js
Cypress.Commands.add('clickButton', (name) => {
  return cy.findByRole('button', { name }).click()
})
// Usage
cy.clickButton(/place order/i)

Practice exercises: rewrite unstable selectors into stable ones

Exercise 1: Replace deep CSS chain with data-cy

Unstable selector:

cy.get('.settings-page .card:nth-child(2) .actions .btn-primary').click()

Task: Add a test hook to the actual button and update the test.

App code (example change):

<button data-cy="settings-save" class="btn-primary">Save</button>

Stable test + robust assertions:

cy.getBySel('settings-save')
  .should('be.visible')
  .and('not.be.disabled')
  .click()

Exercise 2: Replace nth-child with a stable identifier

Unstable selector:

cy.get('table tbody tr:nth-child(1) .delete').click()

Task: Target the row by order id and click delete within it.

App code (example change):

<tr data-cy="order-row" data-order-id="100045">
  <td>100045</td>
  <td><button data-cy="order-delete">Delete</button></td>
</tr>

Stable test + robust assertions:

cy.get('[data-cy="order-row"][data-order-id="100045"]').within(() => {
  cy.getBySel('order-delete')
    .should('be.visible')
    .click()
})
cy.get('[data-cy="toast"]')
  .should('be.visible')
  .and('contain', 'deleted')

Exercise 3: Replace traversal-heavy label lookup with a direct hook or label association

Unstable selector:

cy.contains('Email').parent().find('input').type('user@example.com')

Task A (preferred): Add data-cy to the input.

// HTML
<input data-cy="login-email" id="email" name="email" />

// Test
cy.getBySel('login-email')
  .should('have.attr', 'name', 'email')
  .type('user@example.com')

Task B (if you can’t add hooks): Use label-for and id.

cy.get('label[for="email"]').should('contain', 'Email')
cy.get('#email').type('user@example.com')

Exercise 4: Replace ambiguous text selection with scoped selection

Unstable selector:

cy.contains('Save').click()

Problem: multiple “Save” buttons (profile form, billing form, modal) can exist.

Task: Scope to the correct container and assert the container is the one you expect.

cy.getBySel('billing-form')
  .should('be.visible')
  .within(() => {
    cy.contains('button', /^Save$/)
      .should('be.enabled')
      .click()
  })

Exercise 5: Validate robustness after minor DOM changes

Simulate a minor DOM change mentally: a new wrapper div is added, or an extra help text paragraph is inserted. Your selector should still work if it targets a hook/role rather than structure.

Given stable selector:

cy.getBySel('checkout-submit').click()

Add a robust post-click assertion that doesn’t depend on layout:

cy.getBySel('checkout-submit').click()
cy.getBySel('order-confirmation')
  .should('be.visible')
  .and('contain', 'Order')
cy.location('pathname').should('match', /\/orders\//)

Now answer the exercise about the content:

Which selector approach best keeps Cypress UI tests stable when the UI layout and CSS classes are refactored?

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

You missed! Try again.

Dedicated data-* attributes are designed as stable test hooks and are resilient to layout/CSS refactors, unlike deep chains or positional selectors that break when structure changes.

Next chapter

Writing Your First Maintainable Cypress Tests (Arrange–Act–Assert and Clear Assertions)

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