Best Practices for Selectors — data-testid, role, text

8 min read

The previous lesson taught you how to select elements. This one teaches you what to select on. The choice of selector is the single biggest factor in whether your test suite stays green for a year or breaks every Tuesday after a designer pushes CSS changes. The good news: there's a clear ranking, and the top of it — data-testid — is universally recommended by the Cypress team and every other major framework.

The selector priority list

From most to least reliable, the eight selector strategies you'll meet in real codebases:

  1. data-testid / data-cy — purpose-built test attributes. Best.
  2. ARIA roles and labels[role='dialog'], [aria-label='Search']. Second best.
  3. Visible text via cy.contains — readable, semantic, but couples to copy.
  4. id — unique by spec but rarely added to interactive elements.
  5. name and type attributes — stable for form inputs.
  6. CSS class — varies with design refactors and CSS-in-JS hashes.
  7. Tag plus indexnav > ul > li:nth-child(3) — DOM-shape sensitive.
  8. XPath — not native to Cypress; needs a plugin; harder to read.

The numbers are illustrative — based on the kinds of breakages each selector style produces in real codebases. The shape of the chart is what matters: a small number of strategies are dramatically more reliable than the rest.

Why data-testid is the gold standard

data-testid (and the synonym data-cy Cypress's docs use interchangeably) is a custom HTML attribute with one job: identify an element for testing. Four properties make it the right default:

  1. It doesn't change with design. A designer renames .btn-primary to .button-cta and your CSS-class-based test breaks. The data-testid is invisible to the design system; it survives.
  2. Its purpose is explicit. Anyone who reads data-testid="checkout-submit" in the code knows it exists for tests. Developers won't accidentally remove it during a refactor; they'll move it to wherever the new submit button lives.
  3. It's grep-friendly. git grep 'data-testid="checkout-submit"' finds every reference in source and tests. Class-name searches drown in noise.
  4. It's framework-agnostic. React, Vue, Svelte, Angular, plain HTML — every templating engine renders custom data attributes the same way. Your selectors don't care what the app is built in.

A typical addition looks like:

// React
<button data-testid="checkout-btn" onClick={handleCheckout}>
  Checkout
</button>
 
// Vue
<button data-testid="checkout-btn" @click="handleCheckout">
  Checkout
</button>

The Cypress side is then trivial:

cy.get("[data-testid='checkout-btn']").click();

Add data-testid to anything a test interacts with: buttons, inputs, cards, dialog containers, error messages, status badges. Don't add them to every <div> — that's noise.

Working with your dev team

data-testid only works if the people who write the production code add them. The conversation is usually short:

  • "Adding data-testid makes our tests durable. They survive refactors. They tell you which elements have test coverage at a glance."
  • "The runtime cost is zero — it's a static attribute the browser ignores."
  • "It's the convention used by Cypress, Playwright, and React Testing Library. Industry-standard."

Most teams adopt it the first sprint someone proposes it. If yours is slower, lead by example: when you add a data-testid to a component you're testing, also include it in the PR description ("added test ID to support automated coverage of the checkout flow").

ARIA roles and accessibility-first selectors

The next-best option is selecting by ARIA role or label — the same attributes screen readers use:

cy.get("[role='dialog']");                  // a modal
cy.get("[role='button'][aria-label='Close']"); // close button on the modal
cy.get("[aria-labelledby='order-summary']");

Why this is good: ARIA attributes describe purpose (this element is a button, this one is a navigation landmark), not style (this one has rounded corners). They survive design refactors for the same reason data-testid does — they're tied to behaviour, not presentation. They also double as an accessibility hint: writing tests against ARIA roles is a quiet incentive to keep your app accessible.

If you're building a new test suite from scratch and the app is accessibility-conscious, you can lean on ARIA selectors heavily — sometimes skipping data-testid entirely. Most real apps mix the two.

When you can't add data-testid

You don't always control the source. Third-party widgets, vendor checkout forms, embedded chat clients, legacy code with a strict no-edit policy — all force you to use whatever the markup gives you.

The fallback hierarchy:

// 1. Stable HTML attributes — name, type
cy.get("input[name='email'][type='email']");
 
// 2. ARIA roles, even if the team didn't intend them for tests
cy.get("[role='button'][aria-label='Add to cart']");
 
// 3. Visible text via cy.contains
cy.contains("button", "Confirm purchase");
 
// 4. Combined attributes — least bad
cy.get("form.checkout input[autocomplete='cc-number']");

When the only available selector is class-based and unstable, the right move is often to file an internal ticket asking the team to add data-testid, mark the test as .skip until it's added, and ship the rest of the suite. Forcing fragile tests through under deadline is the recipe for the suite no one trusts.

The Selector Playground as a feedback loop

Chapter 1 introduced the Selector Playground (the crosshair icon in the runner). Use it as a measurement tool, not just a productivity tool. When the Playground suggests [data-testid='...'], your app is well-instrumented for testing. When it falls back to .css-1a2b3c > div:nth-of-type(2) > button, your app needs data-testids — and the test you're about to write is going to be flaky until they're added.

Anti-patterns to avoid

// ❌ Auto-generated CSS-in-JS class names — change every build
cy.get(".css-1a2b3c");
 
// ❌ Long descendant chains — break on any DOM restructure
cy.get(".container .row .col-6 .card .body .actions button");
 
// ❌ Absolute XPath — fragile and unreadable
cy.xpath("/html/body/div[3]/div/form/button[2]");
 
// ❌ nth-of-type / nth-child — break when an item is added or reordered
cy.get(".nav-item:nth-of-type(3) a");
 
// ❌ Index alone with no semantic anchor
cy.get("button").eq(7).click();

Each one of these will fail for a reason that has nothing to do with the feature being tested. They produce the worst kind of test failure: a red build, a minute of investigation, the realisation that the developer didn't break anything — the test did. Over months that erodes trust until the team starts ignoring red runs entirely.

A typed example with a custom command

A small abstraction worth picking up early — wrap data-testid access in a typed custom command so you stop typing the bracket syntax fifty times a day:

// cypress/support/commands.ts
declare global {
  namespace Cypress {
    interface Chainable {
      getByTestId(testId: string): Chainable<JQuery<HTMLElement>>;
    }
  }
}
 
Cypress.Commands.add("getByTestId", (testId: string) => {
  return cy.get(`[data-testid='${testId}']`);
});
 
export {};

Now the test reads cleanly:

cy.getByTestId("checkout-btn").click();
cy.getByTestId("cart-count").should("have.text", "1");
cy.getByTestId("error-message").should("not.exist");

Chapter 5 covers custom commands in depth; the Cypress commands cheat sheet and the XPath/CSS selectors cheat sheet on qa.codes are both useful one-page references when you're picking a selector for a tricky element.

⚠️ Common mistakes

  • Selecting on auto-generated class names from CSS-in-JS frameworks. Tools like styled-components and emotion produce class names like .css-1a2b3c that change on every build. A test using one of these is broken before the feature is even released. If the only stable attribute is one of these, ask for a data-testid instead.
  • Building a five-level descendant chain because one element doesn't have a stable selector. .parent().parent().siblings().find(...) "works" the day you write it and breaks the day a developer wraps anything in a new <div>. Add a data-testid to the actual target instead — five seconds of source-code change beats five hours of test maintenance.
  • Treating cy.contains("Submit") as bulletproof. Visible text changes — for translation, for clarity, for marketing. Tests using cy.contains for all selection get re-written every time the copy team revises the UI. Use cy.contains for user-visible assertions ("the page shows 'Order confirmed'") and data-testid for interaction targets.

🎯 Practice task

Audit a real app's selector quality and improve a test. 25-30 minutes.

  1. Open Sauce Demo (https://www.saucedemo.com) in the Cypress runner. Hover the Login button with the Selector Playground. Note that it suggests [data-test='login-button'] — this app is well-instrumented.
  2. Now open the Cypress example app (https://example.cypress.io). Hover any element. Note that the Playground falls back to ID, class, or tag-based selectors. Compare the two experiences.
  3. In your cypress/e2e/selectors.cy.ts, write a test that logs into Sauce Demo and adds the Sauce Labs Backpack to cart using only data-test attributes (Sauce Demo uses data-test, not data-testid — the principle is identical). Confirm every selector is [data-test='...'].
  4. Now write the same test using only CSS classes. The selectors will look like .login_button_container input.btn_action and similar. Run both. They probably both pass — today.
  5. Add the typed cy.getByTestId (or cy.getByDataTest for Sauce Demo's attribute name) custom command to cypress/support/commands.ts following the pattern in the lesson. Refactor the first test to use it. Confirm autocomplete works on cy.getByDataTest("...").
  6. Stretch: pick one element on the example.cypress.io site that has no stable selector. Write a test for it using cy.contains. Then write a one-line proposal for which data-testid you'd ask the team to add — e.g., <button data-testid="kitchen-sink-actions-btn">. This is the exact internal ticket you'd file at work.

You now know how to pick selectors that survive the calendar. The next lesson is back to commands — click, type, check, select, and the full set of interactions you'll wire to those selectors.

// tip to track lessons you complete and pick up where you left off across devices.