Topic 18.5
Testing Accessibility
In one line
Test accessibility in layers: role-based Testing Library queries that fail when names and roles are missing, automated axe scans, keyboard-only walkthroughs, real screen reader checks, and checks for reduced motion and colour contrast.
Think of it like this
Checking a new house before moving in. A machine can test the wiring in seconds (automated checks), but you still walk through every room, open every door and try the stairs (keyboard testing), and you invite a friend in a wheelchair to try the front door (screen reader and real-user testing). Each check finds problems the others miss.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- getByRole
- A Testing Library query that finds an element by its accessible role and name, the way assistive technology sees it.
- userEvent
- A Testing Library package that simulates real user input: typing, clicking, Tab and other keys.
- axe-core
- An open-source engine that scans a rendered page for automatically detectable accessibility problems.
- Lighthouse
- An auditing tool built into Chrome DevTools; its accessibility score is based on axe checks.
- Keyboard trap
- A place where keyboard focus gets stuck and can't leave. A failure of WCAG 2.1.2.
- NVDA / VoiceOver / TalkBack
- Free screen readers for Windows, Apple devices and Android.
- prefers-reduced-motion
- A media query that is
reducewhen the user asked the system to minimise animation. - Contrast ratio
- How different two colours' brightness is, from 1:1 (same) to 21:1 (black on white).
Step by step
01Write component tests with role queries
Query by role and name first, by label for form fields, and by text for non-interactive content. When a query can't find something, the error lists the roles that do exist, which often shows the bug ("there's no button, only a generic").
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { test, expect } from "vitest";
import Newsletter from "./Newsletter";
test("a keyboard user can subscribe", async () => {
render(<Newsletter />);
const user = userEvent.setup();
await user.tab();
expect(screen.getByRole("textbox", { name: "Email" })).toHaveFocus();
await user.keyboard("asha@example.com");
await user.tab();
expect(screen.getByRole("button", { name: "Subscribe" })).toHaveFocus();
await user.keyboard("{Enter}");
expect(await screen.findByRole("status")).toHaveTextContent("Thanks for subscribing!");
});02Add an axe check to component tests
jest-axe works in Jest and Vitest: extend expect once in your setup file, then scan the container of any rendered component. Keep a few of these on key screens; they catch regressions such as a new icon button without a name.
import { render } from "@testing-library/react";
import { axe, toHaveNoViolations } from "jest-axe";
import { test, expect } from "vitest";
import ProductCard from "./ProductCard";
expect.extend(toHaveNoViolations);
test("ProductCard has no detectable a11y violations", async () => {
const { container } = render(<ProductCard product={sampleProduct} />);
expect(await axe(container)).toHaveNoViolations();
});
// Note: jsdom has no layout, so colour-contrast checks don't run here.
// Run axe in a real browser (Playwright, axe DevTools) for those.03Scan pages in a real browser
End-to-end tests run in real browsers, so axe can check contrast too. Fail CI on new serious and critical violations. The block below runs axe-core 4.13 against a small page in Chromium with Playwright; the output is real.
04Do the manual passes
For each important flow: one keyboard-only pass, one screen reader pass (NVDA + Firefox or Chrome, VoiceOver + Safari), one pass at 200% zoom and 320px width, one with reduce-motion on. Write the steps down so anyone on the team can repeat them before a release.
Try it yourself
- 1
Break the fixed version
In the first example, remove the
<label>from the fixed version only (change{fixed && …}to{false && …}). Predict which tests fail now. Then tryaria-label="Email"on the input instead: does the label test pass? (Yes:aria-labelgives a name, though a visible label is better for everyone.) - 2
Find a passing grey
In the contrast example, add pairs for
#777777and#757575on white. Predict which pass 4.5:1. (Only#757575:#777777gives 4.48:1.) - 3
Break the roving tabindex and watch the test
In the tabs test, change
tabIndex={i === active ? 0 : -1}totabIndex={0}. Predict which checks fail. ("the next Tab goes to the panel" and "only one tab is in the Tab order".)
Code & diagrams
The same tests, two versions of the component. The div-soup version looks identical on screen, but the queries can't find a labelled textbox or a button, exactly as a screen reader user wouldn't. Note that the placeholder doesn't count as a label. Enter on the real button submits the form, which `userEvent` simulates like a browser.
Preview
Console
Nothing logged yet.Expected console output
-- div-soup version
✗ the email field has a label → Unable to find an accessible element with the role "textbox" and name "Email"
✗ a keyboard user can subscribe → Unable to find an accessible element with the role "button" and name "Subscribe"
-- fixed version
✓ the email field has a label
✓ a keyboard user can subscribe`user.tab()` follows the real Tab order, so it proves roving tabindex works: from the tab list, the next Tab skips the other tabs and goes to the panel. `{End}{ArrowRight}` presses two keys in one call.
Preview
Console
Nothing logged yet.Expected console output
✓ Tab lands on the selected tab
✓ ArrowRight selects and focuses Pro
✓ End then ArrowRight wraps to Free
✓ the next Tab goes to the panel
✓ only one tab is in the Tab order`#767676` is the lightest grey that passes 4.5:1 on white; the popular `#999999` fails. The hook reads the operating system setting (here: no preference) and re-renders if the user changes it. Turn on "reduce motion" in your OS settings and run again: the log says `true` and the fade disappears. DevTools can emulate it too (Rendering panel).
Preview
Console
Nothing logged yet.Expected console output
prefers reduced motion: false
#767676 on #ffffff → 4.54:1 passes AA
#999999 on #ffffff → 2.85:1 fails
#ffffff on #087ea4 → 4.64:1 passes AA
#000000 on #ffd43b → 14.73:1 passes AA// axe-scan.cjs: load a page in Chromium, inject axe-core, print the violations
const { chromium } = require("playwright");
(async () => {
const browser = await chromium.launch();
const page = await browser.newPage();
await page.setContent(`<!doctype html><html lang="en"><head><title>Shop</title></head><body><main>
<h1>Shop</h1>
<div class="card" onclick="1">
<img src="data:image/gif;base64,R0lGODlhAQABAAAAACw=">
<input type="text">
<button></button>
<p style="color:#999;background:#fff">Only 2 left</p>
</div></main></body></html>`);
await page.addScriptTag({ path: require.resolve("axe-core/axe.min.js") });
const violations = await page.evaluate(async () => (await axe.run(document)).violations);
for (const v of violations) console.log(v.impact.padEnd(8), v.id + ":", v.help, "(" + v.nodes.length + " node)");
await browser.close();
})();
// $ node axe-scan.cjs
// critical button-name: Buttons must have discernible text (1 node)
// serious color-contrast: Elements must meet minimum color contrast ratio thresholds (1 node)
// critical image-alt: Images must have alternative text (1 node)
// critical label: Form elements must have labels (1 node)
//
// Not reported: the clickable div. axe can't know a div's onclick makes it a
// control; the lint rules (Topic 18.1) and a keyboard pass catch that.Break it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Test with getByTestId everywhere
Write every query as screen.getByTestId("subscribe") and put data-testid on a <div onClick> Subscribe control.
Break #2
Trust a 100 accessibility score
Ship after Lighthouse reports Accessibility 100 on a page with a custom dropdown built from divs with roles and no key handling.
Myth vs fact
Myth
If axe finds no violations, the app is accessible.
Fact
Automated tools catch a portion of issues: missing names, bad contrast, invalid ARIA. They can't check meaningful alt text, logical focus order, keyboard behaviour or useful announcements.
Myth
Screen reader testing needs expensive software.
Fact
NVDA (Windows), VoiceOver (macOS, iOS) and TalkBack (Android) are free. Fifteen minutes per main flow finds problems no tool will.
Myth
Reduced motion means no animation at all.
Fact
It means removing or reducing motion that isn't essential, especially large movement, parallax and zooming. Subtle fades or instant state changes are usually fine.
Myth
Testing Library's role queries are just a testing style.
Fact
They use the same accessible name and role rules as browsers (through the dom-accessibility-api package), so they double as a check that names and roles exist.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
getByRoleis the slowest query because it computes the accessibility tree; in big test suites passhidden: false(the default) and scope queries withwithin(region).logRoles(container)from@testing-library/domprints every role and name in a subtree: a quick way to see the tree as assistive technology does. - ▸
jsdom (the usual Vitest/Jest environment) has no layout or CSS engine, so axe skips contrast checks there and
toBeVisiblerelies on inline styles and attributes. Vitest's browser mode or Playwright component tests run in real browsers and close that gap. - ▸
Accessibility testing in CI works best as a ratchet: fail the build on new
critical/seriousviolations and keep a known-issues list that only shrinks. Disabling rules globally hides regressions. - ▸
WCAG 2.2 removed 4.1.1 Parsing (it's now considered always satisfied) and added nine criteria, including 2.4.11 Focus Not Obscured (Minimum), 2.5.7 Dragging Movements, 2.5.8 Target Size (Minimum), 3.2.6 Consistent Help, 3.3.7 Redundant Entry and 3.3.8 Accessible Authentication (Minimum). Drag-and-drop UIs need a non-dragging alternative (buttons to move items) to meet 2.5.7.
Remember this
- 1
Role queries as a check. Testing Library (Phase 17) finds elements the way assistive technology does:
screen.getByRole("button", { name: "Subscribe" })searches the accessibility tree for an element with that role and accessible name. If your "button" is a div, or your input has no label, the query throws and the test fails. So writing tests withgetByRoleandgetByLabelText(the queries Testing Library recommends first) gives you an accessibility check for free on every component you test.getByTestIdis the last resort because it proves nothing about what users perceive. - 2
Keyboard tests in code.
userEvent.setup()thenawait user.tab(),await user.keyboard("{ArrowRight}")or"{Enter}"simulate a keyboard user, including the browser's default actions (Enter on a button submits its form). Assert focus withdocument.activeElement(orexpect(el).toHaveFocus()from jest-dom). This is how you lock in the widget patterns from Topic 18.4 so a refactor can't break them. - 3
axe (axe-core, by Deque) is the most used automated accessibility engine. It checks the rendered DOM against rules such as missing alt text, unlabelled form fields, buttons without names, colour contrast, invalid ARIA and duplicate ids. You can run it as a browser extension (axe DevTools), through Chrome's Lighthouse accessibility audit (which uses axe), in end-to-end tests (Playwright with
@axe-core/playwright), or in component tests with jest-axe/vitest-axe (expect(await axe(container)).toHaveNoViolations()). Automated tools find only a portion of real problems: they can't judge whether alt text is meaningful, whether focus order makes sense, or whether a live region says anything useful. - 4
Keyboard-only testing is the fastest manual check and needs no tools: put the mouse away and use the app. Can you reach every control with Tab and Shift+Tab? Is the focus indicator always visible and never hidden behind a sticky header? Does the order make sense? Do Enter, Space, arrows and Escape do what the widgets promise? Can you get out of everything (no keyboard trap)? Does focus land somewhere sensible after every route change, dialog and deletion?
- 5
Screen reader testing checks names, roles, states and announcements for real. Free options: NVDA on Windows, VoiceOver on macOS and iOS (toggle on a Mac with Cmd+F5), TalkBack on Android, Narrator on Windows. Learn a few moves: next heading (
Hin NVDA's browse mode), the elements or rotor list (NVDA+F7, VoiceOver's rotor with VO+U) to see all headings, links and landmarks the way a user does, and Tab through forms listening to each label. Test the main user journey, not every pixel. - 6
Reduced motion and contrast. Some people get dizzy or sick from animation; they set "reduce motion" in their operating system, which the CSS media query
prefers-reduced-motion: reduceexposes. Respect it in CSS, and in JavaScript-driven animations read it withmatchMedia(auseSyncExternalStorehook keeps it live, Topic 10.3). Contrast: WCAG 2.2 success criterion 1.4.3 (level AA) requires 4.5:1 between text and background, or 3:1 for large text (about 24px, or 18.66px bold); 1.4.11 requires 3:1 for UI component boundaries and focus indicators. WCAG 2.2 also added 2.4.11 Focus Not Obscured (Minimum) and 2.5.8 Target Size (Minimum: 24 by 24 CSS pixels) at level AA.
Explain it without notes
How do Testing Library's role queries act as accessibility checks?
What can automated tools like axe catch, and what can't they?
Describe a keyboard-only test pass. What are you looking for?
How do you respect reduced motion in a React app, and what contrast ratios does WCAG AA require?
Practice
Write a test (using the tiny test helper style) that checks a Modal returns focus to its opener when closed.
Write a useReducedMotion-aware Toast that slides in normally and just appears when the user prefers reduced motion.
Trade-offs
- ↔
Automated checks are cheap and run on every commit; manual screen reader testing is slow but finds the problems that matter most. Use automation to stop regressions and manual passes on main flows before releases.
- ↔
Running axe in every component test slows the suite. Many teams scan key screens in end-to-end tests (real browser, contrast included) and keep component tests to role queries and keyboard behaviour.
Done when you can
I write tests with
getByRoleandgetByLabelTextso they double as accessibility checks.I can test keyboard behaviour with
userEventand focus assertions.I can run axe in tests or the browser and know what it can't catch.
I can do a keyboard-only pass and a basic screen reader pass.
I respect
prefers-reduced-motionand check contrast against WCAG 2.2 AA.