data-testid Is Not a Smell. Misusing It Is.

data-testid gets a bad reputation.

Some people say it’s lazy. Others say it’s an anti-pattern.

Both are wrong.

data-testid is a tool. Like any tool, it’s either used with intent or abused.

Let’s be honest about when it’s the right choice and when it’s not.


What data-testid actually is

data-testid is a testing-only attribute added to the DOM.

Example:

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

Used in Playwright:

page.getByTestId('save-btn')

That’s it. Nothing magical.


Why people hate data-testid

The usual arguments:

  • “Tests should use what users see”
  • “It couples tests to implementation”
  • “It pollutes the markup”

These complaints come from bad usage, not the attribute itself.


The real problem: unstable selectors

Most flaky tests don’t fail because of Playwright.

They fail because selectors are tied to:

  • DOM structure
  • CSS classes
  • Layout
  • Styling refactors

Example of a bad selector:

page.locator('div > button.primary > span').click()

This breaks when anything visual changes.


Where data-testid shines

Use data-testid when:

1. There are multiple identical elements

Example:

  • Multiple “Save” buttons
  • Same label, different contexts
page.getByTestId('profile-save-btn')

Clear. Unambiguous.


2. Accessibility selectors are not possible

Not every app is perfectly accessible.

If you can’t rely on:

getByRole
getByLabel

data-testid is better than brittle DOM hacks.


3. The element is not user-facing

Icons, loaders, skeletons, hidden triggers.

<div data-testid="loading-spinner" />

Users don’t interact with these. Tests still need to.


When you should NOT use data-testid

Do not use it when:

❌ A semantic selector works

If this works:

page.getByRole('button', { name: 'Save' })

Use it.

It’s closer to user behavior.


❌ You’re replacing bad DOM design

If everything needs data-testid, that’s a UI problem.

data-testid is not a substitute for:

  • proper labels
  • roles
  • accessibility

data-testid vs accessibility selectors

This is the honest hierarchy:

  1. getByRole / getByLabel
  2. getByTestId
  3. Text selectors (carefully)
  4. CSS / XPath (last resort)

data-testid is not last.

It’s second.


Naming data-testid properly (important)

Bad:

data-testid="button1"
data-testid="save"

Good:

data-testid="profile-save-btn"
data-testid="article-publish-button"

Rules:

  • Describe intent
  • Be stable
  • Don’t encode layout or position

Why strict mode makes data-testid valuable

Playwright strict mode fails fast:

strict mode violation: locator resolved to 2 elements

This is good.

data-testid gives you guaranteed uniqueness without hacks like .first().


The real takeaway

data-testid is not cheating.

It’s declaring a testing contract.

You’re saying:

“This element matters for tests. Don’t accidentally break it.”

That’s responsible engineering.


Final opinion

If your test breaks when the UI still works, the selector is wrong.

Sometimes the cleanest fix is:

page.getByTestId('save-btn')

No shame in that.

Write tests for behavior.

Use selectors that survive change.