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.
- 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
getByRolegetByLabelText/getByPlaceholderTextgetByTextgetByDisplayValuegetByAltText/getByTitlegetByTestId
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
- Test ids on every node “for convenience.”
- Querying test id when role fails — fixing the a11y bug.
- Duplicate test ids.
- 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.
Related on this site
- Testing Library queries priority
- Testing Library
- Accessible names computation
- user-event vs fireEvent
- End-to-end testing tradeoffs
Further reading
Related guides
- Accessibility Testing in CIWire axe and lint rules into CI without false confidence — what automation catches, what it misses, and a practical pipeline.
- Component Testing StorybookUse Storybook as a component workshop and optional test runner — stories as living specs, interaction tests, and a11y checks.
- Contract Testing APIsKeep frontend and backend agreements honest — schema contracts, Pact-style consumer tests, and OpenAPI-driven checks without brittle E2E.
- Coverage Metrics PitfallsWhy 100% line coverage can still ship bugs — gaming metrics, useless tests, and what coverage is actually good for.
- End-to-End Testing TradeoffsWhen E2E tests earn their keep, why they flake, and how to keep a small critical-path suite instead of a slow second frontend.