| name | accessibility-testing |
| description | Accessibility (a11y) testing patterns with Playwright and axe-core. Use when adding WCAG 2.1 compliance checks, keyboard navigation testing, screen reader compatibility, or color contrast validation to your test suite.
|
Accessibility Testing Skill
Comprehensive guide for integrating accessibility (a11y) testing into your Playwright test automation.
Why Accessibility Testing Matters
- ✅ Ensures your app is usable by everyone
- ✅ Catches issues early in development
- ✅ Compliance with WCAG 2.1 standards
- ✅ Better user experience for all users
- ✅ Legal requirement in many jurisdictions
Quick Start
Install axe-core
npm install --save-dev @axe-core/playwright
Basic Usage
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
test('homepage should not have accessibility violations', async ({ page }) => {
await page.goto('https://your-app.com');
const accessibilityScanResults = await new AxeBuilder({ page }).analyze();
expect(accessibilityScanResults.violations).toEqual([]);
});
Comprehensive Accessibility Testing
1. Automated Accessibility Scans
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
test.describe('Accessibility Checks', () => {
test('prescription search page should be accessible', async ({ page }) => {
await page.goto('/prescriptions/search');
const accessibilityScanResults = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'])
.analyze();
expect(accessibilityScanResults.violations).toEqual([]);
});
test('refill confirmation page should be accessible', async ({ page }) => {
await page.goto('/prescriptions/refill/confirm');
const accessibilityScanResults = await new AxeBuilder({ page })
.exclude('#third-party-widget')
.analyze();
expect(accessibilityScanResults.violations).toEqual([]);
});
});
2. Keyboard Navigation Testing
test('user can navigate prescription list with keyboard', async ({ page }) => {
await page.goto('/prescriptions');
await page.keyboard.press('Tab');
const firstPrescription = page.getByRole('article').first();
await expect(firstPrescription).toBeFocused();
await page.keyboard.press('ArrowDown');
const secondPrescription = page.getByRole('article').nth(1);
await expect(secondPrescription).toBeFocused();
await page.keyboard.press('Enter');
await expect(page).toHaveURL(/.*prescription\/[0-9]+/);
});
test('modal can be closed with Escape key', async ({ page }) => {
await page.goto('/prescriptions');
await page.getByRole('button', { name: 'Refill' }).click();
const modal = page.getByRole('dialog');
await expect(modal).toBeVisible();
await page.keyboard.press('Escape');
await expect(modal).toBeHidden();
});
3. Screen Reader Compatibility
test('images have alt text', async ({ page }) => {
await page.goto('/prescriptions');
const images = page.getByRole('img');
const count = await images.count();
for (let i = 0; i < count; i++) {
const img = images.nth(i);
await expect(img).toHaveAttribute('alt');
}
});
test('form inputs have accessible labels', async ({ page }) => {
await page.goto('/patient/registration');
await expect(page.getByLabel('First name')).toBeVisible();
await expect(page.getByLabel('Last name')).toBeVisible();
await expect(page.getByLabel('Email')).toBeVisible();
await expect(page.getByLabel('Phone')).toBeVisible();
});
test('buttons have accessible names', async ({ page }) => {
await page.goto('/prescriptions');
const buttons = page.getByRole('button');
const count = await buttons.count();
for (let i = 0; i < count; i++) {
const button = buttons.nth(i);
const accessibleName = await button.getAttribute('aria-label') || await button.textContent();
expect(accessibleName).toBeTruthy();
}
});
4. Color Contrast Testing
test('text has sufficient color contrast', async ({ page }) => {
await page.goto('/dashboard');
const accessibilityScanResults = await new AxeBuilder({ page })
.withTags(['wcag2aa'])
.analyze();
const contrastViolations = accessibilityScanResults.violations.filter(
v => v.id === 'color-contrast'
);
expect(contrastViolations).toEqual([]);
});
5. Focus Management
test('focus moves to error message after validation failure', async ({ page }) => {
await page.goto('/prescriptions/refill');
await page.getByRole('button', { name: 'Submit' }).click();
const errorMessage = page.getByRole('alert');
await expect(errorMessage).toBeFocused();
});
test('focus is trapped in modal dialog', async ({ page }) => {
await page.goto('/prescriptions');
await page.getByRole('button', { name: 'Delete' }).click();
const modal = page.getByRole('dialog');
const confirmButton = modal.getByRole('button', { name: 'Confirm' });
const cancelButton = modal.getByRole('button', { name: 'Cancel' });
await page.keyboard.press('Tab');
await expect(confirmButton).toBeFocused();
await page.keyboard.press('Tab');
await expect(cancelButton).toBeFocused();
await page.keyboard.press('Tab');
await expect(confirmButton).toBeFocused();
});
WCAG 2.1 Level AA Checklist
Perceivable
Operable
Understandable
Robust
Accessibility Test Suite Template
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
test.describe('Accessibility Compliance - Prescription Management', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/prescriptions');
});
test('automated accessibility scan', async ({ page }) => {
const results = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'])
.analyze();
expect(results.violations).toEqual([]);
});
test('keyboard navigation', async ({ page }) => {
await page.keyboard.press('Tab');
await expect(page.getByRole('link', { name: 'Skip to main content' })).toBeFocused();
await page.keyboard.press('Tab');
await expect(page.getByRole('link', { name: 'Prescriptions' })).toBeFocused();
});
test('screen reader landmarks', async ({ page }) => {
await expect(page.getByRole('banner')).toBeVisible();
await expect(page.getByRole('navigation')).toBeVisible();
await expect(page.getByRole('main')).toBeVisible();
await expect(page.getByRole('contentinfo')).toBeVisible();
});
test('form accessibility', async ({ page }) => {
await page.getByRole('button', { name: 'Search' }).click();
await expect(page.getByLabel('Medication name')).toBeVisible();
await expect(page.getByLabel('Prescription number')).toBeVisible();
});
});
Common Accessibility Issues & Fixes
Issue 1: Missing Alt Text
<img src="prescription.jpg">
<img src="prescription.jpg" alt="Lisinopril 10mg prescription">
Issue 2: Poor Color Contrast
.text {
color: #999999;
background: #ffffff;
}
.text {
color: #333333;
background: #ffffff;
}
Issue 3: Non-Accessible Buttons
<div onclick="submit()">Submit</div>
<button type="submit">Submit</button>
Issue 4: Missing Form Labels
<input type="text" name="email" placeholder="Email">
<label for="email">Email</label>
<input type="text" id="email" name="email">
Playwright Native Accessibility Assertions
Playwright provides built-in matchers for element-level accessibility checks — no axe-core needed. Use these for targeted regression testing alongside full-page scans.
toHaveAccessibleName()
Verifies an element's accessible name (what screen readers announce).
test('buttons have correct accessible names', async ({ page }) => {
await page.goto('/prescriptions');
await expect(page.getByRole('button', { name: 'Refill' }))
.toHaveAccessibleName('Refill prescription');
await expect(page.getByRole('link', { name: 'View details' }))
.toHaveAccessibleName('View details for Lisinopril 10mg');
await expect(page.getByTestId('close-btn'))
.toHaveAccessibleName('Close dialog');
});
test('form inputs have proper accessible names', async ({ page }) => {
await page.goto('/patient/registration');
await expect(page.getByRole('textbox', { name: 'First name' }))
.toHaveAccessibleName('First name');
await expect(page.getByRole('textbox', { name: 'Email address' }))
.toHaveAccessibleName('Email address');
await expect(page.getByRole('combobox').first())
.toHaveAccessibleName(/state|province/i);
});
toHaveAccessibleDescription()
Verifies the element's accessible description (additional context for screen readers, often from aria-describedby).
test('form fields have helpful descriptions', async ({ page }) => {
await page.goto('/patient/registration');
await expect(page.getByLabel('Password'))
.toHaveAccessibleDescription('Must be at least 8 characters with one number');
await expect(page.getByLabel('Date of birth'))
.toHaveAccessibleDescription(/MM\/DD\/YYYY/);
});
toHaveAccessibleErrorMessage()
Verifies error messages are properly associated with form inputs via aria-errormessage.
test('form validation shows accessible error messages', async ({ page }) => {
await page.goto('/patient/registration');
await page.getByRole('button', { name: 'Register' }).click();
await expect(page.getByLabel('Email address'))
.toHaveAccessibleErrorMessage('Email is required');
await expect(page.getByLabel('Password'))
.toHaveAccessibleErrorMessage('Password must be at least 8 characters');
await page.getByLabel('Email address').fill('user@example.com');
await page.getByLabel('Email address').blur();
await expect(page.getByLabel('Email address'))
.not.toHaveAccessibleErrorMessage();
});
Combining Native Assertions with axe-core
test.describe('Comprehensive A11y: Scan + Element Assertions', () => {
test('login form is fully accessible', async ({ page }) => {
await page.goto('/login');
const results = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa'])
.analyze();
expect(results.violations).toEqual([]);
await expect(page.getByRole('textbox', { name: 'Email' }))
.toHaveAccessibleName('Email');
await expect(page.getByLabel('Password'))
.toHaveAccessibleDescription(/at least 8 characters/);
await expect(page.getByRole('button', { name: 'Sign in' }))
.toHaveAccessibleName('Sign in');
});
});
Alternative: axe-playwright (Community Library)
axe-playwright offers a simpler API than @axe-core/playwright. Good for teams that want minimal setup.
Install
npm install --save-dev axe-playwright
Usage
import { test, expect } from '@playwright/test';
import { injectAxe, checkA11y, getViolations } from 'axe-playwright';
test.describe('A11y with axe-playwright', () => {
test('homepage is accessible', async ({ page }) => {
await page.goto('/');
await injectAxe(page);
await checkA11y(page);
});
test('scoped scan on specific element', async ({ page }) => {
await page.goto('/prescriptions');
await injectAxe(page);
await checkA11y(page, '#main-content', {
axeOptions: {
runOnly: {
type: 'tag',
values: ['wcag2a', 'wcag2aa'],
},
},
});
});
test('get violations for custom reporting', async ({ page }) => {
await page.goto('/dashboard');
await injectAxe(page);
const violations = await getViolations(page);
if (violations.length > 0) {
const report = violations.map(v => ({
rule: v.id,
impact: v.impact,
description: v.description,
elements: v.nodes.length,
}));
console.table(report);
}
expect(violations).toHaveLength(0);
});
});
@axe-core/playwright vs axe-playwright
| Feature | @axe-core/playwright | axe-playwright |
|---|
| Maintainer | Deque Systems (official) | Community |
| API style | Builder pattern (new AxeBuilder()) | Function calls (injectAxe + checkA11y) |
| Setup | Import and use directly | Inject into page first |
| Flexibility | High (include/exclude, tags, rules) | Moderate |
| Auto-fail on violations | No (you assert manually) | Yes (configurable) |
| Recommendation | ✅ Use for production projects | Good for quick checks |
Accessibility Regression Testing in CI
Track a11y violations over time and prevent regressions in your pipeline.
A11y Fixture for Every Page
import { test as base, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
type A11yFixtures = {
makeAxeBuilder: () => AxeBuilder;
};
export const test = base.extend<A11yFixtures>({
makeAxeBuilder: async ({ page }, use) => {
await use(() =>
new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'])
);
},
});
export { expect };
Run A11y Scans on Every Critical Page
import { test, expect } from '../fixtures/a11y-fixture';
const criticalPages = [
{ name: 'Home', path: '/' },
{ name: 'Login', path: '/login' },
{ name: 'Dashboard', path: '/dashboard' },
{ name: 'Prescriptions', path: '/prescriptions' },
{ name: 'Profile', path: '/profile' },
{ name: 'Settings', path: '/settings' },
];
for (const { name, path } of criticalPages) {
test(`a11y regression: ${name} page`, async ({ page, makeAxeBuilder }) => {
await page.goto(path);
const results = await makeAxeBuilder().analyze();
if (results.violations.length > 0) {
const violationSummary = results.violations.map(v => ({
id: v.id,
impact: v.impact,
description: v.description,
nodes: v.nodes.length,
}));
console.log(`A11y violations on ${name}:`, JSON.stringify(violationSummary, null, 2));
}
expect(results.violations).toEqual([]);
});
}
Tag A11y Tests for Selective CI Runs
test('full a11y audit @a11y @regression', async ({ page, makeAxeBuilder }) => {
await page.goto('/');
const results = await makeAxeBuilder().analyze();
expect(results.violations).toEqual([]);
});
npx playwright test --grep @a11y
npx playwright test --grep @a11y --project=chromium
Related Resources