testing and automating accessibility
Component Testing with jest-axe
jest-axe runs the axe-core rules engine against the DOM your React component renders, so accessibility violations fail your unit suite the same way a broken assertion does. Wired into Testing Library, it turns the abstract goal of "accessible components" into a concrete, fast, per-component gate that runs on every commit. This guide sits under Testing and Automating Accessibility and focuses on the component layer: render a component, run axe against its container, assert zero violations, and then test the dynamic states—open modals, error forms, expanded menus—where regressions actually hide. Axe-core enforces machine-checkable subsets of 1.3.1 Info and Relationships, 4.1.2 Name, Role, Value, and 3.3.2 Labels or Instructions, while you pair it with role/name queries to assert the contracts axe cannot evaluate on its own.
The trade-off to understand up front: jest-axe runs inside jsdom, which has no layout engine and no rendering. That makes it ideal for structural rules (missing labels, broken aria-* references, invalid roles) and useless for anything requiring computed geometry or color—those checks belong in End-to-End Accessibility Testing with Playwright. Knowing which layer owns which rule is the difference between a green suite that means something and one that lies.
Wiring jest-axe into Testing Library
jest-axe ships an axe runner and a custom matcher, toHaveNoViolations. The matcher must be registered once via expect.extend before any test asserts against it—do this in your global setup file so every spec inherits it.
// test-setup.ts — loaded via setupFilesAfterEach in jest.config
import '@testing-library/jest-dom';
import { toHaveNoViolations } from 'jest-axe';
import { cleanup } from '@testing-library/react';
// Register the matcher globally so every spec can call expect(results).toHaveNoViolations()
expect.extend(toHaveNoViolations);
// Unmount components between tests so axe never inspects leaked DOM from a prior render
afterEach(() => cleanup());
// jest.config.js
module.exports = {
testEnvironment: 'jsdom', // axe-core needs a DOM; jsdom provides one (without layout)
setupFilesAfterEach: ['<rootDir>/test-setup.ts'],
};
The jsdom environment is mandatory: axe-core walks a live DOM tree, so a node-only environment has nothing to inspect. With the matcher registered, every test that renders a component can run axe and assert against the structured result. This is the foundation for the step-by-step walkthrough in Testing React Components with jest-axe.
Rendering a Component and Running axe Against the Container
The core pattern is three lines: render the component, pass the rendered container to axe, and assert no violations. Scoping axe to container (not document) keeps the check focused on the component under test rather than test-runner scaffolding.
import { render } from '@testing-library/react';
import { axe } from 'jest-axe';
import { PriceCard } from './PriceCard';
test('PriceCard has no axe violations', async () => {
const { container } = render(<PriceCard plan="pro" price={49} />);
// axe() is async: it loads rules and walks the DOM, resolving to a results object
const results = await axe(container);
// The matcher fails with a readable diff listing rule id, impact, and offending nodes
expect(results).toHaveNoViolations();
});
This catches the rules axe can evaluate without layout: a <button> with no accessible name, an <img> missing alt, an aria-labelledby pointing at a non-existent id, a list item outside a list, duplicate id attributes feeding aria-* references. These map directly to 4.1.2 Name, Role, Value and 1.3.1 Info and Relationships. Because axe() is asynchronous, always await it—an unawaited promise produces a passing test that checked nothing.
One caveat: a component that renders to a React portal lands outside container, so scoping to container will silently skip it. That scope decision is the single most common source of false-green tests, covered in depth in the CI debugging guide below.
Scoping and Disabling Specific Rules Per Test
Axe-core runs its full ruleset by default. Sometimes you need to narrow it—either to assert one specific rule in isolation, or to suppress a rule that cannot run meaningfully in jsdom. Pass a configuration object as the second argument to axe.
test('Avatar disables the contrast rule it cannot evaluate in jsdom', async () => {
const { container } = render(<Avatar name="Ada Lovelace" />);
const results = await axe(container, {
rules: {
// color-contrast needs computed layout/paint, which jsdom lacks—turn it off
// rather than let it false-negative. Real contrast lives in Playwright/CI.
'color-contrast': { enabled: false },
},
});
expect(results).toHaveNoViolations();
});
You can also restrict the run to specific tags or a single rule when writing a targeted regression test:
const results = await axe(container, {
runOnly: { type: 'rule', values: ['button-name'] }, // assert only that buttons are named
});
Disable rules deliberately and document why. A blanket disable hides real defects; a scoped, commented disable communicates that the rule belongs to a different test layer. Resist suppressing a rule just to make a test pass—that converts an accessibility gate into decorative noise. For project-wide axe configuration shared across component and end-to-end suites, see Automated Accessibility Testing with axe-core.
Testing Dynamic Component States
A component's initial render is rarely where accessibility breaks. Regressions appear when a modal opens, a form surfaces validation errors, or a menu expands. Each of these is a distinct DOM state, and each needs its own axe pass. Drive the component into the state with Testing Library, wait for it to settle, then run axe.
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { axe } from 'jest-axe';
import { SettingsDialog } from './SettingsDialog';
test('open dialog has no violations and a proper dialog contract', async () => {
const user = userEvent.setup();
const { container } = render(<SettingsDialog />);
// Drive the component into its OPEN state—the initial render never showed the dialog
await user.click(screen.getByRole('button', { name: /open settings/i }));
// findByRole waits for the dialog to mount before axe inspects it
const dialog = await screen.findByRole('dialog');
// Run axe on the now-open DOM; this catches a focus-trap container with a missing name
const results = await axe(container);
expect(results).toHaveNoViolations();
// aria-modal is a contract axe will not enforce for you; assert it explicitly
expect(dialog).toHaveAttribute('aria-modal', 'true');
});
The same discipline applies to a form in its error state: submit invalid data, wait for the error region, then run axe to confirm each invalid field carries aria-invalid and an aria-describedby that resolves to the visible error text—the structural side of 3.3.1 Error Identification. An expanded disclosure menu needs aria-expanded="true" and a aria-controls target present in the DOM. Run axe once per meaningful state; a single render-time pass leaves the most fragile transitions untested.
Combining axe With Role and Name Queries
Axe-core verifies that the DOM is structurally valid—but it does not know your intent. It cannot tell you that the dialog should be labeled "Edit profile," that the error message should be associated with the email field, or that the active tab should be Account. Those are application contracts, and you assert them with Testing Library's getByRole and accessible-name queries alongside the axe pass.
test('error message is associated with the field it describes', async () => {
const user = userEvent.setup();
const { container } = render(<EmailForm />);
await user.click(screen.getByRole('button', { name: /save/i }));
// Testing Library resolves the field by its accessible name—the same name AT exposes
const email = await screen.findByRole('textbox', { name: /email/i });
// axe confirms the wiring is structurally valid (id targets resolve, role is correct)
expect(await axe(container)).toHaveNoViolations();
// Then assert the semantic contract axe cannot: invalid state + the RIGHT description
expect(email).toHaveAttribute('aria-invalid', 'true');
expect(email).toHaveAccessibleDescription(/enter a valid email/i);
});
The division is clean: axe owns "is this structurally legal," getByRole/name queries own "does it mean the right thing." Querying by role and accessible name also forces your tests to interact with the component the way assistive technology does, which surfaces naming regressions that a data-testid selector would sail past—reinforcing 4.1.2 Name, Role, Value at the assertion level.
jsdom Limitations and What to Push to Playwright
jsdom implements the DOM API but has no layout or paint pipeline. Element geometry is effectively zero, computed styles are incomplete, and nothing is ever truly "visible" in a rendered sense. Several axe-core rules depend on exactly that information and therefore cannot produce trustworthy results in jest-axe:
color-contrast— needs computed foreground/background colors and font metrics; runs as a no-op or false result in jsdom. Owns1.4.3 Contrast (Minimum).- Visibility-dependent rules — anything that checks whether an element is on-screen, occluded, or sized below a target threshold has no geometry to read.
target-size(2.5.8 Target Size (Minimum)) — requires real pixel dimensions jsdom never computes.- Reflow and zoom behavior (
1.4.10 Reflow) — inherently a rendered-viewport concern.
Disable these in your jest-axe config so they cannot false-negative, and assert them in a real browser instead. End-to-End Accessibility Testing with Playwright runs the same axe-core engine against Chromium, where layout and color exist, making it the correct home for contrast, target size, and focus-visibility checks. Use component tests for fast, structural feedback on every state; use end-to-end tests for the rendered truth.
How to verify: Run npx jest --runInBand locally and confirm the suite fails loudly when you delete a label or break an aria-describedby reference—if it stays green, your scope or await is wrong. Then run an end-to-end axe pass for the layout/color rules jsdom skipped, and finish with a manual sweep in NVDA or VoiceOver to confirm the announced names and states match your role/name assertions.
Key Takeaways
- Register
toHaveNoViolationsonce viaexpect.extendin global setup; require thejsdomenvironment. - The base pattern is
render→await axe(container)→expect(results).toHaveNoViolations(); alwaysawait. - Scope axe to the rendered
container, but remember portals escape it—scope todocumentwhen testing portal content. - Test every meaningful dynamic state (open modal, error form, expanded menu) with its own axe pass.
- Pair axe with
getByRole/accessible-name queries to assert the semantic contracts axe cannot check. - Disable layout- and color-dependent rules in jsdom and push them to Playwright.
Frequently Asked Questions
Why does my jest-axe test pass even though the component is clearly inaccessible?
The most common causes are an unawaited axe() call (the promise never resolves before the assertion), scoping to container when the content renders into a portal on document.body, or running axe before the dynamic state has mounted. Confirm you await axe(...), choose the right scope, and use findBy/waitFor to let async DOM settle first.
Can jest-axe catch color-contrast problems?
No—jsdom has no layout or rendering, so the color-contrast rule cannot compute foreground and background colors and either no-ops or returns an unreliable result. Disable it in your jest-axe config and verify contrast in a real browser via Playwright.
Should I run axe on the whole document or just the component's container?
Default to the rendered container so the check stays focused on the component under test. Switch to document only when the component renders into a portal (modals, tooltips, toasts) that mounts outside that container—otherwise axe silently skips the portal content.
Does passing jest-axe mean my component is fully accessible?
No. Axe-core catches a machine-checkable subset of WCAG—missing names, broken references, invalid roles. It cannot judge whether a label is correct, whether focus moves sensibly, or whether contrast passes in jsdom. Pair axe with getByRole/name assertions, end-to-end tests, and manual screen-reader checks.
Where should I disable an axe rule—per test or globally?
Disable per test when the rule is irrelevant to that specific component, and globally (in shared config) only for rules that can never run meaningfully in jsdom, like color-contrast. Always add a comment explaining why, so a suppressed rule reads as a deliberate layer decision rather than a hidden defect.