Accessibility Testing & Process · 5 / 8
lesson 5

Component-level testing

Catch a11y bugs at the cheapest layer — the component test, where they cost minutes instead of weeks.

~ 15 min read·lesson 5 of 8
0 / 8

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:

  1. 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.
  2. 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.
  3. 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.
  4. 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.

Tip

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.

checkout.test.tsx
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 getByRole to getByText. A button named "Save" is getByRole("button", { name: "Save" }), not getByText("Save").
  • Use the name option 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 getByTestId only when nothing else works. It's a code smell.
Watch out

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.

checkout.test.tsx
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.
lint (jsx-a11y) component + jest-axe e2e + axe manual
Where each layer of testing earns its keep — axe in the unit test catches DOM-shape; layout-dependent checks live higher up.

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.

toggle.test.tsx
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.

disclosure.test.tsx
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

check your understanding
Which Testing Library query best mirrors how a screen-reader user would find a button?
check your understanding
Why is color contrast checking unreliable in jest-axe alone?
check your understanding
Your test passes with fireEvent.click(button) but fails when you press the keyboard equivalent. What's the most likely problem?
check your understanding
What's a sensible single-line "smoke" assertion to add to a non-trivial component test?
check your understanding
Which of these is not something component tests are well-suited for?
← prevnext lesson →
KeepLearningcertificate
for completing
Accessibility Testing & Process
0 of 8 read