Back to Blog
On this page5 sections

// opinion

data-testid isn't a test smell. Brittle tests are.

qa.codesqa.codes · 3 February 2026 · 7 min read
Intermediate
cypressplaywrightselectors

There's a take going around that data-testid 'couples tests to implementation.' It's exactly backwards — data-testid is the only selector explicitly decoupled from implementation. This is the post for the 'but ARIA roles are better!' crowd.

What 'coupling to implementation' actually means

Coupling means your test breaks when you change something that isn't the behaviour under test. A test that fails because you renamed a CSS class is coupled to implementation. The test's job was to verify the user can log in — it shouldn't care that the button now has class btn-primary instead of submit-btn.

Let's rank selectors by how often they break on implementation changes:

CSS classes — highest coupling. They change constantly: design system updates, refactors, BEM renaming, utility-first migrations. A test using .submit-btn breaks the moment someone runs a class rename in the design system PR. That's the definition of implementation coupling.

XPath by position — also high coupling. //div[3]/button[1] breaks when layout changes.

Text content — medium coupling. cy.contains('Submit') breaks when copy changes. Copy changes are sometimes intentional (and then a failing test is correct) and sometimes accidental (marketing fix). Text selectors require human judgement about whether the failure is meaningful.

ARIA roles and labels — lower coupling, but not zero. getByRole('button', { name: 'Submit' }) breaks when the accessible name changes. Which might be the right behaviour — accessibility is behaviour. But it also breaks when someone normalises whitespace in a label or refactors from a <button> to an ARIA-role'd <div> (don't do that, but people do).

data-testid — lowest coupling. It changes only when you explicitly change the testing contract. Nobody does that by accident.

Why ARIA-role selectors aren't always the answer

The Testing Library and Playwright recommendations lean heavily on ARIA roles. getByRole('button', { name: 'Submit' }) is good when it works. The problem is "when it works."

Data tables with multiple similar buttons — a table of 50 items where each row has "Edit" and "Delete" buttons. getByRole('button', { name: 'Edit' }) matches 50 elements. You need additional context to target one row. At that point you're writing something like page.getByRole('row', { name: 'Alice' }).getByRole('button', { name: 'Edit' }) — which is verbose, fragile to row ordering, and harder to read than [data-testid="edit-user-alice"].

Ambiguous landmarksgetByRole('main') assumes there's exactly one <main> element. Most well-structured apps have one, but nested router views and modal portals sometimes create multiple landmark regions. You'll get a "strict mode violation" error.

Label collisions — multiple form fields with the same label text. This happens in multi-step wizards and repeated field sets. ARIA-role selectors choke on it.

None of this means ARIA selectors are bad. It means they're not universally applicable, and the "use ARIA always" camp oversimplifies.

The case for data-testid: explicit contract, refactor-safe, framework-neutral

data-testid (or data-cy, data-e2e, whatever naming convention you pick) is an explicit testing contract between the component author and the test author. When you add data-testid="submit-button" to a button, you're saying: "this button is a test anchor. Don't remove this attribute without updating the tests."

That explicitness is a feature. It makes the testing surface visible in the markup. It survives:

  • Component library upgrades
  • CSS refactors
  • Accessible name changes (rebranding, copy tweaks)
  • React-to-framework migrations
  • Server-side rendering vs client-side hydration differences

It's also framework-neutral. The same selector works in Cypress, Playwright, Selenium, and any future tool you migrate to.

The case against ARIA-only: accessibility tree changes are silent failures

Here's the argument that the ARIA-only camp doesn't address: the accessibility tree is maintained by humans writing JSX. It's as fallible as any other human-maintained contract.

When a developer changes <button aria-label="Close dialog"> to <button aria-label="Dismiss"> for copy consistency, getByRole('button', { name: 'Close dialog' }) fails — silently, in a test file nobody touches for weeks, until the next CI run. The test failure is correct (the accessible name changed), but the change was intentional copy work, not a regression.

Meanwhile [data-testid="close-dialog"] still works. The testing contract didn't change; only the label did.

The counter-argument is that accessibility regressions should surface in tests. I agree. Use ARIA assertions to verify accessibilityexpect(button).toHaveAccessibleName('Close dialog'). Use data-testid for selection. Separate the concerns.

The pragmatic rule

Here's what I actually do:

Use data-testid for nodes where the test anchor is the primary purpose — modals, form submissions, dynamic list items, table actions. These are nodes you'd add a test ID to and never reference in CSS.

Use ARIA roles when the element is already semantically unambiguous and there's no data-testid. getByRole('heading', { level: 1 }) is fine for the page title. getByRole('navigation') is fine if there's exactly one nav landmark.

Use text content selectors when the text is stable, user-facing, and the test is verifying that specific text. "Assert that the confirmation message says 'Order confirmed'" is a good text assertion. "Find the submit button by its label" is usually not.

The "ARIA or nothing" rule adds friction to test writing without proportional quality gain. Write tests that verify real user behaviour. Use whichever selector gets you there reliably. data-testid is a professional tool, not a shortcut.


// related

Opinions·24 April 2026 · 6 min read

You probably don't need a Page Object Model

POM was a Selenium-era solution to a Selenium-era problem. In modern Cypress and Playwright, custom commands and locator helpers cover 90% of what POM was supposed to give you.

patternspage-object-modelcypress
Tutorials·10 May 2026 · 7 min read

Custom Cypress commands that actually pay off

Most teams over-abstract too early. Four custom commands are worth writing on every Cypress project — login, seed, intercept, visit. The rest can wait.

cypresstypescriptpatterns