Verdict
No
No, do not find buttons, links and form fields by data-testid; find them by role and accessible name, and add a data-testid only to an element that has no role or name to query, such as a chart drawn on a canvas.
Why
- Blast radiususers
- Change frequencyregularly
- Detectabilityimmediately
- Reversibilitytrivial
- Test costmoderate
Do not find labelled controls by data-testid; find them by role and accessible name. My typical case is a customer-facing web app whose tests drive buttons and fields with a visible label. Blast radius is users and Change frequency is regularly, because these controls reach customers and change about once a month. Detectability is immediately: a role query fails in CI when the button goes, so a test ID catches nothing more. Reversibility is trivial, because a markup revert restores the element, and Test cost is moderate, because each ID is one more attribute to keep in sync. Rule R8 gives Do not test by test ID.
| When | Decision | Why |
|---|---|---|
| The element has no role or name to query, such as a chart that a third-party library draws on a canvas | Test minimally: put a data-testid on the chart's wrapper and assert in one test that the chart renders for sample data | Detectability moves to same-day, because no role query reaches the chart and only a user report shows it blank |
| An older screen has icon-only div controls with click handlers and no role or visible text | Test minimally: add a data-testid to each icon-only control of the main flow until the div becomes a labelled button | Detectability moves to same-day, because no query reaches these controls and only a user report shows a dead one |
| An icon-only button, such as Share, has its name only in aria-label and no flow test clicks it | Test minimally: add no data-testid; assert each such button's name with toHaveAccessibleName or the axe button-name rule | Detectability moves to eventually, because no test touches the button and sighted developers see only the icon |
| The control is the Pay button on the checkout page | Test mandatory: a checkout test on every change that finds Pay by role and name | Blast radius rises to money and Reversibility to costly, because a customer who cannot pay buys elsewhere |
| Every control already carries a data-cy attribute, so a test that uses one costs nothing extra | Do not use the data-cy attributes in new tests; write role queries and convert old tests when they break | Test cost falls to trivial, but Detectability stays immediately and Reversibility trivial, so the decision holds |
What breaks if you don't test
Nothing breaks when a labelled button has no test ID, because the role query fails when the button disappears. The failures that slip through come from test ID queries, which still pass after a label stops pointing to its input or a button becomes a div; screen reader users meet the break weeks later.
What you lose if you over-test
A component copied to a second screen brings its ID along, and Playwright locators are strict, so the test fails on two matches until someone renames one. The bigger loss is false confidence: the suite stays green while a control has lost its name. Match short names such as /save/i, not whole sentences that fail on every moved comma.
What to do instead
- Find controls with
getByRoleand a name, and fields withgetByLabel. The Testing Library query priority listsgetByTestIdlast, for cases where role or text cannot match. - For an Edit button in every table row, find the row first:
page.getByRole('row').filter({ hasText: 'Order 1042' }).getByRole('button', { name: 'Edit' }). - Use a test ID only for elements with no role or name: Playwright's getByTestId reads
data-testid, andtestIdAttributeswitches it todata-cy. - In Cypress, add Cypress Testing Library for role queries instead of the
data-cyselectors in the Cypress guide.
When the answer changes
- The element has no role and no name: a canvas, a map, or an icon-only
divcontrol with no text. - An icon-only button that no test clicks is named only in
aria-label. - The control takes money, as Pay does.
Real incident + Code example
The passenger form that said "edit text" eight times
On a travel booking product I worked on, a refactor moved the passenger form's input IDs to React's useId, but the labels kept their old hard-coded htmlFor values. All eight fields lost their accessible names and sounded the same to a screen reader. Our 94 Cypress tests found the fields by data-cy and passed. Seventeen days later a screen reader user wrote to support that the form announced "edit text" eight times. We moved the form tests to label queries:
// Before: passes whether or not a label names the input
cy.get('[data-cy="first-name"]').type('Ana');
// After: fails when no label is linked to the input
cy.findByLabelText('First name').type('Ana');
cy.findByRole('button', { name: 'Continue' }).click();
// A test ID stays where no role or name exists: the route map canvas.
// The map sets data-markers after drawing, so a blank canvas fails here
cy.get('[data-cy="route-map"]').should('have.attr', 'data-markers', '2');
Related questions
FAQ
- Should I add data-testid attributes for UI tests?
Add data-testid only to elements that tests cannot find by role, label or text, such as a chart drawn on a canvas. For buttons, links and form fields, query by role and accessible name.
- Is getByRole better than getByTestId?
getByRolecatches more bugs thangetByTestIdfor any element that has a role, because it fails when the element loses its role or its accessible name. UsegetByTestIdonly for an element with no role or name, such as a canvas chart.- Why does Cypress recommend data-cy attributes?
The Cypress guide recommends
data-cyattributes because they stay the same when text, CSS classes or markup change. Adata-cytest therefore passes after a control loses its label, so query by role wherever the element has one.- Should I remove data-testid attributes in production builds?
Keep data-testid attributes in production builds when your end-to-end tests run against the production build, because removing them breaks those tests. When every test runs against a development or staging build, keep the data-testid attributes as well, because one such as
data-testid="first-name"is 24 bytes of HTML. Strip them with the babel-plugin-react-remove-properties build plugin only when a page size budget requires it.