Automated Accessibility Testing with axe-core and jest-axe — testing web accessibility for WCAG 2.1 AA compliance.
axe-core is the most widely used automated accessibility testing library, powering tools like Deque's browser extension, jest-axe, and @axe-core/playwright. It implements hundreds of WCAG rules and can be integrated into React component tests, API tests, and E2E tests.
This lesson covers integrating axe-core at all three testing levels: jest-axe for React component unit tests, @axe-core/playwright for page-level E2E tests, and interpreting the violation output to fix issues efficiently.
1npm install --save-dev jest-axe @types/jest-axe1// apps/web/src/components/auth/LoginForm.test.tsx
2import { render } from '@testing-library/react';
3import { axe, toHaveNoViolations } from 'jest-axe';
4import { LoginForm } from './LoginForm';
5
6expect.extend(toHaveNoViolations);
7
8describe('LoginForm accessibility', () => {
9 it('has no axe violations', async () => {
10 const { container } = render(<LoginForm onSubmit={jest.fn()} />);
11 const results = await axe(container);
12 expect(results).toHaveNoViolations();
13 });
14
15 it('has no violations with error state', async () => {
16 const { container } = render(
17 <LoginForm onSubmit={jest.fn()} error="Invalid credentials" />
18 );
19 const results = await axe(container);
20 expect(results).toHaveNoViolations();
21 });
22
23 it('has no violations while loading', async () => {
24 const { container } = render(<LoginForm onSubmit={jest.fn()} isLoading />);
25 const results = await axe(container);
26 expect(results).toHaveNoViolations();
27 });
28});axe-core catches in component tests:
1import { axe } from 'jest-axe';
2
3// Run only specific WCAG rule sets
4const results = await axe(container, {
5 runOnly: {
6 type: 'tag',
7 values: ['wcag2a', 'wcag2aa', 'wcag21aa'],
8 },
9});
10
11// Disable a specific rule with documentation
12const results = await axe(container, {
13 rules: {
14 // Disable color-contrast check in unit tests
15 // (computed styles not available in jsdom)
16 'color-contrast': { enabled: false },
17 },
18});
19
20// Interpret violation output:
21// results.violations is an array of:
22// {
23// id: 'button-name',
24// impact: 'critical', // critical | serious | moderate | minor
25// description: 'Ensures buttons have discernible text',
26// nodes: [{ html: '<button>...</button>', target: ['button'] }]
27// }
28
29// Custom assertion with helpful error message:
30if (results.violations.length > 0) {
31 const report = results.violations.map(v =>
32 `[${v.impact}] ${v.id}: ${v.description}\n` +
33 v.nodes.map(n => ' HTML: ' + n.html.substring(0, 100)).join('\n')
34 ).join('\n\n');
35 throw new Error('Accessibility violations found:\n' + report);
36}1npm install --save-dev @axe-core/playwright1import { test, expect } from '@playwright/test';
2import AxeBuilder from '@axe-core/playwright';
3
4test.describe('Page-level accessibility', () => {
5 test('courses page has no critical violations', async ({ page }) => {
6 await page.goto('/courses');
7 await page.waitForLoadState('networkidle');
8
9 const results = await new AxeBuilder({ page })
10 .withTags(['wcag2a', 'wcag2aa'])
11 .exclude('#cookie-banner') // exclude third-party widget
12 .analyze();
13
14 // Filter to critical/serious only for CI gate
15 const blocking = results.violations.filter(v =>
16 ['critical', 'serious'].includes(v.impact || '')
17 );
18
19 expect(blocking).toHaveLength(0);
20 });
21
22 test('checks all states of interactive components', async ({ page }) => {
23 await page.goto('/courses');
24
25 // Check default state
26 let results = await new AxeBuilder({ page }).analyze();
27 expect(results.violations.filter(v => v.impact === 'critical')).toHaveLength(0);
28
29 // Open a modal and re-check
30 await page.click('[data-testid="filter-modal-trigger"]');
31 await page.waitForSelector('[role="dialog"]');
32 results = await new AxeBuilder({ page })
33 .include('[role="dialog"]') // check only the modal
34 .analyze();
35 expect(results.violations.filter(v => v.impact === 'critical')).toHaveLength(0);
36 });
37});1npm install --save-dev pa11y-ci1// .pa11yci.json
2{
3 "defaults": {
4 "standard": "WCAG2AA",
5 "runners": ["axe", "htmlcs"],
6 "timeout": 10000,
7 "viewport": { "width": 1280, "height": 800 },
8 "ignore": ["color-contrast"]
9 },
10 "urls": [
11 "http://localhost:3000/",
12 "http://localhost:3000/courses",
13 "http://localhost:3000/login",
14 "http://localhost:3000/register"
15 ]
16}1# .github/workflows/a11y.yml
2- name: Run pa11y-ci
3 run: |
4 npm start &
5 npx wait-on http://localhost:3000
6 npx pa11y-ci --reporter json > a11y-results.json 2>&1 || true
7 node -e "
8 const r = require('./a11y-results.json');
9 const total = Object.values(r.results).reduce((a, c) => a + c.issues?.length || 0, 0);
10 if (total > 0) { console.error(total + ' a11y issues found'); process.exit(1); }
11 console.log('PASS: No a11y issues');
12 "1import { render, screen } from '@testing-library/react';
2import userEvent from '@testing-library/user-event';
3
4describe('Form accessibility with RTL', () => {
5 it('error message is associated with input via aria-describedby', async () => {
6 render(<EmailInput error="Invalid email format" />);
7
8 const input = screen.getByRole('textbox', { name: /email/i });
9 const errorId = input.getAttribute('aria-describedby');
10 expect(errorId).toBeTruthy();
11
12 const errorMsg = document.getElementById(errorId!);
13 expect(errorMsg).toHaveTextContent('Invalid email format');
14 });
15
16 it('loading state is communicated to screen readers', async () => {
17 render(<SubmitButton isLoading />);
18 // aria-busy or aria-label change communicates loading state
19 const button = screen.getByRole('button');
20 const isBusy = button.getAttribute('aria-busy') === 'true' ||
21 button.textContent?.includes('Loading') ||
22 button.getAttribute('aria-label')?.includes('loading');
23 expect(isBusy).toBe(true);
24 expect(button).toBeDisabled();
25 });
26
27 it('toast notification uses role=status for announcements', async () => {
28 render(<Toast message="Course enrolled successfully!" type="success" />);
29 // role=status or aria-live=polite for non-critical announcements
30 const toast = screen.getByRole('status');
31 expect(toast).toHaveTextContent('Course enrolled successfully!');
32 });
33});A working implementation demonstrating core patterns and best practices.
1// apps/web/src/components/__tests__/accessibility.spec.tsx
2import { render, screen } from '@testing-library/react';
3import userEvent from '@testing-library/user-event';
4import { axe, toHaveNoViolations } from 'jest-axe';
5
6expect.extend(toHaveNoViolations);
7
8// Test every major component for axe violations
9const components = [
10 { name: 'CourseCard', Component: () => <CourseCard course={mockCourse} /> },
11 { name: 'LoginForm', Component: () => <LoginForm onSubmit={jest.fn()} /> },
12 { name: 'NavigationMenu', Component: () => <NavigationMenu /> },
13 { name: 'SearchBar', Component: () => <SearchBar onSearch={jest.fn()} /> },
14];
15
16describe.each(components)('$name accessibility', ({ Component }) => {
17 it('has no axe violations in default state', async () => {
18 const { container } = render(<Component />);
19 const results = await axe(container, {
20 runOnly: { type: 'tag', values: ['wcag2a', 'wcag2aa'] },
21 rules: { 'color-contrast': { enabled: false } }, // computed styles unavailable in jsdom
22 });
23 expect(results).toHaveNoViolations();
24 });
25});
26
27describe('CourseCard accessibility details', () => {
28 const mockCourse = {
29 id: 'c1', title: 'TypeScript Mastery', slug: 'typescript-mastery',
30 price: 79.99, rating: 4.8, lessonCount: 52,
31 thumbnailUrl: 'https://example.com/thumb.avif',
32 instructor: { name: 'Jane Doe', avatarUrl: null },
33 };
34
35 it('thumbnail has descriptive alt text', () => {
36 render(<CourseCard course={mockCourse} />);
37 const img = screen.getByRole('img', { name: /TypeScript Mastery/i });
38 expect(img).toBeInTheDocument();
39 expect(img).toHaveAttribute('alt');
40 expect(img.getAttribute('alt')).not.toBe('');
41 });
42
43 it('card link has descriptive accessible name', () => {
44 render(<CourseCard course={mockCourse} />);
45 const link = screen.getByRole('link', { name: /TypeScript Mastery/i });
46 expect(link).toBeInTheDocument();
47 // Link text is not just 'Read more' or generic
48 expect(link.textContent?.length).toBeGreaterThan(5);
49 });
50
51 it('price is announced with currency context', () => {
52 render(<CourseCard course={mockCourse} />);
53 // Screen readers need currency context, not just '$79.99'
54 const priceEl = screen.getByText(/79\.99/);
55 const ariaLabel = priceEl.getAttribute('aria-label');
56 // Either aria-label has 'dollars' or nearby text provides context
57 const hasContext = ariaLabel?.includes('dollar') ||
58 priceEl.closest('[aria-label]')?.getAttribute('aria-label')?.includes('price');
59 // This is informational — log if missing but don't fail hard
60 if (!hasContext) console.warn('Price element may lack currency context for screen readers');
61 });
62
63 it('rating has accessible text alternative', () => {
64 render(<CourseCard course={mockCourse} />);
65 // Star rating needs text alternative: '4.8 out of 5 stars'
66 const ratingEl = screen.getByLabelText(/rating/i) ||
67 screen.queryByText(/4\.8.*star/i);
68 expect(ratingEl || screen.getByText('4.8')).toBeInTheDocument();
69 });
70});This test suite uses describe.each to run axe-core checks against every major component in a single parameterized test. The color-contrast rule is disabled because jsdom cannot compute computed styles (use @axe-core/playwright for contrast testing). Individual CourseCard tests verify specific WCAG requirements: images have descriptive alt text (1.1.1), links have meaningful names (2.4.6), prices have currency context (3.1.1).
Continue building comprehensive test coverage with the patterns established in this lesson.
Your progress is saved in this browser, on this device only.