Testing ARIA Roles with Testing Library
Most component tests target elements by a data-testid or a CSS selector, which quietly guarantees that the test can pass even when a screen reader would find nothing usable. This guide shows the opposite discipline: query every element by its computed ARIA role and accessible name, so the test only passes if assistive technology could locate the same control. It is written for React developers using Vitest with React Testing Library and jsdom, and it assumes you have the @testing-library/jest-dom matchers registered. By the end you will know how to pick the right role, resolve accessible names from the four places they can come from, debug a query that finds nothing, and turn a swapped-out semantic element into an immediate test failure rather than a production accessibility bug.
Root Cause Analysis
The reason role-based queries matter is that the DOM you author and the accessibility tree the browser computes are two different structures. When you write <button>Save</button>, the browser derives an accessibility node with role button and accessible name "Save", and that node — not the raw HTML — is what a screen reader navigates. Swap in <div class="btn" onClick={save}>Save</div> and the visual result is identical while the accessibility node collapses to a generic staticText: no role, no keyboard operability, no name a rotor can jump to. A test that queried getByText('Save') passes in both cases, so it never noticed the regression. A test that queried getByRole('button', { name: 'Save' }) fails the moment the semantics break, because that role no longer exists.
Accessible names have their own resolution algorithm, and misunderstanding it is the second common root cause of both real bugs and confusing test failures. The name of a control is computed by walking a defined priority order: an aria-labelledby reference wins first, then aria-label, then the element’s own content or an associated <label>, then attributes like title or alt as a last resort. An icon-only button with no text and no aria-label computes an empty name — invisible to a screen-reader user and, correctly, unfindable by getByRole('button', { name: /.../ }). Understanding this order is what lets you read a “no accessible name” failure as the accurate defect report it is, rather than fighting the query.
Reproducible Setup
Install the runtime and testing dependencies, then register the jest-dom matchers so role and name assertions read naturally.
npm install -D vitest @testing-library/react @testing-library/jest-dom jsdom
// vitest.config.ts
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./vitest.setup.ts'],
},
});
// vitest.setup.ts
import '@testing-library/jest-dom/vitest';
The component under test is a small toolbar with a mix of correctly and incorrectly authored controls, so the queries have something real to catch.
// Toolbar.tsx
export function Toolbar({ onSave }: { onSave: () => void }) {
return (
<div role="toolbar" aria-label="Document actions">
<button onClick={onSave}>Save</button>
<button aria-label="Bold" onClick={() => {}}>
<BoldIcon aria-hidden="true" />
</button>
<a href="/help">Help</a>
</div>
);
}
Implementation
Step 1 — Query the container by its landmark role. Composite widgets like toolbars, tab lists, and menus have their own roles. Asserting the container role first proves the widget announces itself correctly before you check its children.
import { render, screen } from '@testing-library/react';
import { Toolbar } from './Toolbar';
test('exposes a labelled toolbar landmark', () => {
render(<Toolbar onSave={() => {}} />);
expect(
screen.getByRole('toolbar', { name: /document actions/i }),
).toBeInTheDocument();
});
Step 2 — Target each control by role and accessible name. Use the name option with a regular expression so casing and surrounding whitespace do not make the test brittle. Each of these queries fails if the role or the name is missing, which is precisely the coverage you want.
test('every control is reachable by role and name', () => {
render(<Toolbar onSave={() => {}} />);
expect(screen.getByRole('button', { name: /save/i })).toBeEnabled();
expect(screen.getByRole('button', { name: /bold/i })).toBeInTheDocument();
expect(screen.getByRole('link', { name: /help/i })).toHaveAttribute(
'href',
'/help',
);
});
Step 3 — Assert the accessible name explicitly where it is computed indirectly. The bold button gets its name from aria-label because its only content is a decorative icon. toHaveAccessibleName documents that intent and fails if someone later removes the label.
test('icon-only control carries an accessible name', () => {
render(<Toolbar onSave={() => {}} />);
const bold = screen.getByRole('button', { name: /bold/i });
expect(bold).toHaveAccessibleName('Bold');
});
Step 4 — Prove the negative to lock the semantics. A queryByRole that expects null guards against an element accidentally acquiring a role it should not have — a common regression when someone adds an errant role attribute or a wrapping element. Pairing positive and negative role queries pins the accessibility tree in place.
test('decorative icon is hidden from the tree', () => {
render(<Toolbar onSave={() => {}} />);
// The icon is aria-hidden, so it must not surface as an image role.
expect(screen.queryByRole('img')).not.toBeInTheDocument();
});
Step 5 — Handle state-dependent roles and names. A toggle button or a tab exposes an ARIA state (aria-pressed, aria-selected, aria-expanded) that changes with interaction. Assert the state through the role query’s options so you verify the semantics, not the attribute string.
test('toggle reflects its pressed state in the tree', async () => {
render(<MuteToggle />);
const toggle = screen.getByRole('button', { name: /mute/i, pressed: false });
toggle.click();
expect(
screen.getByRole('button', { name: /mute/i, pressed: true }),
).toBeInTheDocument();
});
Verification
Confirm the discipline holds by making the semantics break on purpose. Temporarily change the real <button> in your component to a <div onClick>, run the suite, and watch the getByRole('button', ...) assertions fail with Testing Library’s helpful “unable to find an accessible element with the role button” output — which also prints the accessible roles that are present. That failure is the proof the test guards semantics rather than markup. Restore the button and the suite goes green again. Do the same with an accessible name: delete the aria-label from the icon button and confirm the name: /bold/i query stops matching. When both breakages are caught, your queries are wired to the accessibility tree.
npx vitest run Toolbar.test.tsx
Use screen.logTestingPlaygroundURL() or screen.debug() during development to see exactly which roles and names Testing Library computed for your markup; the printed list is the same tree a screen reader would traverse, which makes it the fastest way to confirm a query targets the right node before you commit the assertion.
Choosing the right query variant is half the battle, and the choice follows a simple rule keyed on when the element exists and whether you expect it to be present at all. If the element must already be in the tree, getByRole is correct because its immediate throw pinpoints the failure. If you are asserting an element is absent, only queryByRole returns null instead of throwing, so it is the sole correct choice for negatives. If the element arrives after an asynchronous update, findByRole retries until it appears. Reaching for the wrong variant is the single most common source of confusing output in a role-first suite, so internalize the branch before writing the assertion.
Troubleshooting
Symptom: getByRole finds nothing but the element is clearly rendered. Diagnosis: the element has no accessible name, or its implicit role is not what you assumed — a <div> has no role, and an <a> without href is not a link. Fix: run screen.getByRole('') with an empty string to trigger Testing Library’s suggestion list of every available role and name, then query against a role that actually exists, and fix the component if the intended role is genuinely absent.
Symptom: the query matches multiple elements and throws. Diagnosis: several controls share the same accessible name, which is itself an accessibility smell because a screen-reader user cannot tell them apart. Fix: give each a distinguishing name via aria-label or visible text, then tighten the query; if the duplication is legitimate, scope the query with within(region) to the correct container.
Symptom: a name query fails only in async tests. Diagnosis: the accessible name resolves after a state update or fetch, so a synchronous getByRole runs too early. Fix: switch to await screen.findByRole(...), and if the component fetches, resolve the request deterministically with MSW v2 so the name is stable, keeping the act() boundary clean.
FAQ
Should I ever use getByTestId instead of getByRole?
Reserve getByTestId for the rare element that has no semantic role and no reasonable accessible name — a purely presentational container you need to reference in a test but that a user never interacts with. For anything interactive, getByRole with a name option is strictly better because it verifies the accessibility contract as a side effect of finding the element. A test id proves the element exists in your DOM; a role query proves it exists in the accessibility tree, which is the thing users of assistive technology actually depend on.
What is the difference between getByRole, queryByRole, and findByRole?
getByRole throws immediately if the element is missing, which makes it the right choice when the element must already be present. queryByRole returns null instead of throwing, so it is the only correct variant for asserting that something is absent. findByRole returns a promise that retries until the element appears or a timeout elapses, so it is the one to use when the element arrives after an asynchronous update. Choosing the wrong variant is the most common cause of confusing test output — use query for negatives and find for anything async.
How do I test roles on a component that renders through a portal?
Portaled content such as a dialog or tooltip is appended outside your component’s container, but Testing Library’s screen queries the whole document by default, so screen.getByRole('dialog') still finds it. The pitfall appears only when you scope a query with within(container) or destructure a bound query from render; those are limited to the local subtree and will miss the portal. Query through screen for portaled elements, and reserve scoped queries for content you know lives inside the rendered container.
Related
- Back to Accessibility Testing for Components
- Automating axe accessibility checks in Vitest — catch the structural mistakes role queries alone would miss.
- Keyboard navigation testing with user-event — prove the roles you found are also operable by keyboard.
- Testing Library best practices — the query philosophy behind role-first testing.