Skip to content
ExponaBLOG
PLAYWRIGHT & BROWSER AUTOMATION · DAY 039 / 100

Accessibility testing with Playwright: a practical guide

Catch accessibility issues automatically.

Expona Admin8 min read

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:

typescript
npm install --save-dev @axe-core/playwright

Then create a basic test that scans a page:

typescript
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:

typescript
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:

typescript
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:

typescript
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:

typescript
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:

typescript
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:

typescript
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:

typescript
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/playwright into 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.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles