Playwright Accessibility Testing: axe-core Integration and Best Practices
Playwright's browser automation and axe-core's accessibility engine are a natural pairing. Playwright handles navigation, interaction, and state management. axe-core scans the DOM for WCAG violations. Together, they let you run accessibility checks at the same time as your functional tests — or in a dedicated suite that covers every route in your application.
Project Setup
npm install --save-dev @playwright/test @axe-core/playwright
npx playwright install// playwright.config.js
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: './tests',
use: {
baseURL: 'http://localhost:3000',
},
projects: [
{
name: 'chromium',
use: { browserName: 'chromium' },
},
{
name: 'firefox',
use: { browserName: 'firefox' },
},
],
});Basic Page Scan
The minimal axe test: navigate to a page, run the scanner, assert no violations.
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
test('homepage has no accessibility violations', async ({ page }) => {
await page.goto('/');
const results = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa', 'wcag21aa'])
.analyze();
expect(results.violations).toEqual([]);
});The .withTags() call scopes the scan to specific WCAG levels. Without it, axe runs all rules including best-practice rules that aren't strictly WCAG requirements. For compliance testing, be explicit about what you're checking.
Available tag sets:
wcag2a— WCAG 2.0 Level Awcag2aa— WCAG 2.0 Level AAwcag21a,wcag21aa— WCAG 2.1 additionswcag22aa— WCAG 2.2 Level AA additionsbest-practice— Non-WCAG recommendations
Testing All Routes Systematically
Don't just test your homepage. Every route is a separate accessibility surface. Write a parameterized test that covers your full page inventory:
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
const PUBLIC_ROUTES = [
'/',
'/about',
'/pricing',
'/blog',
'/contact',
'/login',
'/register',
];
for (const route of PUBLIC_ROUTES) {
test(`${route} has no WCAG 2.1 AA violations`, async ({ page }) => {
await page.goto(route);
await page.waitForLoadState('networkidle');
const results = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa', 'wcag21aa'])
.analyze();
expect(results.violations).toEqual([]);
});
}Testing Authenticated Routes
Most applications have a substantial authenticated surface. Test it too.
Step 1: Set up auth state once
// tests/auth.setup.js
import { test as setup } from '@playwright/test';
setup('authenticate', async ({ page }) => {
await page.goto('/login');
await page.fill('[name="email"]', process.env.TEST_USER_EMAIL);
await page.fill('[name="password"]', process.env.TEST_USER_PASSWORD);
await page.click('[type="submit"]');
await page.waitForURL('/dashboard');
// Save the authenticated state
await page.context().storageState({ path: 'playwright/.auth/user.json' });
});// playwright.config.js
export default defineConfig({
projects: [
{
name: 'setup',
testMatch: /auth\.setup\.js/,
},
{
name: 'authenticated',
testDir: './tests/authenticated',
use: {
storageState: 'playwright/.auth/user.json',
},
dependencies: ['setup'],
},
],
});Step 2: Test authenticated routes
// tests/authenticated/dashboard-a11y.test.js
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
const AUTHENTICATED_ROUTES = [
'/dashboard',
'/settings',
'/settings/profile',
'/settings/billing',
'/reports',
];
for (const route of AUTHENTICATED_ROUTES) {
test(`${route} has no accessibility violations`, async ({ page }) => {
await page.goto(route);
await page.waitForLoadState('networkidle');
const results = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa', 'wcag21aa'])
.analyze();
expect(results.violations).toEqual([]);
});
}Testing Interactive States
Static page scans miss accessibility issues that only appear in interactive states: modals, dropdowns, error states, expanded accordions.
test('modal dialog is accessible', async ({ page }) => {
await page.goto('/');
// Open the modal
await page.click('[data-testid="open-settings-modal"]');
await page.waitForSelector('[role="dialog"]', { state: 'visible' });
// Scan the modal only
const results = await new AxeBuilder({ page })
.include('[role="dialog"]')
.withTags(['wcag2a', 'wcag2aa'])
.analyze();
expect(results.violations).toEqual([]);
});
test('navigation menu is accessible when expanded', async ({ page }) => {
await page.goto('/');
// Mobile viewport to trigger hamburger menu
await page.setViewportSize({ width: 375, height: 812 });
await page.click('[aria-label="Open menu"]');
await page.waitForSelector('[aria-expanded="true"]');
const results = await new AxeBuilder({ page })
.include('nav')
.withTags(['wcag2a', 'wcag2aa'])
.analyze();
expect(results.violations).toEqual([]);
});
test('form validation errors are accessible', async ({ page }) => {
await page.goto('/register');
// Submit empty form to trigger validation
await page.click('[type="submit"]');
await page.waitForSelector('[role="alert"], .error-message');
const results = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa'])
.analyze();
expect(results.violations).toEqual([]);
});
test('loading states are accessible', async ({ page }) => {
await page.goto('/search');
// Trigger a search
await page.fill('[name="q"]', 'test query');
await page.keyboard.press('Enter');
// Scan during loading state
const loadingResults = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa'])
.analyze();
expect(loadingResults.violations).toEqual([]);
// Scan after results load
await page.waitForSelector('[data-testid="search-results"]');
const resultsResults = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa'])
.analyze();
expect(resultsResults.violations).toEqual([]);
});Testing Focus Management
Playwright can simulate keyboard navigation, which lets you verify focus behavior:
test('keyboard focus management in modal', async ({ page }) => {
await page.goto('/');
// Track where focus is
const getFocusedElement = () =>
page.evaluate(() => {
const el = document.activeElement;
return {
tag: el.tagName.toLowerCase(),
role: el.getAttribute('role'),
label: el.getAttribute('aria-label') || el.textContent?.trim().slice(0, 50),
};
});
// Open modal
await page.click('[data-testid="open-modal"]');
await page.waitForSelector('[role="dialog"]');
// Focus should move into the modal
const focusInModal = await getFocusedElement();
const modalContains = await page.evaluate(() => {
const modal = document.querySelector('[role="dialog"]');
return modal?.contains(document.activeElement);
});
expect(modalContains).toBe(true);
// Tab through all focusable elements — should stay in modal
const maxTabs = 20;
for (let i = 0; i < maxTabs; i++) {
await page.keyboard.press('Tab');
const inModal = await page.evaluate(() => {
const modal = document.querySelector('[role="dialog"]');
return modal?.contains(document.activeElement);
});
expect(inModal).toBe(true);
}
// Escape should close and return focus to trigger
await page.keyboard.press('Escape');
await page.waitForSelector('[role="dialog"]', { state: 'hidden' });
const focusAfterClose = await getFocusedElement();
// Focus should be back on the element that opened the modal
expect(focusAfterClose.label).toContain('open'); // adjust to your button text
});Excluding Third-Party Content
Third-party widgets (chat, analytics, ads) often have accessibility issues you can't fix. Exclude them from your scans:
// Create a reusable builder with standard exclusions
function createAxeBuilder(page) {
return new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa', 'wcag21aa'])
.exclude('#hubspot-messages-iframe-container')
.exclude('[data-google-analytics]')
.exclude('#third-party-chat');
}
test('homepage is accessible', async ({ page }) => {
await page.goto('/');
const results = await createAxeBuilder(page).analyze();
expect(results.violations).toEqual([]);
});Surfacing Violations in Test Output
The default violation output is hard to read in CI logs. Format it:
// tests/helpers/a11y.js
export function assertNoViolations(results) {
if (results.violations.length === 0) return;
const message = results.violations
.map(violation => {
const nodes = violation.nodes
.map(node => ` → ${node.html.replace(/\s+/g, ' ').trim().slice(0, 100)}`)
.join('\n');
return [
`[${violation.impact.toUpperCase()}] ${violation.id}`,
` Rule: ${violation.description}`,
` Help: ${violation.helpUrl}`,
` Nodes (${violation.nodes.length}):`,
nodes,
].join('\n');
})
.join('\n\n');
throw new Error(`\nAccessibility violations found:\n\n${message}`);
}import { test } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
import { assertNoViolations } from './helpers/a11y';
test('accessible', async ({ page }) => {
await page.goto('/');
const results = await new AxeBuilder({ page }).analyze();
assertNoViolations(results);
});CI Integration with GitHub Actions
# .github/workflows/a11y.yml
name: Accessibility Tests
on:
pull_request:
push:
branches: [main]
jobs:
accessibility:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- run: npx playwright install --with-deps chromium firefox
- name: Start app
run: npm run start:test &
- name: Wait for app
run: npx wait-on http://localhost:3000 --timeout 30000
- name: Run accessibility tests
run: npx playwright test tests/a11y/
env:
TEST_USER_EMAIL: ${{ secrets.TEST_USER_EMAIL }}
TEST_USER_PASSWORD: ${{ secrets.TEST_USER_PASSWORD }}
- name: Upload results
if: always()
uses: actions/upload-artifact@v4
with:
name: accessibility-report
path: playwright-report/
retention-days: 30Performance Considerations
Running axe on every test is slow. axe DOM analysis typically adds 200–500ms per scan. For large test suites, consider:
- Dedicated a11y test file separate from functional tests, run in a separate CI job
- Scan on a subset of pages rather than every route (prioritize high-traffic, high-interaction pages)
- Cache results for pages that haven't changed since last run
- Run a11y tests less frequently — on main push, not every PR, if CI time is constrained
The right tradeoff depends on your application size and accessibility risk tolerance.