|
| 1 | +--- |
| 2 | +title: Test IDs are an a11y smell |
| 3 | +description: Users don't use data-testid, so why do your tests? |
| 4 | +date: 2026-03-23 |
| 5 | +banner: ./smell.jpg |
| 6 | +tags: |
| 7 | + - Design System |
| 8 | + - Testing |
| 9 | + - A11y |
| 10 | + - ReactJs |
| 11 | +--- |
| 12 | + |
| 13 | +import Attribution from '@components/Attribution' |
| 14 | +import Comments from '@components/Comments.astro' |
| 15 | +import { DsToc } from '@components/ds-toc' |
| 16 | +import Translations from '@components/Translations' |
| 17 | +import Highlight from '@components/Highlight' |
| 18 | +import Emph from '@components/Emph' |
| 19 | +import Aside from '@components/Aside' |
| 20 | +import { VerticalRuler } from '@components/VerticalRuler' |
| 21 | + |
| 22 | +<Attribution |
| 23 | + name="Steve Harvey" |
| 24 | + url="https://unsplash.com/@trommelkopf" |
| 25 | +/> |
| 26 | + |
| 27 | +<DsToc id="test-ids-are-an-a11y-smell" /> |
| 28 | + |
| 29 | +<Translations translations={[]} /> |
| 30 | + |
| 31 | +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. |
| 32 | + |
| 33 | +I couldn't disagree more. |
| 34 | + |
| 35 | +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](https://www.selenium.dev/)), 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. |
| 36 | + |
| 37 | +## Testing Library |
| 38 | + |
| 39 | +But the times are changing, and we have better options now. [Testing Library](https://testing-library.com/) bets on role-based selectors and can be used with almost any test runner or framework. One notable exception is [playwright](https://playwright.dev/), but they have their own role-based selectors built-in. |
| 40 | + |
| 41 | +Its guiding principle is: |
| 42 | + |
| 43 | +<Highlight> |
| 44 | + The more your tests resemble the way your software is used, the more |
| 45 | + confidence they can give you. |
| 46 | +</Highlight> |
| 47 | + |
| 48 | +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](https://x.com/rauchg/status/807626710350839808). |
| 49 | + |
| 50 | +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 <Emph>maintainability</Emph>. |
| 51 | + |
| 52 | +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. 🤷♂️ |
| 53 | + |
| 54 | +The question is, why are role-based selectors better than the alternatives? Here's the thing: |
| 55 | + |
| 56 | +<Emph>Users can't see test IDs.</Emph> |
| 57 | + |
| 58 | +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](https://www.deconstructconf.com/2019/dan-abramov-the-wet-codebase) "just because" is not good enough. |
| 59 | + |
| 60 | +## Accessibility |
| 61 | + |
| 62 | +Again, what matters is that users can interact with our apps. ALL users. Laws like the [European Accessibility Act](https://commission.europa.eu/strategy-and-policy/policies/justice-and-fundamental-rights/disability/european-accessibility-act-eaa_en) or the [Americans with Disabilities Act](https://www.ada.gov/resources/web-guidance/) require us to take this topic seriously, demanding [WCAG 2.1 AA](https://www.w3.org/TR/WCAG21/) compatibility. |
| 63 | + |
| 64 | +Getting a11y right is hard. Using primitives like [react-aria](https://react-aria.adobe.com/) 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. |
| 65 | + |
| 66 | +## Example |
| 67 | + |
| 68 | +The most common example shown with test IDs something like this: |
| 69 | + |
| 70 | +```tsx title="Basic Example" |
| 71 | +function WidgetDialogTrigger({ onClick }: Props) { |
| 72 | + return ( |
| 73 | + <button data-testid="widget-dialog-trigger" onClick={onClick}> |
| 74 | + Open Widget |
| 75 | + </button> |
| 76 | + ) |
| 77 | +} |
| 78 | +``` |
| 79 | + |
| 80 | +Now let's assume we want to click that button in a test, so we do: |
| 81 | + |
| 82 | +```ts title="Test That Button" |
| 83 | +screen.getByTestId('widget-dialog-trigger').click() |
| 84 | +``` |
| 85 | + |
| 86 | +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: |
| 87 | + |
| 88 | +```tsx title="Oh no" {3,5} |
| 89 | +function WidgetDialogTrigger({ onClick }: Props) { |
| 90 | + return ( |
| 91 | + <div data-testid="widget-dialog-trigger" onClick={onClick}> |
| 92 | + Open Widget |
| 93 | + </div> |
| 94 | + ) |
| 95 | +} |
| 96 | +``` |
| 97 | + |
| 98 | +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 `div`s. |
| 99 | + |
| 100 | +<Aside title="This is real"> |
| 101 | + |
| 102 | +If you think this example is made up - it's not, we had something like this [in the sentry codebase](https://github.com/getsentry/sentry/blob/98b67ef3b84b8495add69e6c7775c51590623691/static/app/views/settings/components/settingsBreadcrumb/breadcrumbDropdown.tsx#L59-L67) before we forced [actual buttons as triggers](https://github.com/getsentry/sentry/pull/105040) for our `compactSelect` component. |
| 103 | + |
| 104 | +If you'd prefer a different example: Think an `input` without a correctly associated `label`. Sadly, this happens all the time. |
| 105 | + |
| 106 | +</Aside> |
| 107 | + |
| 108 | +## Role-based selectors |
| 109 | + |
| 110 | +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: |
| 111 | + |
| 112 | +```ts title="Role-based selectors" |
| 113 | +screen.getByRole('button', { name: 'Open Widget' }).click() |
| 114 | +``` |
| 115 | + |
| 116 | +If we're using role-based selectors in our tests, we get a couple of things almost for free: |
| 117 | + |
| 118 | +- We're all of a sudden doing _some_ a11y testing. This doesn't replace dedicated a11y testing with tools like [axe](https://www.deque.com/axe/), but it makes it harder to accidentally create inaccessible markup |
| 119 | +- Our tests become more readable. Yes, Clicking the "Open Widget button" reads great, and the fact that it's tied to the text of the button is not a problem. They don't change as often as you think! |
| 120 | + |
| 121 | +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. |
| 122 | + |
| 123 | +### Examples |
| 124 | + |
| 125 | +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: |
| 126 | + |
| 127 | +> I'm clicking the Dashboards Link in the Sidebar |
| 128 | +
|
| 129 | +```ts title=" " |
| 130 | +within(screen.getByRole('navigation')) |
| 131 | + .getByRole('link', { name: 'Dashboards' }) |
| 132 | + .click() |
| 133 | +``` |
| 134 | + |
| 135 | +> Let me click the Confirm Button in the Save Dialog |
| 136 | +
|
| 137 | +```ts title=" " |
| 138 | +within(screen.getByRole('dialog', { name: 'Save' })) |
| 139 | + .findByRole('button', { name: 'Confirm' }) |
| 140 | + .click() |
| 141 | +``` |
| 142 | + |
| 143 | +> I'm filling out the Email Field in the Registration Form |
| 144 | +
|
| 145 | +```ts title=" " |
| 146 | +userEvent.type( |
| 147 | + within( |
| 148 | + screen.getByRole('form', { name: 'Registration' }), |
| 149 | + ).findByRole('textbox', { name: 'Email' }), |
| 150 | + 'user@example.com', |
| 151 | +) |
| 152 | +``` |
| 153 | + |
| 154 | +### Getting there |
| 155 | + |
| 156 | +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: |
| 157 | + |
| 158 | +- Use semantic HTML. It includes [implicit ARIA roles](https://www.w3.org/TR/html-aria/#docconformance), so you rarely have to add manual `role` attributes. |
| 159 | +- Make sure interactive elements have an accessible name, like visible text or a proper label, and only use interactive elements for things users can interact with! |
| 160 | +- Use the keyboard to navigate around your app. If there's something you can't use (looking at you, [tooltips](tooltip-components-should-not-exist)), it needs fixing. |
| 161 | +- Use headings, landmarks and grouped regions to give the UI a clear structure. This makes it much easier to locate specific text in some parts of the page in your tests using role-based selectors. |
| 162 | +- Always associate form controls with labels so they can be found by name. Even if you don't want a visible label, you can use [`<VisuallyHidden>`](https://react-aria.adobe.com/VisuallyHidden) components or [`sr-only`](https://v3.tailwindcss.com/docs/screen-readers) classes to keep the label accessible without making it visible in the UI. |
| 163 | +- [Testing Playground](https://testing-playground.com/) is a great tool to find the best possible accessible selector for your markup. |
| 164 | +- Similarly, your browsers devtools not only have an Accessibility tab, you can also switch between DOM tree view and Accessibility tree view when inspecting elements, which makes it easy to find the best possible selectors. |
| 165 | +- Ask you favourite AI agent about it. No seriously, they know a ton about a11y. If they leave it out in the one-shot, it's probably because they have been trained on all the code we humans have written in the past decade, which [often falls short on a11y](https://webaim.org/projects/million/). |
| 166 | + |
| 167 | +<VerticalRuler height="5em" /> |
| 168 | + |
| 169 | +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`. |
| 170 | + |
| 171 | +--- |
| 172 | + |
| 173 | +That's it for today. Feel free to reach out to me on [bluesky](https://bsky.app/profile/tkdodo.eu) |
| 174 | +if you have any questions, or just leave a comment below. ⬇️ |
| 175 | + |
| 176 | +<Comments /> |
0 commit comments