Accessibility Testing (a11y)
Automate accessibility audits with axe-core, jest-axe, Lighthouse, and Playwright to achieve WCAG compliance and inclusive design
Why Accessibility Testing?
Accessibility (a11y) testing ensures your application is usable by everyone, including people with visual, auditory, motor, and cognitive disabilities. Beyond being the right thing to do, accessibility is a legal requirement in many jurisdictions (ADA, Section 508, EU Accessibility Act) and improves SEO and overall usability.
Automated vs Manual:
Automated tools catch about 30-40% of accessibility issues. Keyboard testing, screen reader testing, and user testing catch the rest. Use both approaches.
axe-core with Jest (jest-axe)
// Install: npm install --save-dev jest-axe @axe-core/react
import { render } from '@testing-library/react';
import { axe, toHaveNoViolations } from 'jest-axe';
expect.extend(toHaveNoViolations);
test('LoginForm has no accessibility violations', async () => {
const { container } = render(<LoginForm />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
// Test specific components with various states
test('Button states are accessible', async () => {
// Default state
const { container: defaultBtn } = render(
<Button>Click me</Button>
);
expect(await axe(defaultBtn)).toHaveNoViolations();
// Disabled state
const { container: disabledBtn } = render(
<Button disabled>Disabled</Button>
);
expect(await axe(disabledBtn)).toHaveNoViolations();
// Loading state
const { container: loadingBtn } = render(
<Button loading aria-busy="true">Loading...</Button>
);
expect(await axe(loadingBtn)).toHaveNoViolations();
});
// Custom axe configuration
test('form with custom rules', async () => {
const { container } = render(<ComplexForm />);
const results = await axe(container, {
rules: {
// Disable specific rules if needed (with justification)
'color-contrast': { enabled: true },
region: { enabled: false }, // May not apply in isolated components
},
});
expect(results).toHaveNoViolations();
});
Playwright Accessibility Testing
// axe-playwright integration
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
test('full page accessibility audit', async ({ page }) => {
await page.goto('/');
const results = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'])
.analyze();
expect(results.violations).toEqual([]);
});
test('specific component accessibility', async ({ page }) => {
await page.goto('/checkout');
// Only scan the checkout form
const results = await new AxeBuilder({ page })
.include('#checkout-form')
.analyze();
expect(results.violations).toEqual([]);
});
test('accessibility after interaction', async ({ page }) => {
await page.goto('/dashboard');
// Open modal
await page.click('[data-testid="open-modal"]');
await page.waitForSelector('[role="dialog"]');
const results = await new AxeBuilder({ page })
.include('[role="dialog"]')
.analyze();
expect(results.violations).toEqual([]);
// Check focus is trapped in modal
await page.keyboard.press('Tab');
const focused = await page.evaluate(() =>
document.activeElement?.closest('[role="dialog"]') !== null
);
expect(focused).toBe(true);
});
Keyboard Navigation Testing
// Test keyboard navigation with Playwright
test('navigation menu keyboard support', async ({ page }) => {
await page.goto('/');
// Tab to navigation
await page.keyboard.press('Tab');
const nav = page.getByRole('navigation');
// Tab through menu items
await page.keyboard.press('Tab');
await expect(page.getByRole('link', { name: 'Home' })).toBeFocused();
await page.keyboard.press('Tab');
await expect(page.getByRole('link', { name: 'Products' })).toBeFocused();
// Enter to activate
await page.keyboard.press('Enter');
await expect(page).toHaveURL('/products');
});
test('dropdown keyboard interaction', async ({ page }) => {
await page.goto('/components');
const trigger = page.getByRole('button', { name: 'Select option' });
await trigger.focus();
// Open with Enter or Space
await page.keyboard.press('Enter');
await expect(page.getByRole('listbox')).toBeVisible();
// Navigate with arrow keys
await page.keyboard.press('ArrowDown');
await page.keyboard.press('ArrowDown');
await page.keyboard.press('Enter');
// Close dropdown
await expect(page.getByRole('listbox')).not.toBeVisible();
// Escape to close
await trigger.focus();
await page.keyboard.press('Enter');
await page.keyboard.press('Escape');
await expect(page.getByRole('listbox')).not.toBeVisible();
});
WCAG Compliance Checklist
Automated Checks:
- Color contrast ratios (4.5:1 normal, 3:1 large text)
- Alt text on images
- Label associations on form fields
- ARIA attributes validity
- Heading hierarchy (h1 > h2 > h3)
- Language attribute on html
Manual Checks:
- Full keyboard navigation without mouse
- Screen reader announcements make sense
- Focus order is logical
- Error messages are associated with fields
- Dynamic content changes are announced
- Content is usable at 200% zoom
Key Takeaways
- Add jest-axe to every component test for free accessibility coverage
- Use axe-playwright for full-page audits in E2E tests
- Test keyboard navigation for all interactive components
- Automated tools catch 30-40% of issues; complement with manual testing
- Target WCAG 2.1 AA as the baseline compliance level