| 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.(, { : }).();
modal = page.();
(modal).();
page..();
(modal).();
});
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();
});
(, ({ page }) => {
page.();
buttons = page.();
count = buttons.();
( i = ; i < count; i++) {
button = buttons.(i);
accessibleName = button.() || button.();
(accessibleName).();
}
});
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..();
(confirmButton).();
page..();
(cancelButton).();
page..();
(confirmButton).();
});
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.();
(page.(, { : })).();
});
(, ({ page }) => {
(page.()).();
(page.()).();
(page.()).();
(page.()).();
});
(, ({ page }) => {
page.(, { : }).();
(page.()).();
(page.()).();
});
});
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(, { : }))
.();
(page.().())
.();
});
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');
(page);
violations = (page);
(violations. > ) {
report = violations.( ({
: v.,
: v.,
: v.,
: v..,
}));
.(report);
}
(violations).();
});
});
@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.,
: v.,
: v.,
: v..,
}));
.(, .(violationSummary, , ));
}
(results.).([]);
});
}
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