Component-level testing
Catch a11y bugs at the cheapest layer — the component test, where they cost minutes instead of weeks.
The cheapest accessibility bug to fix is one a unit test catches before the PR is open. The most expensive is one a user reports six months after release. Component-level tests sit close to the cheap end, and they have an underrated property: writing them with accessibility in mind makes the code more accessible whether or not the test ever fails.
In this lesson we'll use Testing Library (specifically React Testing Library — the API is similar across frameworks) and jest-axe.
The right level
There are roughly four places to put accessibility checks:
- Lint rules like
eslint-plugin-jsx-a11y. Cheap, instant, and catches obvious sins (alt missing on an img, click handler on a non-button). Always on. - Component tests with Testing Library and jest-axe. Catches integration issues — a label not connected to its input, a custom button not focusable, a heading hierarchy that's wrong.
- End-to-end tests with Playwright + axe. Catches page-level issues — landmarks, color contrast in real CSS, focus management across navigation. We'll cover this in lesson 6.
- Manual audit with the keyboard and a screen reader. Catches the rest.
Component tests are the sweet spot: fast feedback, reproduce on CI, and they document the contract of the component (what role, what accessible name) in a way that other developers can read.
Don't try to push everything into component tests. They run dozens of times an hour, so they need to be fast — keep them focused on the contract, not the rendering minutiae.
Queries by role
Testing Library has a queryable hierarchy that maps almost exactly onto how a screen-reader user would find your element. The official guidance is to prefer queries that mirror real user behaviour, in this order: by role, by label, by text, then by test-id only as an escape hatch.
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Checkout } from "./Checkout";
test("submit button is reachable and labelled", async () => {
render(<Checkout />);
// role + name = how a screen-reader user would find this button
const button = screen.getByRole("button", { name: /place order/i });
// it's actually a button (focusable, activated on Enter / Space)
await userEvent.tab();
// ...
});When getByRole("button", { name: /place order/i }) fails — say the developer used a <div onClick> — the test breaks before the bug ships. The error message even tells you what roles were present. The query and the a11y check are the same thing.
A few rules of thumb:
- Prefer
getByRoletogetByText. A button named "Save" isgetByRole("button", { name: "Save" }), notgetByText("Save"). - Use the
nameoption to pin which role you mean. Pages have many buttons; the name is what tells them apart. - For inputs, use
getByLabelText("Email")— if that fails, your label-input association is broken. The test doubles as the audit. - Reach for
getByTestIdonly when nothing else works. It's a code smell.
Treat screen.getByText as a last-resort query. It happily finds spans, divs, and text in tooltips —
none of which exercise the role/name behaviour a real user depends on.
jest-axe
Testing Library queries cover contract. They don't catch general WCAG issues like missing landmarks or color contrast on the rendered output. That's where jest-axe comes in — it runs axe-core inside your test and asserts no violations.
import { render } from "@testing-library/react";
import { axe, toHaveNoViolations } from "jest-axe";
import { Checkout } from "./Checkout";
expect.extend(toHaveNoViolations);
test("Checkout has no axe violations", async () => {
const { container } = render(<Checkout />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});A few things to know:
- jest-axe runs against the JSDOM render. JSDOM doesn't fully implement layout, so contrast checks won't flag much here — that's a job for end-to-end tests.
- It picks up DOM-shape issues reliably: missing labels, mismatched
for/id, invalid ARIA, duplicate ids. - You can configure rules with
axe(container, { rules: { 'color-contrast': { enabled: false } } })if the defaults bark on something you handle elsewhere.
Test patterns that pay off
A handful of small patterns return huge value when you use them consistently.
Tab-and-activate. For any interactive component, write a test that tabs onto it and activates it with the keyboard. If you have to use fireEvent.click to make the test pass, that's a bug — the keyboard path is broken.
test("toggle activates with Space", async () => {
const onChange = jest.fn();
render(<Toggle label="Notifications" onChange={onChange} />);
await userEvent.tab();
expect(screen.getByRole("switch", { name: /notifications/i })).toHaveFocus();
await userEvent.keyboard(" ");
expect(onChange).toHaveBeenCalledWith(true);
});State announcements. For any component with a meaningful state — open/closed, pressed/unpressed, expanded/collapsed — assert that the state attribute changes. This forces you to expose the state to assistive tech.
test("disclosure exposes expanded state", async () => {
render(<Disclosure title="Details">…</Disclosure>);
const trigger = screen.getByRole("button", { name: /details/i });
expect(trigger).toHaveAttribute("aria-expanded", "false");
await userEvent.click(trigger);
expect(trigger).toHaveAttribute("aria-expanded", "true");
});Label connection. When you build a form input component, write a test that finds it by label text. If the test fails, the input isn't reachable by label — for screen readers or your test.
No-violations smoke. A single toHaveNoViolations test per non-trivial component, asserting jest-axe finds nothing. When the codebase grows, this is the safety net that catches regressions you'd otherwise miss.
What component tests cannot do
A short, honest list:
- Color contrast in JSDOM is unreliable — there's no real layout engine running.
- Focus visibility — whether the focus ring is actually drawn — needs a real browser.
- Tab order across pages — JSDOM doesn't run real navigation.
- Screen-reader output — no automated tool reproduces what NVDA actually says.
For all of these, lift the check up to the end-to-end layer (next lesson) or run a manual pass.
Check yourself
fireEvent.click(button) but fails when you press the keyboard equivalent. What's the most likely problem?