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 (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';
importAxeBuilderfrom'@axe-core/playwright';
test('homepage should not have accessibility violations', async ({ page }) => {
await page.goto('https://your-app.com');
const accessibilityScanResults = awaitnewAxeBuilder({ page }).analyze();
expect(accessibilityScanResults.).([]);
});
violations
toEqual
Comprehensive Accessibility Testing
1. Automated Accessibility Scans
import { test, expect } from'@playwright/test';
importAxeBuilderfrom'@axe-core/playwright';
test.describe('Accessibility Checks', () => {
test('prescription search page should be accessible', async ({ page }) => {
await page.goto('/prescriptions/search');
const accessibilityScanResults = awaitnewAxeBuilder({ 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 = awaitnewAxeBuilder({ page })
.exclude('#third-party-widget') // Exclude elements you don't control
.analyze();
expect(accessibilityScanResults.violations).toEqual([]);
});
});
2. Keyboard Navigation Testing
test('user can navigate prescription list with keyboard', async ({ page }) => {
await page.goto('/prescriptions');
// Tab to first prescriptionawait page.keyboard.press('Tab');
const firstPrescription = page.getByRole('article').first();
awaitexpect(firstPrescription).toBeFocused();
// Navigate with arrow keysawait page.keyboard.press('ArrowDown');
const secondPrescription = page.getByRole('article').nth(1);
awaitexpect(secondPrescription).toBeFocused();
// Activate with Enterawait page.keyboard.press('Enter');
awaitexpect(page).toHaveURL(/.*prescription\/[0-9]+/);
});
test('modal can be closed with Escape key', async ({ page }) => {
await page.goto('/prescriptions');
// Open modalawait page.getByRole('button', { name: 'Refill' }).click();
const modal = page.getByRole('dialog');
awaitexpect(modal).toBeVisible();
// Close with Escapeawait page.keyboard.press('Escape');
awaitexpect(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);
awaitexpect(img).toHaveAttribute('alt');
}
});
test('form inputs have accessible labels', async ({ page }) => {
await page.goto('/patient/registration');
// All inputs should be accessible by labelawaitexpect(page.getByLabel('First name')).toBeVisible();
awaitexpect(page.getByLabel('Last name')).toBeVisible();
awaitexpect(page.getByLabel('Email')).toBeVisible();
awaitexpect(page.getByLabel('Phone')).toBeVisible();
});
test('buttons have accessible names', async ({ page }) => {
await page.goto('/prescriptions');
// Verify all buttons have accessible namesconst 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 = awaitnewAxeBuilder({ page })
.withTags(['wcag2aa'])
.analyze();
// Check for color contrast violationsconst 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');
// Submit form without filling required fieldsawait page.getByRole('button', { name: 'Submit' }).click();
// Focus should move to error message or first invalid fieldconst errorMessage = page.getByRole('alert');
awaitexpect(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' });
// Tab through modal elementsawait page.keyboard.press('Tab');
awaitexpect(confirmButton).toBeFocused();
await page.keyboard.press('Tab');
awaitexpect(cancelButton).toBeFocused();
// Tab again should cycle back to first element (focus trap)await page.keyboard.press('Tab');
awaitexpect(confirmButton).toBeFocused();
});
WCAG 2.1 Level AA Checklist
Perceivable
All images have alt text
Text has sufficient color contrast (4.5:1 for normal text, 3:1 for large text)
Content is accessible without relying on color alone
<!-- ❌ Bad --><imgsrc="prescription.jpg"><!-- ✅ Good --><imgsrc="prescription.jpg"alt="Lisinopril 10mg prescription">
Issue 2: Poor Color Contrast
/* ❌ Bad - Low contrast */.text {
color: #999999;
background: #ffffff;
}
/* ✅ Good - High contrast */.text {
color: #333333;
background: #ffffff;
}
Issue 3: Non-Accessible Buttons
<!-- ❌ Bad - Div as button --><divonclick="submit()">Submit</div><!-- ✅ Good - Semantic button --><buttontype="submit">Submit</button>
Issue 4: Missing Form Labels
<!-- ❌ Bad - No label --><inputtype="text"name="email"placeholder="Email"><!-- ✅ Good - Proper label --><labelfor="email">Email</label><inputtype="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');
// Verify accessible names on interactive elementsawaitexpect(page.getByRole('button', { name: 'Refill' }))
.toHaveAccessibleName('Refill prescription');
awaitexpect(page.getByRole('link', { name: 'View details' }))
.toHaveAccessibleName('View details for Lisinopril 10mg');
// Icon-only button should have an accessible name via aria-labelawaitexpect(page.getByTestId('close-btn'))
.toHaveAccessibleName('Close dialog');
});
test('form inputs have proper accessible names', async ({ page }) => {
await page.goto('/patient/registration');
awaitexpect(page.getByRole('textbox', { name: 'First name' }))
.toHaveAccessibleName('First name');
awaitexpect(page.getByRole('textbox', { name: 'Email address' }))
.toHaveAccessibleName('Email address');
// Supports regex matchingawaitexpect(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');
// Password field should describe requirementsawaitexpect(page.getByLabel('Password'))
.toHaveAccessibleDescription('Must be at least 8 characters with one number');
// Date field should describe formatawaitexpect(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');
// Submit empty form to trigger validationawait page.getByRole('button', { name: 'Register' }).click();
// Verify error messages are programmatically associated with inputsawaitexpect(page.getByLabel('Email address'))
.toHaveAccessibleErrorMessage('Email is required');
awaitexpect(page.getByLabel('Password'))
.toHaveAccessibleErrorMessage('Password must be at least 8 characters');
// After fixing the error, error message should clearawait page.getByLabel('Email address').fill('user@example.com');
await page.getByLabel('Email address').blur();
awaitexpect(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');
// 1. Full page scan with axe-coreconst results = awaitnewAxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa'])
.analyze();
expect(results.violations).toEqual([]);
// 2. Element-level assertions for regression safetyawaitexpect(page.getByRole('textbox', { name: 'Email' }))
.toHaveAccessibleName('Email');
awaitexpect(page.getByLabel('Password'))
.toHaveAccessibleDescription(/at least 8 characters/);
awaitexpect(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('/');
// Step 1: Inject axe-core into the pageawaitinjectAxe(page);
// Step 2: Run accessibility check (auto-fails on violations)awaitcheckA11y(page);
});
test('scoped scan on specific element', async ({ page }) => {
await page.goto('/prescriptions');
awaitinjectAxe(page);
// Check only the main content areaawaitcheckA11y(page, '#main-content', {
axeOptions: {
runOnly: {
type: 'tag',
values: ['wcag2a', 'wcag2aa'],
},
},
});
});
test('get violations for custom reporting', async ({ page }) => {
await page.goto('/dashboard');
awaitinjectAxe(page);
// Get violations without auto-failing (for custom handling)const violations = awaitgetViolations(page);
// Custom assertion with detailed reportingif (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.
# Run only a11y tests in CI
npx playwright test --grep @a11y
# Run a11y tests nightly (not on every PR)
npx playwright test --grep @a11y --project=chromium