data-testid isn't a test smell. Brittle tests are.
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 landmarks — getByRole('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 accessibility — expect(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
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.
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.