Skip to content
TkDodo's blog

Test IDs are an a11y smell

Mar 23, 2026 — Design System, Testing, A11y, ReactJs
Test IDs are an a11y smell
Photo by Steve Harvey

I’ve come across a lot of articles lately claiming that using data-testid is the best way to define selectors in your tests. Apparently, they simplify element selection, ensure maintainability and stability and decouple your tests from UI changes.

I couldn’t disagree more.

I haven’t used a data-testid attribute in over a decade, so I’m surprised these takes are still around. I used to think that it comes from a time when our only alternatives were either using hardcoded ids, or classNames, or xpath selectors. If that are your only options (e.g. because you test with something like selenium (opens in a new window)), then data-testid might look promising because everything else is so much worse. In the land of the blind, the one-eyed man is king.

Testing Library

But the times are changing, and we have better options now. Testing Library (opens in a new window) bets on role-based selectors and can be used with almost any test runner or framework. One notable exception is playwright (opens in a new window), but they have their own role-based selectors built-in.

Its guiding principle is:

The more your tests resemble the way your software is used, the more confidence they can give you.

That aligns with how I like to think about testing. We don’t want to be testing implementation details. Fewer mocks are better, do mostly integration tests (opens in a new window).

If we focus our tests on what the user can see and interact with, we get the best bang for our buck: We’re free to refactor internals, layout things differently or change how API calls are made without having to change our tests. That’s maintainability.

It’s also the only thing that matters at the end of the day: Can our users use our application? We gain nothing from having 100% unit test coverage on our formatting utils if our page crashes after an API call. 🤷‍♂️

The question is, why are role-based selectors better than the alternatives? Here’s the thing:

Users can’t see test IDs.

So whenever we use a data-testid to query an element, we are violating that guiding principle. That alone is not a good reason though. Principles have their right to exist, but we also have the right to ignore them if we know what we’re doing. Sticking to principles like DRY (opens in a new window) “just because” is not good enough.

Accessibility

Again, what matters is that users can interact with our apps. ALL users. Laws like the European Accessibility Act (opens in a new window) or the Americans with Disabilities Act (opens in a new window) require us to take this topic seriously, demanding WCAG 2.1 AA (opens in a new window) compatibility.

Getting a11y right is hard. Using primitives like react-aria (opens in a new window) that focus on first-class accessibility is very helpful and I wouldn’t recommend building components without such a library. But still, it doesn’t fully stop us from getting things wrong. And fact is, most teams don’t do explicit a11y testing.

Example

The most common example shown with test IDs something like this:

Basic Example
function WidgetDialogTrigger({ onClick }: Props) {
return (
<button data-testid="widget-dialog-trigger" onClick={onClick}>
Open Widget
</button>
)
}

Now let’s assume we want to click that button in a test, so we do:

Test That Button
screen.getByTestId('widget-dialog-trigger').click()

This “works” and seems easy enough, but the way we are actually interacting with that element is not how our Users would do it. We can easily replace our button with a clickable div (yuck) and our test still works:

Oh no
function WidgetDialogTrigger({ onClick }: Props) {
return (
<div data-testid="widget-dialog-trigger" onClick={onClick}>
Open Widget
</div>
)
}

That’s bad because now that “button” is not keyboard accessible and doesn’t have the correct semantic role, so screen readers may not announce it properly. It’s just another div in our soup of divs.

Role-based selectors

Here’s where role-based selectors come in helpful. If we had written our test like this, it wouldn’t pass the div-with-a-click-handler:

Role-based selectors
screen.getByRole('button', { name: 'Open Widget' }).click()

If we’re using role-based selectors in our tests, we get a couple of things almost for free:

To be blunt: If you can’t identify an element in your app with a role-based selector, you’re doing something wrong in your markup. Semantic HTML goes a long way.

Examples

The way I like to approach writing tests with role-based selectors is talking myself through what I’m doing when I’m clicking the steps myself, then I try to get that into selectors. For example:

I’m clicking the Dashboards Link in the Sidebar

within(screen.getByRole('navigation'))
.getByRole('link', { name: 'Dashboards' })
.click()

Let me click the Confirm Button in the Save Dialog

within(screen.getByRole('dialog', { name: 'Save' }))
.findByRole('button', { name: 'Confirm' })
.click()

I’m filling out the Email Field in the Registration Form

userEvent.type(
within(
screen.getByRole('form', { name: 'Registration' }),
).findByRole('textbox', { name: 'Email' }),
)

Getting there

If those selectors don’t work, I know I have to change my app because it’s not accessible enough. If you’re struggling with knowing how to make that possible, here are a couple of hopefully helpful tips that I’ve used in the past:


The bottom line is: If your tests can’t find it with a role-based selector, some of your users probably can’t either. In those cases, it’s better to fix the UI rather than work around it in the test by adding a data-testid.


That’s it for today. Feel free to reach out to me on bluesky (opens in a new window) if you have any questions, or just leave a comment below. ⬇️

Like the monospace font in the code blocks?

Check out monolisa.dev

Bytes - the JavaScript Newsletter that doesn't suck