The most surprising truth about writing stable Cypress selectors is that the best selectors often aren’t the ones you’d pick if you were just trying to find an element on a page. They’re designed to be resilient to the very things that break typical selectors.

Let’s see this in action. Imagine we have a simple user profile card:

<div class="profile-card" data-testid="user-profile-card">
  <h2 class="profile-name" data-testid="user-name">Alice Wonderland</h2>
  <p class="profile-email" data-testid="user-email">alice@example.com</p>
  <button class="profile-edit-button" data-testid="edit-button">Edit</button>
</div>

A naive approach might use CSS classes:

// Brittle selector!
cy.get('.profile-card .profile-name');
cy.get('.profile-email');
cy.get('.profile-edit-button');

But what happens if the UI team decides to change the class names? profile-card becomes user-info-block, profile-name becomes user-display-name, and profile-email is now contact-details-email. Suddenly, all your tests fail.

The problem isn’t Cypress; it’s that we’re coupling our tests to implementation details (CSS classes) that are free to change.

The core principle of stable selectors is to use attributes that are intended to be stable and meaningful to the application’s logic, not its presentation. The most robust approach is to leverage data-* attributes, specifically ones that act as unique identifiers for your application’s components or data.

Cypress has first-class support for data-* attributes, making them incredibly easy to use. Instead of relying on brittle class names, we use data-testid:

// Stable selectors!
cy.get('[data-testid="user-profile-card"]');
cy.get('[data-testid="user-name"]');
cy.get('[data-testid="user-email"]');
cy.get('[data-testid="edit-button"]');

Why does this work? Because data-testid attributes are not tied to CSS or styling. They are purely for testing purposes. When the UI team refactors the CSS, these data-testid attributes remain untouched, and your tests continue to pass.

Think about the mental model: our tests are no longer looking at "a div with class 'profile-card' that contains an h2 with class 'profile-name'". Instead, they are looking for "the profile card component, identified by data-testid='user-profile-card', and within that, the element representing the user’s name, identified by data-testid='user-name'." This is a much more robust description of what we’re interacting with, rather than how it’s currently styled.

The power of data-* attributes extends beyond simple identification. You can use them to target specific states or variations of an element. For instance, if you have a disabled button, you might add a data-testid="save-button" and a data-disabled attribute:

<button data-testid="save-button" data-disabled>Save</button>

Your Cypress command would then be:

cy.get('[data-testid="save-button"][data-disabled]');

This allows you to assert that the button is indeed disabled, without relying on the presence or absence of a disabled HTML attribute or a specific CSS class that might indicate a disabled state.

When you’re working with lists or dynamic content, you can combine data-testid with other selectors or even text content to pinpoint specific items. For example, to find a list item with specific text:

<ul class="items-list">
  <li data-testid="list-item" data-item-id="123">Apple</li>
  <li data-testid="list-item" data-item-id="456">Banana</li>
  <li data-testid="list-item" data-item-id="789">Cherry</li>
</ul>

You could target the "Banana" item like this:

cy.get('[data-testid="list-item"]').contains('Banana');

Or, if you know the data-item-id is stable:

cy.get('[data-testid="list-item"][data-item-id="456"]');

This latter approach is generally more robust if the data-item-id is a stable, meaningful identifier for that specific data record.

A common pitfall is to over-rely on the :nth-child() or :first-child pseudo-classes. While they can work, they are incredibly fragile. If another element is added before the one you’re targeting, your :first-child selector will suddenly point to the wrong element. Always prefer explicit, stable identifiers.

The key takeaway is to treat data-testid (or any data-* attribute you designate for testing) as your primary selector strategy. Integrate it into your development workflow: when a new interactive element is added, assign it a data-testid. This small upfront investment pays massive dividends in test stability.

If you find yourself needing to select an element and the only unique thing about it is its visible text and its position in a list, consider adding a data-testid to it. It’s much better to have a stable identifier that’s slightly more verbose than a selector that breaks every time the layout or styling changes.

Once you’ve mastered stable selectors, the next challenge is understanding how to effectively chain commands to build complex interactions without creating brittle test sequences.

Want structured learning?

Take the full Cypress course →