Playwright Accessibility Testing: axe-core Integration and Best Practices

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 A
  • wcag2aa — WCAG 2.0 Level AA
  • wcag21a, wcag21aa — WCAG 2.1 additions
  • wcag22aa — WCAG 2.2 Level AA additions
  • best-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: 30

Performance Considerations

Running axe on every test is slow. axe DOM analysis typically adds 200–500ms per scan. For large test suites, consider:

  1. Dedicated a11y test file separate from functional tests, run in a separate CI job
  2. Scan on a subset of pages rather than every route (prioritize high-traffic, high-interaction pages)
  3. Cache results for pages that haven't changed since last run
  4. 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.

Read more