Command Palette

Search for a command to run...

Hectal
PHASE 18Intermediate ~28 min· topic 5 of 5

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 reduce when 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").

Newsletter.test.jsxwhole filejsx
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.

ProductCard.a11y.test.jsxwhole filejsx
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.

Do the manual passesdiagram
Rendering diagram…

Try it yourself

  1. 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 try aria-label="Email" on the input instead: does the label test pass? (Yes: aria-label gives a name, though a visible label is better for everyone.)

  2. 2

    Find a passing grey

    In the contrast example, add pairs for #777777 and #757575 on white. Predict which pass 4.5:1. (Only #757575: #777777 gives 4.48:1.)

  3. 3

    Break the roving tabindex and watch the test

    In the tabs test, change tabIndex={i === active ? 0 : -1} to tabIndex={0}. Predict which checks fail. ("the next Tab goes to the panel" and "only one tab is in the Tab order".)

Code & diagrams

Role queries fail on div soup and pass on real elements New tab

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

Press Run to see the page.

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
Keyboard test for the tabs pattern New tab

`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

Press Run to see the page.

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
Contrast ratios and a reduced-motion hook New tab

`#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

Press Run to see the page.

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-core in a real browser (Playwright, real output)javascript
// 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.

terminal
$ Test run
── what you'll see ──
All tests pass. The shipped control is still unreachable by keyboard and silent to screen readers.

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.

terminal
$ Keyboard and screen reader
── what you'll see ──
The dropdown is announced as "listbox" but arrow keys do nothing and focus never enters it. Lighthouse still shows 100.

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.

  • ▸

    getByRole is the slowest query because it computes the accessibility tree; in big test suites pass hidden: false (the default) and scope queries with within(region). logRoles(container) from @testing-library/dom prints 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 toBeVisible relies 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/serious violations 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. 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 with getByRole and getByLabelText (the queries Testing Library recommends first) gives you an accessibility check for free on every component you test. getByTestId is the last resort because it proves nothing about what users perceive.

  2. 2

    Keyboard tests in code. userEvent.setup() then await 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 with document.activeElement (or expect(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. 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. 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. 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 (H in 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. 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: reduce exposes. Respect it in CSS, and in JavaScript-driven animations read it with matchMedia (a useSyncExternalStore hook 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

01

How do Testing Library's role queries act as accessibility checks?

02

What can automated tools like axe catch, and what can't they?

03

Describe a keyboard-only test pass. What are you looking for?

04

How do you respect reduced motion in a React app, and what contrast ratios does WCAG AA require?

Practice

01

Write a test (using the tiny test helper style) that checks a Modal returns focus to its opener when closed.

02

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 getByRole and getByLabelText so they double as accessibility checks.

  • I can test keyboard behaviour with userEvent and 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-motion and check contrast against WCAG 2.2 AA.