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 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-submitcart-item,cart-remove,cart-checkoutprofile-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\//)