@axe-core/playwright runs accessibility checks alongside your regular Playwright tests, catching missing alt text, unlabeled form inputs, insufficient color contrast, and incorrect ARIA usage on every CI commit. Automated scanning catches roughly 30-40% of WCAG violations; the rest require manual keyboard and screen reader testing. This article covers the full setup: scanning full pages, specific components, filtering by WCAG level, and testing keyboard navigation flows.
Why Automate Accessibility Testing?
Manual accessibility audits are slow and expensive. Automated checks catch the most common issues instantly:
- Missing alt text on images
- Form inputs without labels
- Insufficient color contrast
- Missing ARIA roles and attributes
- Keyboard navigation issues
- Focus management problems
Automated tools catch ~30-40% of accessibility issues. The rest require manual testing with actual assistive technologies. But catching 30-40% automatically, on every commit, is far better than nothing.
Setup: axe-playwright
npm install --save-dev @axe-core/playwrightBasic Accessibility Scan
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
test('home page has no accessibility violations', async ({ page }) => {
await page.goto('/');
const results = await new AxeBuilder({ page }).analyze();
expect(results.violations).toEqual([]);
});If there are violations, the test fails with details:
● home page has no accessibility violations
expect(received).toEqual(expected)
Expected: []
Received: [
{
id: 'color-contrast',
description: 'Elements must have sufficient color contrast',
nodes: [{ target: ['#nav-link'], ... }]
}
]Scanning Specific Pages
test.describe('Accessibility checks', () => {
const pages = [
{ name: 'Home', url: '/' },
{ name: 'Login', url: '/login' },
{ name: 'Products', url: '/products' },
{ name: 'Contact', url: '/contact' },
];
for (const { name, url } of pages) {
test(`${name} page is accessible`, async ({ page }) => {
await page.goto(url);
await page.waitForLoadState('networkidle');
const results = await new AxeBuilder({ page }).analyze();
expect(results.violations).toEqual([]);
});
}
});Filtering Rules
Run only specific WCAG criteria, or exclude known issues:
test('login page WCAG AA compliant', async ({ page }) => {
await page.goto('/login');
const results = await new AxeBuilder({ page })
// Only check WCAG 2.1 AA criteria
.withTags(['wcag2a', 'wcag2aa', 'wcag21aa'])
.analyze();
expect(results.violations).toEqual([]);
});
test('products page with known issues excluded', async ({ page }) => {
await page.goto('/products');
const results = await new AxeBuilder({ page })
// Temporarily exclude a known issue while it's being fixed
.disableRules(['color-contrast'])
.analyze();
expect(results.violations).toEqual([]);
});Scanning Part of the Page
test('navigation menu is accessible', async ({ page }) => {
await page.goto('/');
const results = await new AxeBuilder({ page })
.include('#main-navigation') // Only scan the nav
.analyze();
expect(results.violations).toEqual([]);
});
test('modal dialog is accessible', async ({ page }) => {
await page.goto('/products');
await page.click('[data-testid="add-to-cart"]');
// Wait for modal to open
await page.waitForSelector('[role="dialog"]');
const results = await new AxeBuilder({ page })
.include('[role="dialog"]')
.analyze();
expect(results.violations).toEqual([]);
});Keyboard Navigation Testing
Axe catches missing ARIA attributes. Manual keyboard testing finds navigation flow problems:
test('login form is keyboard navigable', async ({ page }) => {
await page.goto('/login');
// Tab through the form fields
await page.keyboard.press('Tab');
await expect(page.locator(':focus')).toHaveAttribute('data-testid', 'email-input');
await page.keyboard.press('Tab');
await expect(page.locator(':focus')).toHaveAttribute('data-testid', 'password-input');
await page.keyboard.press('Tab');
await expect(page.locator(':focus')).toHaveAttribute('data-testid', 'submit-btn');
// Submit with Enter key
await page.keyboard.press('Enter');
await page.waitForURL('/dashboard');
});
test('modal can be closed with Escape', async ({ page }) => {
await page.goto('/products');
await page.click('[data-testid="filter-btn"]');
await expect(page.getByRole('dialog')).toBeVisible();
await page.keyboard.press('Escape');
await expect(page.getByRole('dialog')).not.toBeVisible();
});Focus Management
When modals open, focus should move inside them. When they close, focus should return:
test('modal traps focus correctly', async ({ page }) => {
await page.goto('/');
// Open modal
const triggerButton = page.getByTestId('open-modal');
await triggerButton.click();
const modal = page.getByRole('dialog');
await expect(modal).toBeVisible();
// Focus should be inside modal
const focusedElement = page.locator(':focus');
await expect(modal).toContainElement(focusedElement);
// Tab through modal — focus shouldn't escape
await page.keyboard.press('Tab');
await page.keyboard.press('Tab');
await page.keyboard.press('Tab');
// Still inside modal
await expect(modal).toContainElement(page.locator(':focus'));
// Close modal
await page.keyboard.press('Escape');
// Focus returns to trigger button
await expect(triggerButton).toBeFocused();
});Image Alt Text Check
test('all product images have alt text', async ({ page }) => {
await page.goto('/products');
// Find all images
const images = page.locator('img');
const count = await images.count();
for (let i = 0; i < count; i++) {
const img = images.nth(i);
const alt = await img.getAttribute('alt');
const src = await img.getAttribute('src');
// Alt should exist and not be empty string (unless decorative with role="presentation")
const role = await img.getAttribute('role');
if (role !== 'presentation') {
expect(alt, `Image ${src} is missing alt text`).not.toBeNull();
expect(alt, `Image ${src} has empty alt text`).not.toBe('');
}
}
});ARIA Roles and Labels
test('form inputs have labels', async ({ page }) => {
await page.goto('/contact');
const inputs = page.locator('input, textarea, select');
const count = await inputs.count();
for (let i = 0; i < count; i++) {
const input = inputs.nth(i);
const type = await input.getAttribute('type');
// Skip hidden inputs
if (type === 'hidden') continue;
const id = await input.getAttribute('id');
const ariaLabel = await input.getAttribute('aria-label');
const ariaLabelledBy = await input.getAttribute('aria-labelledby');
// Input must have a label (via id+label, aria-label, or aria-labelledby)
const hasLabel = id ? await page.locator(`label[for="${id}"]`).count() > 0 : false;
const isLabelled = hasLabel || ariaLabel || ariaLabelledBy;
expect(isLabelled, `Input without label: ${id || 'unnamed'}`).toBeTruthy();
}
});Generating Accessible HTML Reports
import AxeBuilder from '@axe-core/playwright';
// Custom helper that formats violations nicely
async function checkAccessibility(page, selector?: string) {
const builder = new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa']);
if (selector) builder.include(selector);
const results = await builder.analyze();
if (results.violations.length > 0) {
const report = results.violations.map(v =>
`\n[${v.impact?.toUpperCase()}] ${v.id}: ${v.description}\n` +
v.nodes.map(n => ` - ${n.target.join(', ')}: ${n.failureSummary}`).join('\n')
).join('\n');
throw new Error(`Accessibility violations found:\n${report}`);
}
}
test('dashboard is accessible', async ({ page }) => {
await page.goto('/dashboard');
await checkAccessibility(page);
});Common Accessibility Issues Found in Automation
| Issue | axe rule | Fix |
|-------|----------|-----|
| Image no alt | image-alt | Add alt="description" |
| Low contrast | color-contrast | Use contrast ratio ≥ 4.5:1 |
| Input no label | label | Add or aria-label |
| Button no text | button-name | Add text or aria-label |
| Heading order | heading-order | Don't skip h1→h3 |
| Missing lang | html-has-lang | Add |
| Link no name | link-name | Add descriptive link text |
Summary
# Install
npm install --save-dev @axe-core/playwright
# Basic usage
const results = await new AxeBuilder({ page }).analyze();
expect(results.violations).toEqual([]);
# Filter by WCAG level
.withTags(['wcag2a', 'wcag2aa'])
# Scope to element
.include('#main-nav')
# Exclude known issue
.disableRules(['color-contrast'])Run accessibility checks on your critical pages the same way you run functional tests — on every PR, automatically. Combined with keyboard navigation tests and focus management checks, you catch the majority of issues before they reach users who depend on assistive technology.
→ See also: Accessibility Testing for QA Engineers: Tools, Techniques, and the EAA 2025 Deadline | Keyboard and Mouse Events in Playwright | AI Visual Regression Testing: Beyond Pixel-Perfect Screenshots