Introduction
Most teams catch bugs through manual testing or end-to-end test coverage, but accessibility issues often slip through until users report them—or worse, until a compliance audit catches them. Accessibility testing with Playwright: a practical guide gives you the tools to shift left and automate a meaningful chunk of accessibility validation directly in your test suite. We're not going to catch every issue programmatically, but integrating automated accessibility checks into your CI pipeline catches real problems early and holds the line as your codebase grows.
Why automated accessibility testing matters
Building accessible products isn't just about compliance. It's about ensuring your application works for users with visual, motor, hearing, and cognitive disabilities. Tools like screen readers, voice control, and keyboard navigation depend on proper semantic HTML, ARIA attributes, color contrast, and logical focus order.
Manual accessibility audits are thorough but expensive and slow. Automated checks, particularly those built into your test suite, run on every commit and catch regressions instantly. A practical accessibility testing with Playwright setup lets you find issues in development rather than production.
axe integration: scanning pages automatically
The axe accessibility engine, maintained by Deque Systems, is one of the most reliable automated accessibility scanners available. The @axe-core/playwright package integrates axe directly into your Playwright tests, letting you scan entire pages or specific regions for violations.
Setting up axe-core/playwright
Start by installing the package:
npm install --save-dev @axe-core/playwrightThen create a basic test that scans a page:
import { test, expect } from '@playwright/test';
import { injectAxe, checkA11y } from '@axe-core/playwright';
test('homepage should not have accessibility violations', async ({ page }) => {
await page.goto('https://app.example.com');
await injectAxe(page);
await checkA11y(page);
});The injectAxe function loads the axe engine into the page's context. checkA11y then runs a full scan and throws an error if violations are found. If you want to see what violations were detected, you can catch the error:
import { test, expect } from '@playwright/test';
import { injectAxe, getViolations } from '@axe-core/playwright';
test('log accessibility violations', async ({ page }) => {
await page.goto('https://app.example.com');
await injectAxe(page);
const violations = await getViolations(page);
if (violations.length > 0) {
console.log('Violations found:');
violations.forEach(v => {
console.log(` ${v.id}: ${v.description}`);
console.log(` Impact: ${v.impact}`);
console.log(` Elements: ${v.nodes.length}`);
});
}
});Each violation includes an ID (like color-contrast, missing-alt-text, button-name), a description, impact level, and the affected DOM nodes. This structure lets you understand what's broken and where.
Scanning specific regions
Running axe on an entire page can generate noise from third-party embeds or unrelated UI. You can scan a specific region instead:
test('scan only the form region', async ({ page }) => {
await page.goto('https://app.example.com/checkout');
await injectAxe(page);
const form = page.locator('form[data-testid="checkout-form"]');
await checkA11y(page, form);
});This focuses your scan on the component you're testing, reducing false positives from unrelated content.
Run per page: checking key pages in CI
Scanning every page in your application can slow down CI, but scanning the critical user journeys will catch most accessibility regressions. Prioritize pages where users perform key tasks: login, checkout, payment, form submission, search, and account settings.
Creating a dedicated accessibility test suite
Build a separate test file focused on accessibility checks across your most important pages:
import { test, expect } from '@playwright/test';
import { injectAxe, checkA11y } from '@axe-core/playwright';
const criticalPages = [
{ name: 'Login', url: '/login' },
{ name: 'Dashboard', url: '/dashboard' },
{ name: 'Checkout', url: '/checkout' },
{ name: 'Payment', url: '/checkout/payment' },
{ name: 'Confirmation', url: '/order/confirmation' },
];
criticalPages.forEach(page => {
test(`${page.name} page should have no accessibility violations`, async ({ page: browserPage }) => {
await browserPage.goto(page.url);
await injectAxe(browserPage);
await checkA11y(browserPage);
});
});Run this suite in CI on every push. It's fast, covers your highest-risk areas, and gives you early feedback on accessibility regressions.
Handling dynamic content
If your pages load content dynamically (modals, dropdowns, lazy-loaded lists), wait for that content before scanning:
test('scan modal after opening', async ({ page }) => {
await page.goto('https://app.example.com');
// Open the modal
await page.click('[data-testid="open-modal"]');
// Wait for modal content to render
await page.waitForSelector('[role="dialog"]');
// Now scan
await injectAxe(page);
const modal = page.locator('[role="dialog"]');
await checkA11y(page, modal);
});This ensures axe scans the actual content users will interact with, not an empty placeholder.
Role locators help: accessible names and semantic HTML
Using role-based locators in your tests acts as an accessibility check itself. When you query by role, you're verifying that elements have proper semantic meaning and accessible names—two cornerstones of accessibility.
Why role locators matter
A button might be styled as a button visually, but if it's a <div> without the button role and no keyboard support, screen reader users won't recognize it as interactive. When you write your test using page.getByRole('button', { name: 'Submit' }), you're asserting that the button exists with the correct accessible name. If that assertion fails, it's an accessibility bug.
Practical example: testing a form
Instead of brittle selectors like #submit-btn, write tests that rely on accessible names:
test('submit form using role locators', async ({ page }) => {
await page.goto('https://app.example.com/signup');
// Query inputs by their labels (accessible names)
await page.getByLabel('Email Address').fill('user@example.com');
await page.getByLabel('Password').fill('SecurePassword123');
// Query button by its accessible name
await page.getByRole('button', { name: 'Create Account' }).click();
// Assert success
await expect(page.getByText('Account created')).toBeVisible();
});If an input doesn't have a proper label, getByLabel will fail. If a button's text doesn't match its accessible name or it's not a real button element, getByRole will fail. Your tests become accessibility validators by design.
Finding accessible names with getByRole
When you're unsure if an element has the right accessible name, inspect it:
test('debug accessible names', async ({ page }) => {
await page.goto('https://app.example.com');
// Get all buttons and log their accessible names
const buttons = page.locator('button');
const count = await buttons.count();
for (let i = 0; i < count; i++) {
const name = await buttons.nth(i).getAttribute('aria-label')
|| await buttons.nth(i).textContent();
console.log(`Button ${i}: "${name}"`);
}
});This reveals which elements lack proper names—a signal that they need ARIA attributes or semantic restructuring.
Manual checks still matter: automation finds about a third of issues
Automated accessibility scanning is powerful, but it's not complete. Tools like axe catch objective, programmatic issues: missing alt text, color contrast, missing form labels, missing roles. They miss subjective problems: confusing link text, poor visual hierarchy, logical tab order issues, or complex interactions that only a human with assistive technology can validate.
What automation catches well
Automated tools excel at detecting:
- Missing alt text on images
- Color contrast violations
- Missing form labels and required attributes
- Missing or incorrect ARIA roles
- Duplicate IDs
- Keyboard traps (elements where focus can't escape)
What requires manual testing
Plan quarterly manual accessibility audits using real assistive technologies:
- Screen reader testing: NVDA (Windows), VoiceOver (Mac), JAWS (Windows)
- Keyboard-only navigation: Can you reach every interactive element and submit forms using only Tab, Enter, and Space?
- Magnification: Does your layout work at 200% zoom?
- Cognitive load: Is content organized logically? Are instructions clear?
Many teams pair automated scanning in CI with a monthly or quarterly manual session. Automated checks hold the line against regressions; manual testing catches the gaps and validates that the user experience actually works for people using assistive technology.
Common mistakes
Ignoring violations in CI
It's tempting to suppress or ignore axe violations to keep CI green. Don't. Every violation you ignore becomes debt. If a violation can't be fixed immediately, create a tracking issue and document why it's accepted. Better yet, use axe's configuration to defer checking on certain rules until they're addressed.
Scanning only at page load
If your page updates after load (tabs open, filters apply, content loads), scan after those state changes. A violation that appears in a hidden tab is still a violation for users who navigate to that tab.
Using inaccessible test selectors
Tests that bypass role locators and use CSS selectors or XPath don't verify accessibility. Shift to getByRole, getByLabel, and getByPlaceholder as much as possible. Your test suite becomes a first line of defense.
Skipping keyboard navigation tests
Write explicit tests for keyboard interactions:
test('navigate form with keyboard only', async ({ page }) => {
await page.goto('https://app.example.com/form');
// Tab to the first field and fill it
await page.keyboard.press('Tab');
await page.keyboard.type('John Doe');
// Tab to the next field
await page.keyboard.press('Tab');
await page.keyboard.type('john@example.com');
// Tab to submit button and activate it
await page.keyboard.press('Tab');
await page.keyboard.press('Enter');
// Assert form submission
await expect(page.getByText('Form submitted')).toBeVisible();
});This validates that focus moves logically and all interactions work without a mouse.
Key takeaways
- Integrate
@axe-core/playwrightinto your test suite to catch automated accessibility violations on every commit. - Focus automated scans on critical user journeys—login, checkout, forms—rather than every page to keep CI fast.
- Use role-based locators (
getByRole,getByLabel) in your tests to verify accessible names and semantic HTML by default. - Recognize that automation finds about one-third of accessibility issues; pair automated checks with quarterly manual testing using real assistive technology.
- Create a culture where accessibility violations are tracked and fixed like any other bug, not suppressed or ignored.
- Test dynamic content and state changes; scan modals, dropdowns, and lazy-loaded sections before they're hidden from users.






Comments
Loading comments…