PatternsTesting Strategies
A11y Testing Automation
Automated accessibility testing with axe-core and Pa11y
Accessibility Testing Automation
Accessibility testing ensures your application is usable by everyone, including people with disabilities. Automated tools catch 30-40% of accessibility issues—manual testing is still essential, but automation provides a solid foundation.
Why Automate A11y Testing?
What automated tools catch:
- Missing alt text on images
- Insufficient color contrast
- Missing form labels
- Invalid ARIA attributes
- Keyboard navigation issues
- Missing page titles
What they don't catch:
- Logical tab order
- Focus management
- Screen reader UX
- Cognitive load issues
- Real user experience
axe-core (The Industry Standard)
Setup with jest-axe
npm install --save-dev jest-axe// jest.setup.ts
import { toHaveNoViolations } from 'jest-axe';
expect.extend(toHaveNoViolations);Basic Usage
// Component.test.tsx
import { render } from '@testing-library/react';
import { axe } from 'jest-axe';
test('has no accessibility violations', async () => {
const { container } = render(<MyComponent />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});Custom Rules
test('checks specific rules', async () => {
const { container } = render(<Form />);
const results = await axe(container, {
rules: {
// Only check these rules
'color-contrast': { enabled: true },
'label': { enabled: true },
'button-name': { enabled: true },
},
});
expect(results).toHaveNoViolations();
});Exclude Regions
test('ignores third-party widgets', async () => {
const { container } = render(<PageWithAds />);
const results = await axe(container, {
// Exclude ad container from checks
exclude: [['.advertisement']],
});
expect(results).toHaveNoViolations();
});Playwright Integration
// tests/a11y/homepage.spec.ts
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
test.describe('Homepage Accessibility', () => {
test('should not have any automatically detectable accessibility issues', async ({ page }) => {
await page.goto('http://localhost:3000');
const accessibilityScanResults = await new AxeBuilder({ page })
.analyze();
expect(accessibilityScanResults.violations).toEqual([]);
});
test('checks specific WCAG standards', async ({ page }) => {
await page.goto('http://localhost:3000');
const accessibilityScanResults = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa', 'wcag21aa'])
.analyze();
expect(accessibilityScanResults.violations).toEqual([]);
});
test('excludes third-party content', async ({ page }) => {
await page.goto('http://localhost:3000');
const accessibilityScanResults = await new AxeBuilder({ page })
.exclude('.third-party-widget')
.analyze();
expect(accessibilityScanResults.violations).toEqual([]);
});
});Test Multiple Pages
const pages = [
'/',
'/about',
'/products',
'/contact',
];
for (const path of pages) {
test(`${path} has no a11y violations`, async ({ page }) => {
await page.goto(`http://localhost:3000${path}`);
const results = await new AxeBuilder({ page }).analyze();
expect(results.violations).toEqual([]);
});
}Pa11y (Command Line Tool)
Installation
npm install --save-dev pa11y pa11y-ciConfiguration
// .pa11yci.json
{
"defaults": {
"standard": "WCAG2AA",
"timeout": 30000,
"wait": 1000,
"chromeLaunchConfig": {
"args": ["--no-sandbox"]
},
"ignore": [
"notice",
"warning"
]
},
"urls": [
"http://localhost:3000/",
"http://localhost:3000/about",
"http://localhost:3000/products",
{
"url": "http://localhost:3000/dashboard",
"actions": [
"set field #email to test@example.com",
"set field #password to password123",
"click element button[type=submit]",
"wait for url to be http://localhost:3000/dashboard"
]
}
]
}Package.json Scripts
{
"scripts": {
"test:a11y": "pa11y-ci"
}
}Custom Reporter
// scripts/pa11y-report.js
const pa11y = require('pa11y');
async function runPa11y() {
const results = await pa11y('http://localhost:3000', {
standard: 'WCAG2AA',
reporters: ['cli', 'json'],
});
const issues = results.issues;
// Group by type
const errors = issues.filter(i => i.type === 'error');
const warnings = issues.filter(i => i.type === 'warning');
console.log(`Found ${errors.length} errors, ${warnings.length} warnings`);
if (errors.length > 0) {
console.error('\n❌ Accessibility Errors:');
errors.forEach(err => {
console.error(` - ${err.message}`);
console.error(` ${err.selector}`);
console.error(` ${err.context}\n`);
});
process.exit(1);
}
}
runPa11y();Lighthouse Accessibility
// Already covered in Performance Monitoring section
// Lighthouse includes accessibility auditManual Testing Checklist
Automated tools are a starting point. Always test manually:
Keyboard Navigation
- All interactive elements are keyboard accessible
- Tab order is logical
- Focus indicators are visible
- No keyboard traps
- Can close modals with Escape
// Test keyboard navigation
test('modal can be closed with Escape', async () => {
const user = userEvent.setup();
render(<Modal />);
const closeButton = screen.getByRole('button', { name: /close/i });
// Open modal (if needed)
// Focus an element inside
closeButton.focus();
// Press Escape
await user.keyboard('{Escape}');
// Modal should be closed
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});Screen Reader Testing
- Test with NVDA (Windows), JAWS (Windows), VoiceOver (macOS/iOS)
- All content is announced properly
- ARIA labels are meaningful
- Live regions work correctly
- Form errors are announced
// Verify screen reader text
test('announces error message', () => {
render(<Form />);
const emailInput = screen.getByRole('textbox', { name: /email/i });
fireEvent.blur(emailInput);
// Error should be associated with input
expect(emailInput).toHaveAccessibleDescription('Please enter a valid email');
});Color & Contrast
- Sufficient color contrast (4.5:1 for text, 3:1 for large text)
- Information not conveyed by color alone
- Focus indicators visible
// Check color contrast programmatically
test('has sufficient color contrast', async () => {
const { container } = render(<Button>Click me</Button>);
const results = await axe(container, {
rules: {
'color-contrast': { enabled: true },
},
});
expect(results.violations).toHaveLength(0);
});Forms
- All inputs have labels
- Error messages are clear
- Required fields are indicated
- Autocomplete attributes for common fields
test('form inputs have proper labels', () => {
render(<SignupForm />);
// Inputs can be found by their label
expect(screen.getByLabelText('Email')).toBeInTheDocument();
expect(screen.getByLabelText('Password')).toBeInTheDocument();
expect(screen.getByLabelText('Confirm Password')).toBeInTheDocument();
});CI/CD Integration
# .github/workflows/a11y.yml
name: Accessibility Tests
on: [push, pull_request]
jobs:
a11y:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Setup Node
uses: actions/setup-node@v3
with:
node-version: '20'
- name: Install dependencies
run: npm ci
- name: Build
run: npm run build
- name: Start server
run: npm start &
- name: Wait for server
run: npx wait-on http://localhost:3000
- name: Run Pa11y
run: npm run test:a11y
- name: Run Playwright a11y tests
run: npx playwright test tests/a11y
- name: Upload report
if: always()
uses: actions/upload-artifact@v3
with:
name: a11y-report
path: pa11y-report/Best Practices
- Run on Every Page: Include accessibility tests for all pages
- Test Interactive States: Hover, focus, active, disabled
- Test With Real Users: Automated tools catch 30-40%, humans catch the rest
- Use Semantic HTML: Start with accessible foundations
- Test Keyboard Navigation: Ensure all functionality works without mouse
- Include in CI: Block PRs with accessibility issues
- Track Over Time: Monitor accessibility scores
WCAG Compliance Levels
| Level | Requirements | Use Case |
|---|---|---|
| A | Basic accessibility | Minimum legal compliance |
| AA | Addresses major barriers | Industry standard |
| AAA | Highest accessibility | Government, critical services |
Recommendation: Target WCAG 2.1 Level AA as baseline.
Common Issues & Fixes
Missing Alt Text
// ❌ BAD
<img src="/photo.jpg" />
// ✅ GOOD
<img src="/photo.jpg" alt="Team celebrating product launch" />
// ✅ GOOD: Decorative images
<img src="/decoration.jpg" alt="" role="presentation" />Missing Form Labels
// ❌ BAD
<input type="email" placeholder="Email" />
// ✅ GOOD
<label htmlFor="email">Email</label>
<input id="email" type="email" />
// ✅ GOOD: Visually hidden label
<label htmlFor="search" className="sr-only">Search</label>
<input id="search" type="search" placeholder="Search..." />Insufficient Color Contrast
/* ❌ BAD: 2.5:1 contrast */
.text {
color: #777;
background: #fff;
}
/* ✅ GOOD: 7:1 contrast */
.text {
color: #333;
background: #fff;
}Missing ARIA Labels
// ❌ BAD
<button>
<CloseIcon />
</button>
// ✅ GOOD
<button aria-label="Close dialog">
<CloseIcon />
</button>Tools Summary
| Tool | Scope | Integration | Cost |
|---|---|---|---|
| axe-core | Component/Page | Jest, Playwright | Free |
| Pa11y | Page/Site | CLI, CI | Free |
| Lighthouse | Page | CLI, CI | Free |
| Wave | Page | Browser ext | Free |
| NVDA | Screen reader | Manual | Free |
| JAWS | Screen reader | Manual | Paid |
When to Test
✅ Every component: Unit tests with jest-axe
✅ Every page: E2E tests with Playwright
✅ Every deploy: Pa11y in CI
✅ Every sprint: Manual testing with screen readers
Accessibility is not optional—it's a legal requirement in many jurisdictions and the right thing to do.