Accessibility Testing
Learn how to test web accessibility using jest-axe, Lighthouse, manual testing, and screen readers to ensure your applications are usable by everyone.
What is Accessibility Testing?
Accessibility (a11y) testing ensures your web applications are usable by people with disabilities, including those using assistive technologies like screen readers, keyboard navigation, and alternative input devices.
Why Accessibility Matters
- Legal compliance: Many countries require accessible websites (ADA, Section 508, WCAG)
- Inclusive design: 15% of the world’s population has some form of disability
- Better UX: Accessibility improvements benefit all users
- SEO benefits: Accessible sites rank better in search engines
- Wider audience: Don’t exclude potential users/customers
Accessibility is not optional. It’s a legal requirement in many jurisdictions and essential for inclusive web experiences.
WCAG Compliance Levels
The Web Content Accessibility Guidelines (WCAG) define three conformance levels:
| Level | Requirements | Use Case |
|---|---|---|
| A | Basic accessibility features | Minimum legal requirement |
| AA | Addresses major barriers | Most common target (recommended) |
| AAA | Highest level of accessibility | Specialized content, government sites |
WCAG Principles (POUR)
- Perceivable: Information must be presentable to users in ways they can perceive
- Operable: Interface components must be operable
- Understandable: Information and operation must be understandable
- Robust: Content must be robust enough for assistive technologies
Testing with jest-axe
jest-axe integrates the axe-core accessibility testing engine into your Jest tests.
Installation
npm install --save-dev jest-axeBasic Setup
// jest.setup.ts
import { toHaveNoViolations } from 'jest-axe';
expect.extend(toHaveNoViolations);Component Testing
// Button.test.tsx
import React from 'react';
import { render } from '@testing-library/react';
import { axe } from 'jest-axe';
import { Button } from './Button';
describe('Button Accessibility', () => {
it('should not have any accessibility violations', async () => {
const { container } = render(<Button>Click me</Button>);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('should be accessible when disabled', async () => {
const { container } = render(<Button disabled>Disabled</Button>);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('should have accessible name', async () => {
const { container } = render(
<Button aria-label="Submit form">
<span className="icon" />
</Button>
);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
});Form Testing
// LoginForm.test.tsx
import React from 'react';
import { render, screen } from '@testing-library/react';
import { axe } from 'jest-axe';
import { LoginForm } from './LoginForm';
describe('LoginForm Accessibility', () => {
it('should have no accessibility violations', async () => {
const { container } = render(<LoginForm />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('should have properly labeled inputs', () => {
render(<LoginForm />);
// Inputs should be associated with labels
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
expect(screen.getByLabelText(/password/i)).toBeInTheDocument();
});
it('should show accessible error messages', async () => {
const { container } = render(<LoginForm />);
// Submit form with errors
const submitButton = screen.getByRole('button', { name: /submit/i });
submitButton.click();
// Wait for error messages
await screen.findByRole('alert');
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('should have accessible password toggle', async () => {
const { container } = render(<LoginForm />);
const toggleButton = screen.getByRole('button', { name: /show password/i });
expect(toggleButton).toHaveAttribute('aria-pressed', 'false');
const results = await axe(container);
expect(results).toHaveNoViolations();
});
});Custom axe Configuration
import { axe, toHaveNoViolations } from 'jest-axe';
expect.extend(toHaveNoViolations);
describe('Custom axe config', () => {
it('should check specific rules', async () => {
const { container } = render(<MyComponent />);
const results = await axe(container, {
// Only check specific rules
rules: {
'color-contrast': { enabled: true },
'label': { enabled: true },
'button-name': { enabled: true }
}
});
expect(results).toHaveNoViolations();
});
it('should disable specific rules', async () => {
const { container } = render(<ThirdPartyComponent />);
const results = await axe(container, {
rules: {
// Disable specific rule if needed
'color-contrast': { enabled: false }
}
});
expect(results).toHaveNoViolations();
});
it('should test specific WCAG level', async () => {
const { container } = render(<MyComponent />);
const results = await axe(container, {
// Test WCAG 2.1 Level AA
runOnly: {
type: 'tag',
values: ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa']
}
});
expect(results).toHaveNoViolations();
});
it('should test best practices', async () => {
const { container } = render(<MyComponent />);
const results = await axe(container, {
runOnly: {
type: 'tag',
values: ['best-practice']
}
});
expect(results).toHaveNoViolations();
});
});Testing Specific Patterns
describe('Accessibility Patterns', () => {
it('should have accessible modal', async () => {
const { container } = render(
<Modal isOpen onClose={() => {}}>
<h2>Modal Title</h2>
<p>Modal content</p>
</Modal>
);
// Check for modal attributes
const dialog = screen.getByRole('dialog');
expect(dialog).toHaveAttribute('aria-modal', 'true');
expect(dialog).toHaveAttribute('aria-labelledby');
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('should have accessible tabs', async () => {
const { container } = render(
<Tabs>
<TabList>
<Tab>Tab 1</Tab>
<Tab>Tab 2</Tab>
</TabList>
<TabPanel>Content 1</TabPanel>
<TabPanel>Content 2</TabPanel>
</Tabs>
);
const tablist = screen.getByRole('tablist');
const tabs = screen.getAllByRole('tab');
expect(tabs[0]).toHaveAttribute('aria-selected', 'true');
expect(tabs[1]).toHaveAttribute('aria-selected', 'false');
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('should have accessible dropdown', async () => {
const { container } = render(
<Dropdown label="Select option">
<option value="1">Option 1</option>
<option value="2">Option 2</option>
</Dropdown>
);
const combobox = screen.getByRole('combobox', { name: /select option/i });
expect(combobox).toBeInTheDocument();
const results = await axe(container);
expect(results).toHaveNoViolations();
});
});jest-axe catches many accessibility issues but not all. Combine it with manual testing and screen reader testing for comprehensive coverage.
Using Lighthouse for A11y
Lighthouse includes accessibility audits based on axe-core.
Running Accessibility Audits
// lighthouserc.js
module.exports = {
ci: {
collect: {
url: ['http://localhost:3000'],
numberOfRuns: 1,
settings: {
onlyCategories: ['accessibility']
}
},
assert: {
assertions: {
'categories:accessibility': ['error', { minScore: 0.9 }],
// Specific audits
'aria-valid-attr': ['error', { minScore: 1 }],
'aria-valid-attr-value': ['error', { minScore: 1 }],
'button-name': ['error', { minScore: 1 }],
'color-contrast': ['error', { minScore: 1 }],
'image-alt': ['error', { minScore: 1 }],
'label': ['error', { minScore: 1 }],
'link-name': ['error', { minScore: 1 }],
'document-title': ['error', { minScore: 1 }],
'html-has-lang': ['error', { minScore: 1 }],
'valid-lang': ['error', { minScore: 1 }]
}
}
}
};Playwright + Lighthouse
// tests/a11y.spec.ts
import { test } from '@playwright/test';
import { playAudit } from 'playwright-lighthouse';
test('should pass accessibility audit', async ({ page }) => {
await page.goto('http://localhost:3000');
await playAudit({
page,
thresholds: {
accessibility: 90
},
reports: {
formats: {
html: true
},
name: 'accessibility-report',
directory: 'lighthouse-reports'
}
});
});Manual Accessibility Testing
Automated tools catch only ~30-40% of accessibility issues. Manual testing is essential.
Keyboard Navigation Testing
// tests/keyboard-navigation.spec.ts
import { test, expect } from '@playwright/test';
test.describe('Keyboard Navigation', () => {
test('should navigate through form with Tab', async ({ page }) => {
await page.goto('http://localhost:3000/form');
// Focus on first input
await page.keyboard.press('Tab');
await expect(page.locator('#name')).toBeFocused();
// Tab to next input
await page.keyboard.press('Tab');
await expect(page.locator('#email')).toBeFocused();
// Tab to button
await page.keyboard.press('Tab');
await expect(page.getByRole('button', { name: /submit/i })).toBeFocused();
});
test('should activate button with Enter and Space', async ({ page }) => {
await page.goto('http://localhost:3000');
const button = page.getByRole('button', { name: /submit/i });
await button.focus();
// Should activate with Enter
await page.keyboard.press('Enter');
await expect(page.locator('.success')).toBeVisible();
// Should also activate with Space
await button.focus();
await page.keyboard.press('Space');
await expect(page.locator('.success')).toBeVisible();
});
test('should close modal with Escape', async ({ page }) => {
await page.goto('http://localhost:3000');
// Open modal
await page.click('button[data-testid="open-modal"]');
await expect(page.getByRole('dialog')).toBeVisible();
// Close with Escape
await page.keyboard.press('Escape');
await expect(page.getByRole('dialog')).not.toBeVisible();
});
test('should trap focus in modal', async ({ page }) => {
await page.goto('http://localhost:3000');
await page.click('button[data-testid="open-modal"]');
const modal = page.getByRole('dialog');
const closeButton = modal.getByRole('button', { name: /close/i });
const submitButton = modal.getByRole('button', { name: /submit/i });
// Tab should cycle through modal elements
await page.keyboard.press('Tab');
await expect(closeButton).toBeFocused();
await page.keyboard.press('Tab');
await expect(submitButton).toBeFocused();
// Tab again should go back to first element
await page.keyboard.press('Tab');
await expect(closeButton).toBeFocused();
});
test('should skip navigation with skip link', async ({ page }) => {
await page.goto('http://localhost:3000');
// First tab should focus skip link
await page.keyboard.press('Tab');
const skipLink = page.getByRole('link', { name: /skip to main content/i });
await expect(skipLink).toBeFocused();
// Activating skip link should focus main content
await page.keyboard.press('Enter');
await expect(page.locator('#main-content')).toBeFocused();
});
});Focus Visibility Testing
test.describe('Focus Indicators', () => {
test('should show visible focus indicator', async ({ page }) => {
await page.goto('http://localhost:3000');
const button = page.getByRole('button', { name: /submit/i });
// Focus the button
await button.focus();
// Check for focus indicator (outline or custom style)
const outlineWidth = await button.evaluate((el) => {
const styles = window.getComputedStyle(el);
return styles.outlineWidth;
});
expect(parseFloat(outlineWidth)).toBeGreaterThan(0);
});
test('should maintain focus order', async ({ page }) => {
await page.goto('http://localhost:3000/complex-form');
const focusOrder = [
'input[name="firstName"]',
'input[name="lastName"]',
'input[name="email"]',
'button[type="submit"]'
];
for (const selector of focusOrder) {
await page.keyboard.press('Tab');
await expect(page.locator(selector)).toBeFocused();
}
});
});Screen Reader Testing Basics
While automated tests help, actual screen reader testing is crucial.
Screen Readers to Test
| Screen Reader | Platform | Browser | Market Share |
|---|---|---|---|
| JAWS | Windows | Chrome, Edge, Firefox | ~40% |
| NVDA | Windows | Chrome, Firefox | ~30% |
| VoiceOver | macOS/iOS | Safari | ~20% |
| TalkBack | Android | Chrome | ~10% |
Testing Checklist
// Document what to test manually
describe('Screen Reader Manual Tests', () => {
/**
* Manual Testing Checklist:
*
* ☐ Page title is announced
* ☐ Headings are properly announced (h1, h2, etc.)
* ☐ Landmarks are properly identified (nav, main, aside, footer)
* ☐ Links have descriptive text
* ☐ Images have alt text
* ☐ Form labels are announced
* ☐ Form errors are announced
* ☐ Dynamic content changes are announced (aria-live)
* ☐ Button purpose is clear
* ☐ Modal dialogs trap focus and announce properly
* ☐ Tables have proper headers
* ☐ Lists are properly identified
*/
it('should have proper ARIA labels for screen readers', () => {
render(<SearchInput />);
const input = screen.getByRole('searchbox');
expect(input).toHaveAttribute('aria-label', 'Search products');
});
it('should announce loading states', () => {
render(<DataTable loading />);
const status = screen.getByRole('status');
expect(status).toHaveTextContent('Loading');
expect(status).toHaveAttribute('aria-live', 'polite');
});
it('should announce form errors', async () => {
render(<Form />);
const submitButton = screen.getByRole('button', { name: /submit/i });
submitButton.click();
const alert = await screen.findByRole('alert');
expect(alert).toBeInTheDocument();
expect(alert).toHaveAttribute('aria-live', 'assertive');
});
});ARIA Patterns Testing
describe('ARIA Patterns', () => {
it('should have proper button role and state', () => {
render(<ToggleButton />);
const button = screen.getByRole('button', { name: /toggle menu/i });
expect(button).toHaveAttribute('aria-expanded', 'false');
button.click();
expect(button).toHaveAttribute('aria-expanded', 'true');
});
it('should have proper listbox pattern', () => {
render(<AutoComplete options={['Apple', 'Banana', 'Cherry']} />);
const combobox = screen.getByRole('combobox');
expect(combobox).toHaveAttribute('aria-autocomplete', 'list');
expect(combobox).toHaveAttribute('aria-controls');
const listbox = screen.getByRole('listbox');
expect(listbox).toHaveAttribute('id');
});
it('should announce live region updates', async () => {
render(<NotificationCenter />);
const liveRegion = screen.getByRole('status');
expect(liveRegion).toHaveAttribute('aria-live', 'polite');
expect(liveRegion).toHaveAttribute('aria-atomic', 'true');
});
});Color Contrast Testing
WCAG requires minimum contrast ratios for text and interactive elements.
Contrast Requirements
| Text Type | WCAG AA | WCAG AAA |
|---|---|---|
| Normal text | 4.5:1 | 7:1 |
| Large text (18pt+) | 3:1 | 4.5:1 |
| UI components | 3:1 | - |
Testing Contrast
describe('Color Contrast', () => {
it('should have sufficient contrast for normal text', async () => {
const { container } = render(
<div style={{ color: '#757575', backgroundColor: '#fff' }}>
This is normal text
</div>
);
// jest-axe will check color contrast
const results = await axe(container, {
rules: {
'color-contrast': { enabled: true }
}
});
expect(results).toHaveNoViolations();
});
it('should have sufficient contrast for buttons', async () => {
const { container } = render(
<button style={{ color: '#fff', backgroundColor: '#0066cc' }}>
Click me
</button>
);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
// Manual calculation
it('should calculate contrast ratio', () => {
function getLuminance(r: number, g: number, b: number): number {
const [rs, gs, bs] = [r, g, b].map(c => {
c = c / 255;
return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
});
return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs;
}
function getContrastRatio(
fg: [number, number, number],
bg: [number, number, number]
): number {
const l1 = getLuminance(...fg);
const l2 = getLuminance(...bg);
const lighter = Math.max(l1, l2);
const darker = Math.min(l1, l2);
return (lighter + 0.05) / (darker + 0.05);
}
// Black text on white background
const ratio = getContrastRatio([0, 0, 0], [255, 255, 255]);
expect(ratio).toBeGreaterThan(4.5); // WCAG AA for normal text
});
});Playwright Visual Contrast Testing
test('should have visible focus indicators', async ({ page }) => {
await page.goto('http://localhost:3000');
const button = page.getByRole('button', { name: /submit/i });
await button.focus();
// Take screenshots for visual verification
await button.screenshot({ path: 'button-focus.png' });
// Check computed styles
const styles = await button.evaluate((el) => {
const computed = window.getComputedStyle(el);
return {
outlineWidth: computed.outlineWidth,
outlineColor: computed.outlineColor,
outlineStyle: computed.outlineStyle
};
});
expect(styles.outlineWidth).not.toBe('0px');
});Complete Accessibility Test Example
// components/LoginForm/LoginForm.a11y.test.tsx
import React from 'react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { axe, toHaveNoViolations } from 'jest-axe';
import { LoginForm } from './LoginForm';
expect.extend(toHaveNoViolations);
describe('LoginForm Accessibility', () => {
describe('Automated Tests', () => {
it('should have no axe violations', async () => {
const { container } = render(<LoginForm />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('should have no violations with errors', async () => {
const { container } = render(<LoginForm />);
const submitButton = screen.getByRole('button', { name: /log in/i });
await userEvent.click(submitButton);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
});
describe('Form Labels', () => {
it('should have properly labeled inputs', () => {
render(<LoginForm />);
expect(screen.getByLabelText(/email address/i)).toBeInTheDocument();
expect(screen.getByLabelText(/password/i)).toBeInTheDocument();
});
it('should associate errors with inputs', async () => {
render(<LoginForm />);
const submitButton = screen.getByRole('button', { name: /log in/i });
await userEvent.click(submitButton);
const emailInput = screen.getByLabelText(/email address/i);
expect(emailInput).toHaveAttribute('aria-invalid', 'true');
expect(emailInput).toHaveAccessibleDescription(/email is required/i);
});
});
describe('Keyboard Navigation', () => {
it('should allow tab navigation', async () => {
render(<LoginForm />);
const emailInput = screen.getByLabelText(/email address/i);
const passwordInput = screen.getByLabelText(/password/i);
const showPasswordButton = screen.getByRole('button', { name: /show password/i });
const submitButton = screen.getByRole('button', { name: /log in/i });
await userEvent.tab();
expect(emailInput).toHaveFocus();
await userEvent.tab();
expect(passwordInput).toHaveFocus();
await userEvent.tab();
expect(showPasswordButton).toHaveFocus();
await userEvent.tab();
expect(submitButton).toHaveFocus();
});
it('should submit form with Enter key', async () => {
const onSubmit = jest.fn();
render(<LoginForm onSubmit={onSubmit} />);
const emailInput = screen.getByLabelText(/email address/i);
await userEvent.type(emailInput, 'test@example.com{Enter}');
expect(onSubmit).toHaveBeenCalled();
});
});
describe('Screen Reader Support', () => {
it('should have proper form structure', () => {
render(<LoginForm />);
const form = screen.getByRole('form', { name: /log in/i });
expect(form).toBeInTheDocument();
});
it('should announce password visibility toggle', async () => {
render(<LoginForm />);
const toggleButton = screen.getByRole('button', { name: /show password/i });
expect(toggleButton).toHaveAttribute('aria-pressed', 'false');
await userEvent.click(toggleButton);
expect(toggleButton).toHaveAttribute('aria-pressed', 'true');
expect(screen.getByRole('button', { name: /hide password/i })).toBeInTheDocument();
});
it('should announce form submission status', async () => {
render(<LoginForm />);
const submitButton = screen.getByRole('button', { name: /log in/i });
await userEvent.click(submitButton);
const status = await screen.findByRole('status');
expect(status).toHaveTextContent(/logging in/i);
expect(status).toHaveAttribute('aria-live', 'polite');
});
it('should announce errors assertively', async () => {
render(<LoginForm />);
const submitButton = screen.getByRole('button', { name: /log in/i });
await userEvent.click(submitButton);
const alert = await screen.findByRole('alert');
expect(alert).toHaveAttribute('aria-live', 'assertive');
});
});
describe('Focus Management', () => {
it('should focus first error on submit', async () => {
render(<LoginForm />);
const submitButton = screen.getByRole('button', { name: /log in/i });
await userEvent.click(submitButton);
const emailInput = screen.getByLabelText(/email address/i);
expect(emailInput).toHaveFocus();
});
it('should have visible focus indicators', () => {
const { container } = render(<LoginForm />);
const inputs = container.querySelectorAll('input');
inputs.forEach((input) => {
const styles = window.getComputedStyle(input, ':focus');
// Should have outline or box-shadow when focused
expect(
styles.outlineWidth !== '0px' ||
styles.boxShadow !== 'none'
).toBe(true);
});
});
});
});Best Practices
✅ Do This
- Use semantic HTML (
<button>,<nav>,<main>, etc.) - Provide text alternatives for images
- Ensure sufficient color contrast
- Support keyboard navigation
- Use ARIA attributes correctly
- Test with real screen readers
- Include skip links
- Provide form labels
- Announce dynamic content changes
- Maintain logical focus order
❌ Avoid This
- Using
<div>or<span>instead of semantic elements - Relying only on color to convey information
- Removing focus indicators
- Creating keyboard traps
- Using placeholder as label
- Auto-playing media without controls
- Creating inaccessible custom components
- Ignoring screen reader testing
Related Topics
- Best Practices - Testing guidelines
- React Testing - Component testing
- E2E Testing - End-to-end tests