ESC

Type to search the knowledge base.

Test IDs as Last Resort

Why data-testid ranks last in Testing Library guidance — prefer roles and labels, and when a test id is still the right tool.

beginner3 min read
  • testing
  • test-ids

data-testid is easy: stab an attribute, query it, ship. It’s also how suites decouple from accessibility and allow unusable UI to pass. Testing Library’s guidance puts test ids at the bottom of the query priority list.

Docs: About Queries, Guiding principles.

Priority reminder

  1. getByRole
  2. getByLabelText / getByPlaceholderText
  3. getByText
  4. getByDisplayValue
  5. getByAltText / getByTitle
  6. getByTestId

If you need a test id, ask whether a user can find the control.

Bad default

<button data-testid="submit-btn">Save</button>
// test
screen.getByTestId('submit-btn');
// Better — forces accessible name
<button type="submit">Save</button>
screen.getByRole('button', { name: /save/i });

Missing accessible names fail the test — and fail real AT users.

When test ids are justified

Case Why
Icon-only control with name already set, but duplicate roles Disambiguate carefully — prefer name option first
Canvas / third-party widget No role semantics exposed
Performance: huge lists virtualized oddly Rare; still try roles
E2E across redesigns of text Prefer roles; test id as stable hook if copy changes weekly
Non-interactive markers for scroll containers data-testid="main-scroll"
// Icon button done right first
<button type="button" aria-label="Close">
  <CloseIcon />
</button>
screen.getByRole('button', { name: /close/i });

Test id only if the design system forbids labels (it shouldn’t).

Naming conventions if you use them

data-testid="checkout-submit"
data-testid="user-row-{id}"
  • Stable, semantic, not CSS leftovers
  • Don’t encode styles (button-red-left)
  • Strip from production if you care about bytes (usually negligible)

E2E note

Playwright recommends roles/labels too. page.getByTestId exists for the same last-resort cases.

await page.getByRole('button', { name: 'Pay now' }).click();

Footguns

  1. Test ids on every node “for convenience.”
  2. Querying test id when role fails — fixing the a11y bug.
  3. Duplicate test ids.
  4. Asserting only on test id existence, not behavior.

Interview out-loud answer

“I query by role and label so tests mirror users and catch a11y gaps. data-testid is last resort for non-semantic or third-party surfaces. If getByRole fails, I fix the UI name first.”

Migration strategy

If a legacy suite is test-id heavy, migrate opportunistically: when you touch a test, switch queries to role/label and delete the id if unused. Don’t big-bang rewrite 2,000 tests — but stop adding new test ids for buttons that already have names.

Extra practice

Write a minimal demo in a scratch file or the playground: one happy path, one failure path, and one boundary input. If you cannot exhibit a bug that the pattern prevents, you do not own the concept yet — re-read the primary docs linked below and tighten the example until the failure is obvious.

Notes from real codebases

Teams that succeed here keep the rules mechanical: lint where possible, CI for the rest, and a short human checklist for what automation cannot see. Document exceptions with an owner name and a removal date so “temporary” escapes do not become permanent architecture.

Implementation notes

Wire this into real code on the next feature, not only a demo. Prefer the smallest change that encodes the rule — a shared helper, a lint rule, or a checklist item in the PR template. Revisit after a week of production traffic: if users or tests still hit the failure mode, the documentation (and the abstraction) are not sharp enough yet.

Further reading

Related guides