Skip to content

Commit 42df5a2

Browse files
authored
test ids (#417)
2 parents a4ef7b3 + 9744025 commit 42df5a2

22 files changed

Lines changed: 248 additions & 44 deletions

File tree

content/posts/about-async-functions/index.mdx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@ import Comments from '@components/Comments.astro'
1212
import Attribution from '@components/Attribution'
1313
import Translations from '@components/Translations'
1414
import Aside from '@components/Aside'
15-
import Emph from '@components/Emph'
1615

1716
<Attribution
1817
name="WanderLabs"

content/posts/avoiding-hydration-mismatches-with-use-sync-external-store/index.mdx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@ import Comments from '@components/Comments.astro'
1414
import Attribution from '@components/Attribution'
1515
import Translations from '@components/Translations'
1616
import Emph from '@components/Emph'
17-
import Highlight from '@components/Highlight'
1817
import { TsError } from '@components/TsError'
1918
import Aside from '@components/Aside'
2019
import GifPlayer from '@components/GifPlayer.astro'

content/posts/building-type-safe-compound-components/index.mdx

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,11 @@ tags:
1111
- ReactJs
1212
---
1313

14-
import Aside from '@components/Aside'
1514
import Comments from '@components/Comments.astro'
1615
import Attribution from '@components/Attribution'
1716
import Translations from '@components/Translations'
1817
import { DsToc } from '@components/ds-toc'
1918
import Emph from '@components/Emph'
20-
import Highlight from '@components/Highlight'
2119
import { VerticalRuler } from '@components/VerticalRuler'
2220

2321
<Attribution

content/posts/concurrent-optimistic-updates-in-react-query/index.mdx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@ import { RqToc } from '@components/rq-toc'
1919
import Emph from '@components/Emph'
2020
import Aside from '@components/Aside'
2121
import QueryGG from '@components/QueryGG.astro'
22-
import Highlight from '@components/Highlight'
2322

2423
<Attribution
2524
name="Mariola Grobelska"

content/posts/designing-design-systems/index.mdx

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ tags:
1111
- ReactJs
1212
---
1313

14-
import Aside from '@components/Aside'
1514
import Comments from '@components/Comments.astro'
1615
import Attribution from '@components/Attribution'
1716
import Translations from '@components/Translations'
@@ -90,7 +89,7 @@ If you're curious about any of these points, let me know in the comments and I'l
9089
- Do visual regression testing for important things.
9190
- State syncing is the root of all evil.
9291
- Controlled first, uncontrolled if necessary. And make it typed.
93-
- `data-test-id` is an a11y smell.
92+
- [`data-testid` is an a11y smell.](test-ids-are-an-a11y-smell)
9493
- Build for the future of React.
9594

9695
---

content/posts/how-infinite-queries-work/index.mdx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@ import { RqToc } from '@components/rq-toc'
1717
import Emph from '@components/Emph'
1818
import Aside from '@components/Aside'
1919
import QueryGG from '@components/QueryGG.astro'
20-
import Highlight from '@components/Highlight'
2120

2221
<Attribution name="Reuben" url="https://unsplash.com/@re" />
2322

content/posts/omit-for-discriminated-unions-in-type-script/index.mdx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@ import Comments from '@components/Comments.astro'
1414
import Attribution from '@components/Attribution'
1515
import Translations from '@components/Translations'
1616
import Emph from '@components/Emph'
17-
import Highlight from '@components/Highlight'
1817
import { TsError } from '@components/TsError'
1918

2019
<Attribution

content/posts/react-19-and-suspense-a-drama-in-3-acts/index.mdx

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,7 @@ tags:
1313
import Comments from '@components/Comments.astro'
1414
import Attribution from '@components/Attribution'
1515
import Translations from '@components/Translations'
16-
import { RqToc } from '@components/rq-toc'
1716
import Emph from '@components/Emph'
18-
import Aside from '@components/Aside'
19-
import QueryGG from '@components/QueryGG.astro'
20-
import Highlight from '@components/Highlight'
2117
import { VerticalRuler } from '@components/VerticalRuler'
2218
import Tweet, {
2319
AvatarTkDodo,

content/posts/ref-callbacks-react-19-and-the-compiler/index.mdx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@ import Aside from '@components/Aside'
1313
import Comments from '@components/Comments.astro'
1414
import Attribution from '@components/Attribution'
1515
import Translations from '@components/Translations'
16-
import Highlight from '@components/Highlight'
1716
import { VerticalRuler } from '@components/VerticalRuler'
1817
import Tweet, { AvatarSathya } from '@components/Tweet'
1918
import { RcToc } from '@components/rc-toc'
Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
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

Comments
 (0)