axe-core + Playwright: Automated Accessibility Testing in CI

axe-core + Playwright: Automated Accessibility Testing in CI

Accessibility bugs are cheap to fix when caught during development and expensive to fix in production. Automated accessibility testing with axe-core and Playwright catches a significant portion of WCAG violations without manual effort — and it fits neatly into any CI pipeline. This guide walks through everything from installation to handling known violations in a mature test suite.

Why axe-core?

axe-core is the engine behind most serious accessibility testing tools. It powers browser extensions (axe DevTools), integrations for Cypress, WebdriverIO, Selenium, and the @axe-core/playwright package. Deque Systems maintains it, and it's used by organizations like Microsoft, Google, and the US government.

What makes axe-core credible:

  • Zero false positives by design. The team deliberately keeps rules conservative — if axe flags something, it's a real violation.
  • Covers roughly 57% of WCAG 2.1 success criteria automatically.
  • Rules are individually documented with impact levels (critical, serious, moderate, minor) and links to fixes.

The remaining ~43% of WCAG issues require human judgment (color contrast ratios, cognitive load, screen reader flow) — but catching the automatable 57% in CI is a substantial baseline.

Installation

Start with a working Playwright setup. If you don't have one yet:

npm init playwright@latest

Then install the axe-core Playwright integration:

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

That's it. No additional browser setup needed — @axe-core/playwright injects axe-core into the page and runs analysis in the browser context.

Basic Page Scan

Here's the minimal setup to scan a full page:

// tests/accessibility.spec.ts
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';

test('homepage should have no accessibility violations', async ({ page }) => {
  await page.goto('/');

  const accessibilityScanResults = await new AxeBuilder({ page }).analyze();

  expect(accessibilityScanResults.violations).toEqual([]);
});

When this test fails, the violations array tells you exactly what's wrong. Each violation object has:

{
  id: 'color-contrast',
  impact: 'serious',
  description: 'Ensure the contrast between foreground and background colors meets WCAG 2 AA contrast ratio thresholds',
  nodes: [
    {
      html: '<span class="muted-text">Secondary info</span>',
      failureSummary: 'Fix any of the following: Element has insufficient color contrast...',
      target: ['.muted-text']
    }
  ]
}

Scanning Specific Components

Full-page scans are useful but noisy during early development. You can scope the scan to a specific DOM element using include:

test('navigation menu should be accessible', async ({ page }) => {
  await page.goto('/');

  const results = await new AxeBuilder({ page })
    .include('nav[aria-label="Main navigation"]')
    .analyze();

  expect(results.violations).toEqual([]);
});

test('login form should be accessible', async ({ page }) => {
  await page.goto('/login');

  const results = await new AxeBuilder({ page })
    .include('#login-form')
    .analyze();

  expect(results.violations).toEqual([]);
});

Conversely, use exclude to skip third-party widgets or components you don't own:

test('page content should be accessible (excluding chat widget)', async ({ page }) => {
  await page.goto('/');

  const results = await new AxeBuilder({ page })
    .exclude('#intercom-container')
    .exclude('.cookie-consent-banner')
    .analyze();

  expect(results.violations).toEqual([]);
});

Testing Interactive States

Static page scans miss violations that only appear in dynamic states — open modals, expanded dropdowns, error messages. Test those explicitly:

test('modal dialog should be accessible when open', async ({ page }) => {
  await page.goto('/dashboard');

  // Open the modal
  await page.click('[data-testid="open-settings-modal"]');
  await page.waitForSelector('[role="dialog"]', { state: 'visible' });

  const results = await new AxeBuilder({ page })
    .include('[role="dialog"]')
    .analyze();

  expect(results.violations).toEqual([]);
});

test('form error states should be accessible', async ({ page }) => {
  await page.goto('/signup');

  // Trigger validation errors
  await page.click('[type="submit"]');
  await page.waitForSelector('[aria-invalid="true"]');

  const results = await new AxeBuilder({ page }).analyze();

  expect(results.violations).toEqual([]);
});

test('dropdown menu should be accessible when open', async ({ page }) => {
  await page.goto('/');

  await page.click('[aria-haspopup="true"]');
  await page.waitForSelector('[role="menu"]', { state: 'visible' });

  const results = await new AxeBuilder({ page })
    .include('[role="menu"]')
    .analyze();

  expect(results.violations).toEqual([]);
});

Configuring Rules

axe-core has over 100 rules. You can configure which ones run:

// Run only WCAG 2.1 AA rules
const results = await new AxeBuilder({ page })
  .withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'])
  .analyze();

// Disable specific rules you're intentionally deferring
const results = await new AxeBuilder({ page })
  .disableRules(['color-contrast'])
  .analyze();

// Run only specific rules
const results = await new AxeBuilder({ page })
  .withRules(['aria-required-attr', 'label', 'heading-order'])
  .analyze();

Available tag groups:

Tag Covers
wcag2a WCAG 2.0 Level A
wcag2aa WCAG 2.0 Level AA
wcag21a WCAG 2.1 Level A additions
wcag21aa WCAG 2.1 Level AA additions
best-practice Deque best practices (not required by WCAG)
section508 US Section 508 requirements

Handling Known Violations

In real projects, you'll encounter violations you can't fix immediately — a third-party component, a design constraint, or a backlog item. Don't skip the test entirely. Instead, track known violations explicitly so new violations are still caught:

test('dashboard page accessibility (with known violations)', async ({ page }) => {
  await page.goto('/dashboard');

  const results = await new AxeBuilder({ page }).analyze();

  // Filter out known, tracked violations
  const knownViolationIds = [
    'color-contrast', // Tracked in issue #247 — design system update in Q2
  ];

  const unexpectedViolations = results.violations.filter(
    (violation) => !knownViolationIds.includes(violation.id)
  );

  expect(unexpectedViolations).toEqual([]);
});

A cleaner approach is a shared helper:

// tests/helpers/accessibility.ts
export function filterKnownViolations(
  violations: axe.Result[],
  knownViolations: { id: string; reason: string; issue?: string }[]
) {
  const knownIds = knownViolations.map((v) => v.id);
  return violations.filter((v) => !knownIds.includes(v.id));
}
import { filterKnownViolations } from './helpers/accessibility';

test('dashboard should have no unexpected accessibility violations', async ({ page }) => {
  await page.goto('/dashboard');

  const results = await new AxeBuilder({ page }).analyze();

  const unexpected = filterKnownViolations(results.violations, [
    { id: 'color-contrast', reason: 'Design system update in Q2', issue: '#247' },
  ]);

  expect(unexpected).toEqual([]);
});

This approach keeps the test strict about new violations while documenting the existing debt.

Interpreting Violations

When a test fails, the output isn't always obvious. Write a custom reporter or use Playwright's built-in verbose output. Here's a helper that formats violations readably:

function formatViolations(violations: axe.Result[]): string {
  return violations
    .map((violation) => {
      const nodes = violation.nodes
        .map((node) => `  - ${node.target.join(', ')}\n    ${node.failureSummary}`)
        .join('\n');
      return `[${violation.impact?.toUpperCase()}] ${violation.id}: ${violation.description}\nAffected elements:\n${nodes}`;
    })
    .join('\n\n');
}

test('homepage accessibility', async ({ page }) => {
  await page.goto('/');

  const results = await new AxeBuilder({ page }).analyze();

  if (results.violations.length > 0) {
    console.log('Accessibility violations found:\n' + formatViolations(results.violations));
  }

  expect(results.violations).toEqual([]);
});

Common WCAG Issues axe-core Finds

1. Missing form labels (WCAG 1.3.1)

<!-- Violation -->
<input type="email" placeholder="Email address" />

<!-- Fix -->
<label for="email">Email address</label>
<input type="email" id="email" placeholder="email@example.com" />

<!-- Or with aria-label -->
<input type="email" aria-label="Email address" placeholder="email@example.com" />

2. Insufficient color contrast (WCAG 1.4.3)

WCAG AA requires 4.5:1 ratio for normal text, 3:1 for large text. axe-core checks this automatically.

/* Violation: gray text on white (#767676 = 4.48:1 — barely fails) */
color: #767676;

/* Fix: darker gray (#595959 = 7.0:1) */
color: #595959;

3. Images without alt text (WCAG 1.1.1)

<!-- Violation -->
<img src="chart.png" />

<!-- Fix: descriptive alt for meaningful images -->
<img src="chart.png" alt="Bar chart showing 40% increase in monthly active users" />

<!-- Fix: empty alt for decorative images -->
<img src="decorative-divider.png" alt="" />

4. Missing skip link (WCAG 2.4.1)

<!-- Add at the top of body -->
<a href="#main-content" class="skip-link">Skip to main content</a>

<!-- CSS to show on focus -->
<style>
.skip-link {
  position: absolute;
  left: -9999px;
}
.skip-link:focus {
  left: 0;
  top: 0;
  z-index: 9999;
}
</style>

<main id="main-content">
  <!-- page content -->
</main>

5. Incorrect heading hierarchy (WCAG 1.3.1)

<!-- Violation: skipping from h1 to h3 -->
<h1>Page Title</h1>
<h3>Section</h3>

<!-- Fix: sequential heading levels -->
<h1>Page Title</h1>
<h2>Section</h2>
<h3>Subsection</h3>

Integrating into CI

GitHub Actions

# .github/workflows/accessibility.yml
name: Accessibility Tests

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

jobs:
  accessibility:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: Install Playwright browsers
        run: npx playwright install --with-deps chromium

      - name: Start application
        run: npm run start &
        env:
          NODE_ENV: test

      - name: Wait for application
        run: npx wait-on http://localhost:3000 --timeout 60000

      - name: Run accessibility tests
        run: npx playwright test --grep @accessibility

      - name: Upload test results
        uses: actions/upload-artifact@v4
        if: failure()
        with:
          name: accessibility-report
          path: playwright-report/

Tag your accessibility tests with @accessibility in the test name so you can run them in isolation:

test('@accessibility homepage should have no violations', async ({ page }) => {
  // ...
});

Playwright Configuration for Accessibility Tests

// playwright.config.ts
import { defineConfig } from '@playwright/test';

export default defineConfig({
  testDir: './tests',
  use: {
    baseURL: process.env.BASE_URL || 'http://localhost:3000',
    // Run in chromium for axe-core (most consistent results)
    // axe-core behavior can vary slightly across engines
  },
  projects: [
    {
      name: 'accessibility',
      testMatch: /.*\.a11y\.spec\.ts/,
      use: {
        browserName: 'chromium',
      },
    },
  ],
});

Snapshot Testing for Violations

Instead of asserting zero violations, you can snapshot the current violation list and detect regressions:

test('violation count should not increase', async ({ page }) => {
  await page.goto('/');

  const results = await new AxeBuilder({ page }).analyze();

  // This will fail if new violations are introduced
  expect(results.violations.map((v) => v.id).sort()).toMatchSnapshot();
});

Be careful with this approach — it allows the violation list to grow if you update the snapshot without reviewing. It's better for tracking known violations than as a gate.

Generating HTML Reports

For stakeholder reporting, generate an HTML summary of accessibility violations:

import { createHtmlReport } from 'axe-html-reporter';

test.afterEach(async ({}, testInfo) => {
  if (testInfo.status === 'failed') {
    // Violations are attached via test metadata
  }
});

// Or generate from violations directly
const htmlReport = createHtmlReport({
  results: accessibilityScanResults,
  options: {
    projectKey: 'MyApp',
    outputDir: 'reports/accessibility',
    reportFileName: 'accessibility-report.html',
  },
});

Install the reporter: npm install --save-dev axe-html-reporter

What axe-core Cannot Catch

Automated tools have limits. axe-core won't catch:

  • Cognitive load issues — Is the form too complex? Does the error message make sense?
  • Focus order problems — Automated tools can check focus exists but not whether the order makes logical sense.
  • Screen reader announcements — axe-core checks ARIA markup but not how screen readers actually announce content.
  • Motion and animationprefers-reduced-motion support requires manual testing.
  • Touch target sizes — WCAG 2.5.5 requires 44x44px targets; axe-core doesn't reliably catch this.

Pair axe-core automation with periodic manual testing using keyboard navigation and a screen reader (NVDA on Windows, VoiceOver on macOS) to cover what automated tools miss.

Putting It All Together

A mature accessibility test suite in Playwright looks like this:

  1. Full-page scans on critical user journeys (homepage, checkout, login, signup)
  2. Component scans on shared components (navigation, modals, forms, data tables)
  3. Interactive state scans on dynamic UI (open menus, error states, loading states)
  4. Known violation tracking with documented reasons and issue links
  5. CI integration that blocks merges on new violations

Start with the first two, add the rest as your test suite matures. The goal isn't 100% automated coverage — it's catching regressions automatically so your manual testing effort can focus on the harder problems.

Accessibility testing with axe-core and Playwright is one of the highest-ROI investments in a modern test suite. The setup is minimal, the feedback is immediate, and the violations it catches are real problems for real users.

Read more

Start now free