Accessibility Testing for Components

Accessibility is not a late-stage audit you bolt on before release; it is a property of each component that your test suite can pin down at the same tier where you already verify behavior. This topic area sits inside the broader component and integration testing layer, and it treats the accessibility tree as a first-class assertion target rather than a manual checklist. When a component renders, the browser exposes it to assistive technology through computed roles, accessible names, states, and a focus order — and every one of those is observable from a Vitest test running against jsdom. The discipline here is to stop testing the DOM you wrote and start testing the semantics a screen-reader user actually receives, because those two things drift apart the moment someone swaps a <button> for a styled <div> with an onClick. This area collects four in-depth guides that make that semantic layer testable: querying by ARIA role, running automated axe checks inside Vitest, driving keyboard interaction with user-event, and verifying focus management in modal dialogs.

The reason accessibility testing belongs at the component tier — and not exclusively in a slow, browser-driven end-to-end run — is economic and structural. A misassigned role, a missing accessible name, or a focus trap that leaks are all deterministic outcomes of a single component’s markup and props. They do not require a live backend, a real network, or a full application boot to reproduce; they require rendering the component and interrogating its accessibility tree. That makes them ideal residents of a fast Vitest run where hundreds of assertions execute per second across CI workers. Pushing these checks up into E2E inflates pipeline cost, slows feedback to minutes, and couples an accessibility regression to an unrelated deploy. Pulling them down to the unit tier loses the rendered DOM entirely. The component tier is the only place where the real accessibility tree exists and the feedback loop stays sub-second.

Architectural Scope & Boundaries

This topic area covers accessibility verification for individual components and small composed trees rendered in jsdom under Vitest, driven by React Testing Library and @testing-library/user-event, with automated rule scanning provided by jest-axe (or its Vitest-native sibling vitest-axe). Its boundaries are deliberate. Inside scope: computed ARIA roles and the queries that target them, accessible names and descriptions, keyboard operability and focus order, focus trapping and restoration in overlays, and the subset of WCAG success criteria that a static-analysis engine like axe-core can evaluate against a rendered fragment. Outside scope: anything that depends on a real rendering engine’s layout and paint — color-contrast ratios computed from actual pixels, visible focus indicators, reflow at zoom, and screen-reader announcement timing. Those belong to a browser-driven layer where a real engine resolves computed styles, and jsdom cannot substitute for it.

Understanding that split is what keeps this tier honest. jsdom implements the DOM and enough of the accessibility model to compute roles and accessible names via the dom-accessibility-api that Testing Library depends on, but it does not lay out boxes or resolve getComputedStyle the way a browser does. So axe-core running in jsdom can catch a missing alt, a control with no accessible name, an invalid aria-* attribute, or a role that requires a name it does not have — but it will report color-contrast violations unreliably because the color values it sees are not the painted ones. The practical rule this area enforces: assert structure and semantics here, and defer anything pixel-dependent to a real browser. That boundary appears again in the Playwright component testing area, which runs the same components in a real engine when a check genuinely needs one.

There is a second boundary worth naming: the difference between testing a component in isolation and testing it composed. Many accessibility properties are compositional — a heading is only correctly ranked relative to the headings around it, a form field’s label association is only meaningful in the context of its form, and a landmark like main or navigation is only unique when the whole page is considered. A component tested alone can be perfectly accessible and still produce a broken page when three copies mount together and each declares itself the single main landmark. This area therefore recommends testing components both in isolation, where the fast feedback lives, and in a small number of representative compositions that mirror how they actually appear, so structural rules that only make sense across a whole document have something real to evaluate. The React state and hydration testing area covers the composed-render mechanics these tests reuse.

What the component tier can and cannot assert about accessibility Roles, accessible names, keyboard order, and focus trapping are testable in jsdom, while color contrast, visible focus rings, and announcement timing require a real browser engine. Where each accessibility check belongs Component tier — jsdom + Vitest ARIA roles & accessible names keyboard order & operability focus trapping & restoration axe-core structural rules Browser tier — real engine computed color contrast visible focus indicators reflow & zoom at 400% announcement timing Assert semantics fast here; defer pixel-dependent checks to a real browser
The boundary between structural checks jsdom can verify and pixel-dependent checks that need a real engine.

Prerequisites

Step-by-Step Implementation

The four guides in this area each go deep on one technique. This section shows how they compose into a single component’s accessibility test file, so you can see the shape before drilling into the specifics. The ordering matters: you register matchers once globally, then within each test you render, query and interact, and only scan with axe after the component has reached the state you care about — scanning a half-rendered tree wastes the check on markup no user will ever see. Treat the composition below as the default skeleton for any interactive component’s accessibility spec, and reach for the individual guides when a control needs deeper treatment than the skeleton provides.

Step 1 — Register the matchers once. A shared setup file extends expect with both the jest-dom matchers and the axe matcher, so every test file inherits toHaveNoViolations, toHaveAccessibleName, and the role-aware assertions.

// vitest.setup.ts
import '@testing-library/jest-dom/vitest';
import { expect } from 'vitest';
import { toHaveNoViolations } from 'jest-axe';

expect.extend(toHaveNoViolations);

Step 2 — Query by role, never by test id. The single most important habit in this area is targeting elements the way assistive technology sees them. If getByRole('button', { name: /save/i }) finds your control, a screen reader will too; if it does not, that is a real defect the test just caught.

// SaveBar.test.tsx
import { render, screen } from '@testing-library/react';
import { SaveBar } from './SaveBar';

test('exposes an operable, named save control', () => {
  render(<SaveBar onSave={() => {}} />);
  const button = screen.getByRole('button', { name: /save changes/i });
  expect(button).toBeEnabled();
});

Step 3 — Drive interaction through the keyboard. Real users tab, arrow, and press Enter. user-event reproduces that sequence including focus movement, which fireEvent does not. Asserting on document.activeElement after each keystroke verifies the focus order matches the reading order.

import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { SaveBar } from './SaveBar';

test('reaches the save control by keyboard alone', async () => {
  const onSave = vi.fn();
  const user = userEvent.setup();
  render(<SaveBar onSave={onSave} />);

  await user.tab();
  expect(screen.getByRole('button', { name: /save changes/i })).toHaveFocus();
  await user.keyboard('{Enter}');
  expect(onSave).toHaveBeenCalledOnce();
});

Step 4 — Scan the rendered fragment with axe. After exercising the component, hand its container to axe and assert zero violations. This catches the whole class of structural mistakes — unlabeled controls, invalid ARIA, missing list semantics — that role queries alone would not surface.

import { render } from '@testing-library/react';
import { axe } from 'jest-axe';
import { SaveBar } from './SaveBar';

test('has no automatically detectable a11y violations', async () => {
  const { container } = render(<SaveBar onSave={() => {}} />);
  expect(await axe(container)).toHaveNoViolations();
});

Step 5 — Verify focus management for overlays. Dialogs, menus, and drawers must trap focus while open and restore it on close. This is the one interaction pattern where a subtle bug is invisible to sighted mouse users but completely blocks keyboard users, so it earns its own dedicated assertions covered in depth by the focus-management guide below.

Four accessibility techniques composed into one test file A rendered component flows through role queries, keyboard interaction, an axe scan, and focus assertions, together producing a single pass or fail signal. render() component in jsdom getByRole names & roles user-event keyboard & focus axe(container) structural rules activeElement focus order pass / fail one signal
The four techniques in this area compose into one test file that emits a single accessibility signal.

Configuration Reference

The tools in this area are small and composable. This table maps each to its job, the package that provides it, and where the deeper guidance lives.

Tool Package Responsibility Runs in
Role queries @testing-library/react Target elements by computed ARIA role and accessible name jsdom
jest-dom matchers @testing-library/jest-dom toHaveAccessibleName, toHaveRole, toBeDisabled assertions jsdom
user-event @testing-library/user-event Realistic keyboard, tab order, and pointer simulation jsdom
axe scanner jest-axe / vitest-axe Automated WCAG structural rule evaluation jsdom
Environment vitest + jsdom Renders the component tree and accessibility model Node

The one configuration knob worth calling out is axe’s rule set. In jsdom you should disable the color-contrast rule explicitly, because the engine cannot resolve painted colors and will emit noise. Every axe-focused test can pass { rules: { 'color-contrast': { enabled: false } } }, and you recover that check in a real browser as covered in the Playwright component testing area.

Verification & Assertions

The assertions in this area verify semantics, not markup. A good accessibility test never inspects a class name or a data-testid; it queries the accessibility tree and asserts on what a screen reader would receive. The canonical proof points are: a control is reachable by role and has the expected accessible name; every interactive element is operable by keyboard and appears in a sensible tab order; an automated axe scan returns zero violations for the rules that jsdom can evaluate; and any overlay traps focus while open and restores it to the trigger on close. When all four hold for a component, you have strong evidence — though never a complete guarantee — that assistive-technology users can operate it.

The honesty caveat matters. Automated tooling like axe-core catches a well-studied fraction of WCAG issues, commonly cited around 30 to 40 percent; the rest — meaningful reading order, sensible alternative text, logical heading structure, whether an announcement actually makes sense — require human judgment. So the assertions here are a floor, not a ceiling. Treat a green suite as “no known machine-detectable regressions,” pair it with periodic manual review using a real screen reader, and resist the temptation to believe that toHaveNoViolations() means the component is accessible. It means the component is not obviously inaccessible in the ways a static analyzer can see.

Automated coverage versus the full accessibility surface Automated axe checks cover roughly a third of WCAG issues; the remainder needs manual review with a real screen reader. What automation covers Full WCAG success-criteria surface Automated (axe) ~30–40% machine-detectable Manual review required reading order, alt text, real screen-reader passes A green axe run is a floor, not proof of accessibility
Automated checks are a necessary floor; the majority of accessibility still needs human review.

Edge Cases & Failure Modes

The failure modes in accessibility testing gather around one gap: the distance between the DOM you wrote and the accessibility tree the browser computed. The first is the silent role mismatch: a <div role="button"> with an onClick looks fine visually and even passes a naive getByText query, but it is not keyboard-operable and has no accessible name unless you add tabindex, a key handler, and a label. A role query written to match the intended semantics will fail loudly, which is exactly what you want. The second is the portal blind spot: modals, tooltips, and menus rendered through React portals live outside the component’s container in the DOM, so an axe(container) scan that only covers the local subtree misses them entirely — you must scan document.body or the portal root instead.

A third recurring trap is the conditional label: a control whose accessible name depends on state — an icon-only toggle that should read “Mute” then “Unmute” — passes a single-state test but regresses when the label fails to update. Assert the name in both states. The fourth is asynchronous name resolution, where an accessible name arrives after a data fetch; here you must await a findByRole query rather than a synchronous getByRole, and you must be careful with the act() boundary so the assertion runs after React commits. When these components fetch, use MSW v2 to resolve the request deterministically so the accessible name is stable by the time you query it.

A fifth failure mode deserves its own mention because it is the one teams most often argue about: the false-confidence pass. A component can satisfy every automated assertion in this area and still be unusable, because the assertions verify mechanics — a role exists, a name is present, focus moves — not meaning. An error message can be correctly associated with aria-describedby and still say nothing actionable; a tab order can be technically correct and still make no sense to someone who cannot see the layout; a live region can update at exactly the wrong moment and interrupt a screen reader mid-sentence. None of these are catchable by any tool in this tier, which is why every guide here frames its automated checks as a regression guard rather than a certification. The correct posture is to make the machine-detectable failures impossible to reintroduce, then spend human review time on the judgment-dependent remainder — which is where the real accessibility work lives.

Performance & CI Impact

Accessibility assertions divide cleanly into cheap and less-cheap. Role queries and jest-dom matchers cost essentially nothing beyond the render you were already paying for; they walk the accessibility tree Testing Library has already computed. Keyboard simulation through user-event is marginally more expensive because it dispatches a realistic event sequence, but it stays well under a millisecond per interaction. The one line item to watch is the axe scan: axe-core walks the DOM and evaluates dozens of rules, which adds tens of milliseconds per call. On a component with a handful of scans that is invisible, but a suite that calls axe() in hundreds of tests will feel it. The fix is to scan once per meaningful DOM state rather than after every trivial interaction, and to disable rules jsdom cannot evaluate so axe does no wasted work.

In CI, keep these tests in the same fast Vitest lane as the rest of your component suite rather than isolating them, because their whole value is fast feedback on every pull request. They parallelize linearly across workers like any other jsdom test, and they need no browser, no server, and no network when requests are mocked. That is the payoff of drawing the scope boundary strictly: everything in this area runs in the cheap tier, and the expensive browser checks stay small and separate. For teams sizing this investment, the same cost-benefit reasoning about test layers that governs the pyramid applies — accessibility checks at the component tier buy a great deal of confidence per second of CI time.

The sequencing advice is worth stating plainly because it drives the whole cost profile. Run role queries and keyboard assertions freely, since they piggyback on renders you already pay for, and reserve the axe scan for the one or two DOM states per component that genuinely differ — initial, error, loaded — rather than after every keystroke. A suite disciplined this way keeps its accessibility coverage broad while its wall-clock cost stays close to that of the plain behavioral suite it sits beside, which is exactly what makes running it on every pull request sustainable rather than a periodic special event. When a check truly needs painted pixels, promote just that check to a browser-driven run and leave everything else here, where it is cheapest.

In This Topic Area