React Accessibility Testing with jest-axe: Unit Testing for A11y
End-to-end accessibility tests catch page-level violations. But component-level accessibility bugs — a missing label on a custom dropdown, an ARIA role on the wrong element, a modal that doesn't trap focus correctly — are best caught in unit tests where feedback is fast and failures are isolated. jest-axe brings the axe-core engine into your Jest test suite, letting you assert accessibility on individual React components using the same tools that power browser extensions and E2E accessibility tests.
Installation
npm install --save-dev jest-axe @testing-library/react @testing-library/jest-domIf you're using TypeScript:
npm install --save-dev @types/jest-axeBasic Setup
Configure jest-axe's custom matchers in your Jest setup file:
// jest.setup.js (or setupFilesAfterFramework in jest.config.js)
import { toHaveNoViolations } from 'jest-axe';
expect.extend(toHaveNoViolations);In your Jest config:
// jest.config.js
module.exports = {
setupFilesAfterFramework: ['./jest.setup.js'],
testEnvironment: 'jsdom',
};Your First Accessibility Test
import React from 'react';
import { render } from '@testing-library/react';
import { axe, toHaveNoViolations } from 'jest-axe';
expect.extend(toHaveNoViolations);
describe('Button component', () => {
it('should have no accessibility violations', async () => {
const { container } = render(<button type="button">Save changes</button>);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
});When this test fails, the error message lists each violation clearly:
expect(received).toHaveNoViolations(expected)
Expected the HTML found at $('button') to have no violations:
<button>Save</button>
Received:
"button-name" (moderate): Buttons must have discernible text
Fix any of the following:
Element does not have inner text that is visible to screen readersTesting Components with Interactive States
Static renders catch missing ARIA attributes and structural problems. But some violations only appear in specific states — open menus, error messages, focused elements. Use Testing Library's user interactions to test those:
import React, { useState } from 'react';
import { render, fireEvent, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { axe } from 'jest-axe';
// Component under test
function Accordion({ title, children }: { title: string; children: React.ReactNode }) {
const [isOpen, setIsOpen] = useState(false);
const id = 'accordion-panel';
return (
<div>
<button
type="button"
aria-expanded={isOpen}
aria-controls={id}
onClick={() => setIsOpen(!isOpen)}
>
{title}
</button>
<div id={id} hidden={!isOpen} role="region" aria-label={title}>
{children}
</div>
</div>
);
}
describe('Accordion', () => {
it('is accessible in collapsed state', async () => {
const { container } = render(
<Accordion title="Shipping options">
<p>Standard shipping: 5-7 days</p>
</Accordion>
);
expect(await axe(container)).toHaveNoViolations();
});
it('is accessible in expanded state', async () => {
const user = userEvent.setup();
const { container, getByRole } = render(
<Accordion title="Shipping options">
<p>Standard shipping: 5-7 days</p>
</Accordion>
);
await user.click(getByRole('button', { name: 'Shipping options' }));
expect(await axe(container)).toHaveNoViolations();
});
});Testing Error States
Form validation errors are a common source of accessibility failures — error messages not associated with fields, missing aria-invalid, missing aria-describedby:
function EmailField() {
const [value, setValue] = useState('');
const [error, setError] = useState('');
const errorId = 'email-error';
const validate = () => {
if (!value.includes('@')) {
setError('Please enter a valid email address');
} else {
setError('');
}
};
return (
<div>
<label htmlFor="email">Email address</label>
<input
id="email"
type="email"
value={value}
onChange={(e) => setValue(e.target.value)}
onBlur={validate}
aria-invalid={error ? 'true' : undefined}
aria-describedby={error ? errorId : undefined}
/>
{error && (
<span id={errorId} role="alert">
{error}
</span>
)}
</div>
);
}
describe('EmailField', () => {
it('is accessible in default state', async () => {
const { container } = render(<EmailField />);
expect(await axe(container)).toHaveNoViolations();
});
it('is accessible in error state', async () => {
const user = userEvent.setup();
const { container, getByRole } = render(<EmailField />);
await user.type(getByRole('textbox', { name: 'Email address' }), 'notanemail');
await user.tab(); // trigger onBlur
expect(await axe(container)).toHaveNoViolations();
});
});Testing Focus State
Focus management is critical for modal dialogs and other components that manipulate focus programmatically:
function Modal({ isOpen, onClose, children }: {
isOpen: boolean;
onClose: () => void;
children: React.ReactNode;
}) {
const modalRef = React.useRef<HTMLDivElement>(null);
React.useEffect(() => {
if (isOpen && modalRef.current) {
modalRef.current.focus();
}
}, [isOpen]);
if (!isOpen) return null;
return (
<div
role="dialog"
aria-modal="true"
aria-label="Settings"
ref={modalRef}
tabIndex={-1}
>
<button type="button" onClick={onClose} aria-label="Close settings">
×
</button>
{children}
</div>
);
}
describe('Modal', () => {
it('is accessible when open', async () => {
const { container } = render(
<Modal isOpen={true} onClose={() => {}}>
<p>Modal content</p>
</Modal>
);
expect(await axe(container)).toHaveNoViolations();
});
it('is accessible when closed (renders nothing)', async () => {
const { container } = render(
<Modal isOpen={false} onClose={() => {}}>
<p>Modal content</p>
</Modal>
);
expect(await axe(container)).toHaveNoViolations();
});
});Custom axe-core Rules
Configure which rules run using the second argument to axe():
// Run only WCAG 2.1 AA rules
const results = await axe(container, {
runOnly: {
type: 'tag',
values: ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'],
},
});
// Disable a specific rule
const results = await axe(container, {
rules: {
'color-contrast': { enabled: false },
},
});
// Run specific rules only
const results = await axe(container, {
runOnly: {
type: 'rule',
values: ['label', 'aria-required-attr', 'button-name'],
},
});You can create a shared axe configuration for your project:
// test-utils/axe-config.ts
import { AxeResults, RunOptions } from 'axe-core';
export const defaultAxeConfig: RunOptions = {
runOnly: {
type: 'tag',
values: ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'],
},
rules: {
// Color contrast requires real browser rendering — skip in jsdom
'color-contrast': { enabled: false },
},
};
// Use in tests
const results = await axe(container, defaultAxeConfig);Note on color-contrast: jsdom doesn't compute actual CSS styles, so color contrast rules may produce unreliable results in unit tests. Disable it here and cover it with E2E accessibility tests (axe-core/Playwright) where a real browser renders styles.
Common React A11y Patterns
aria-label vs aria-labelledby
Use aria-label for elements without a visible label:
// Icon button — no visible text
function IconButton({ onClick, label }: { onClick: () => void; label: string }) {
return (
<button type="button" onClick={onClick} aria-label={label}>
<SearchIcon aria-hidden="true" />
</button>
);
}
// Test
it('icon button has accessible label', async () => {
const { container } = render(
<IconButton onClick={() => {}} label="Search" />
);
expect(await axe(container)).toHaveNoViolations();
});Use aria-labelledby to reference an existing visible label:
function Section({ id, title, children }: {
id: string;
title: string;
children: React.ReactNode;
}) {
return (
<section aria-labelledby={`${id}-title`}>
<h2 id={`${id}-title`}>{title}</h2>
{children}
</section>
);
}aria-describedby
Associate helper text and error messages with form fields:
function PasswordField() {
const helpId = 'password-help';
return (
<div>
<label htmlFor="password">Password</label>
<input
id="password"
type="password"
aria-describedby={helpId}
minLength={8}
/>
<span id={helpId}>
Minimum 8 characters, including one number
</span>
</div>
);
}
it('password field has accessible description', async () => {
const { container } = render(<PasswordField />);
expect(await axe(container)).toHaveNoViolations();
});ARIA Landmark Roles
Every page needs a logical landmark structure. In component tests, test the landmark structure of layout components:
function AppLayout({ children }: { children: React.ReactNode }) {
return (
<>
<header>
<nav aria-label="Main navigation">
{/* nav links */}
</nav>
</header>
<main id="main-content">
{children}
</main>
<footer>
<nav aria-label="Footer navigation">
{/* footer links */}
</nav>
</footer>
</>
);
}
it('app layout has correct landmark structure', async () => {
const { container } = render(
<AppLayout>
<h1>Dashboard</h1>
<p>Content here</p>
</AppLayout>
);
expect(await axe(container)).toHaveNoViolations();
});Note: axe-core's region rule requires all content to be inside landmarks. If you have multiple <nav> elements, each must have a unique aria-label or aria-labelledby.
Custom Select / Combobox
Custom dropdowns are notoriously difficult to get right. The ARIA pattern for a combobox is complex:
function ComboBox({ options, label, onChange }: {
options: string[];
label: string;
onChange: (value: string) => void;
}) {
const [isOpen, setIsOpen] = useState(false);
const [selected, setSelected] = useState('');
const [activeIndex, setActiveIndex] = useState(-1);
const listId = `combobox-list-${label.replace(/\s/g, '-').toLowerCase()}`;
return (
<div>
<label id={`${listId}-label`}>{label}</label>
<div
role="combobox"
aria-expanded={isOpen}
aria-haspopup="listbox"
aria-owns={listId}
aria-labelledby={`${listId}-label`}
>
<input
type="text"
value={selected}
readOnly
aria-autocomplete="list"
aria-controls={listId}
aria-activedescendant={
activeIndex >= 0 ? `${listId}-option-${activeIndex}` : undefined
}
onClick={() => setIsOpen(!isOpen)}
/>
</div>
<ul
id={listId}
role="listbox"
aria-labelledby={`${listId}-label`}
hidden={!isOpen}
>
{options.map((option, index) => (
<li
key={option}
id={`${listId}-option-${index}`}
role="option"
aria-selected={option === selected}
onClick={() => {
setSelected(option);
onChange(option);
setIsOpen(false);
}}
>
{option}
</li>
))}
</ul>
</div>
);
}
describe('ComboBox', () => {
it('is accessible when closed', async () => {
const { container } = render(
<ComboBox
options={['Option 1', 'Option 2', 'Option 3']}
label="Select an option"
onChange={() => {}}
/>
);
expect(await axe(container)).toHaveNoViolations();
});
it('is accessible when open', async () => {
const user = userEvent.setup();
const { container, getByRole } = render(
<ComboBox
options={['Option 1', 'Option 2', 'Option 3']}
label="Select an option"
onChange={() => {}}
/>
);
await user.click(getByRole('textbox'));
expect(await axe(container)).toHaveNoViolations();
});
});Testing Data Tables
Tables need proper structure for screen reader announcement:
function DataTable({ headers, rows }: {
headers: string[];
rows: (string | number)[][];
}) {
return (
<table>
<thead>
<tr>
{headers.map((h) => (
<th key={h} scope="col">{h}</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row, i) => (
<tr key={i}>
{row.map((cell, j) => (
j === 0
? <th key={j} scope="row">{cell}</th>
: <td key={j}>{cell}</td>
))}
</tr>
))}
</tbody>
</table>
);
}
it('data table has correct structure', async () => {
const { container } = render(
<DataTable
headers={['Name', 'Status', 'Last Run']}
rows={[
['Login test', 'Passed', '2 hours ago'],
['Checkout test', 'Failed', '30 minutes ago'],
]}
/>
);
expect(await axe(container)).toHaveNoViolations();
});WCAG 2.1 AA Coverage in Component Tests
jest-axe with the wcag21aa tag set covers these criteria automatically:
| WCAG Criterion | What axe checks |
|---|---|
| 1.1.1 Non-text content | image-alt, input-image-alt |
| 1.3.1 Info and relationships | label, aria-required-attr, table |
| 1.3.5 Identify input purpose | autocomplete-valid |
| 2.4.3 Focus order | scrollable-region-focusable |
| 2.4.4 Link purpose | link-name |
| 2.5.3 Label in name | label-content-name-mismatch |
| 4.1.1 Parsing | duplicate-id-aria |
| 4.1.2 Name, role, value | aria-required-attr, aria-valid-attr |
| 4.1.3 Status messages | aria-live-region-item |
Criteria that require manual or E2E testing:
- 1.4.3 Color contrast (requires real CSS rendering)
- 2.1.1 Keyboard — requires actual keyboard interaction testing
- 2.4.7 Focus visible — requires visual verification
- 3.3.1 Error identification — partially covered; context determines adequacy
Organizing Accessibility Tests
Co-locate accessibility tests with your component tests, but make them easy to run in isolation:
src/
components/
Button/
Button.tsx
Button.test.tsx # includes a11y tests
Button.a11y.test.tsx # dedicated a11y tests (optional for complex components)
Modal/
Modal.tsx
Modal.test.tsx
Modal.a11y.test.tsxFor the dedicated a11y test file approach:
// Modal.a11y.test.tsx
describe('Modal accessibility', () => {
it.each([
['open', true],
['closed', false],
])('is accessible when %s', async (_, isOpen) => {
const { container } = render(
<Modal isOpen={isOpen} onClose={() => {}}>
<p>Content</p>
</Modal>
);
expect(await axe(container)).toHaveNoViolations();
});
it('error state is accessible', async () => {
const { container } = render(
<Modal isOpen={true} onClose={() => {}} error="Something went wrong">
<p>Content</p>
</Modal>
);
expect(await axe(container)).toHaveNoViolations();
});
});Run only a11y tests:
jest --testPathPattern="\.a11y\.test\."Performance Considerations
axe-core analysis is synchronous CPU work in Node.js. For large component trees, it can take 200–500ms per axe() call. Keep tests focused on the component under test — don't render the entire app for component-level a11y tests.
For shared test utilities, create a wrapper that times axe calls:
// test-utils/axe.ts
import { axe, toHaveNoViolations } from 'jest-axe';
expect.extend(toHaveNoViolations);
export async function checkA11y(container: HTMLElement) {
const start = Date.now();
const results = await axe(container, {
runOnly: {
type: 'tag',
values: ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'],
},
});
const duration = Date.now() - start;
if (duration > 1000) {
console.warn(`axe scan took ${duration}ms — consider scoping to a smaller element`);
}
return results;
}Snapshot Testing for Accessibility
Jest snapshots can track which WCAG violations exist in a component — useful for documenting known violations without making tests permanently fail:
it('documents known accessibility violations', async () => {
const { container } = render(<LegacyComponent />);
const results = await axe(container);
// Snapshot the violation IDs — test fails if new violations appear
const violationIds = results.violations.map((v) => v.id).sort();
expect(violationIds).toMatchSnapshot();
});Update with jest --updateSnapshot only when intentionally changing the accessibility state of a component. This approach is a fallback — the goal is always zero violations.
jest-axe is the fastest way to catch accessibility regressions at the component level. The feedback loop is immediate (component unit test vs. browser E2E test), the violations are isolated to the component under test, and it runs in the same Jest run as your existing tests. Start by adding axe assertions to your most-used shared components — forms, modals, navigation, data tables — and expand coverage as you go.