Integrating @axe-core/playwright Into an Existing Project

You already have a Playwright suite that exercises your real user flows. Adding accessibility coverage should not mean a parallel test harness — it should reuse the pages and navigation you already drive. This guide is part of Playwright Accessibility Plugin Integration, and it walks from npm install through a reusable fixture, per-page scanning, scoping and excluding noisy regions, and a first CI run that gates on serious and critical violations.

Key implementation targets:

  • A single fixture injects axe-core and the shared tags into every test that needs it.
  • Scans run against the same pages your functional tests already visit.
  • Known-noisy third-party widgets are excluded by selector, not by disabling rules globally.
Playwright test to axe scan to impact filter to CI gate A test navigates to a page, the fixture injects axe-core and runs analyze, results are filtered by impact, and the gate fails on serious or critical violations. Playwright test page.goto(route) makeAxe fixture inject + analyze filter impact serious+critical CI fails exit 1 CI passes exit 0
The fixture injects axe-core, the test filters by impact, and only serious or critical violations gate CI.

Root Cause / Context

Teams often stall on accessibility automation because they treat it as a separate tool with its own runner and its own page setup. In reality, @axe-core/playwright is a thin builder that runs inside a Playwright page, so it should ride on the navigation and authentication your functional tests already perform. The friction is usually two things: where to put the shared scan logic so it is not copy-pasted, and how to keep third-party widgets (chat bubbles, ad iframes) from generating violations you cannot fix.

A Playwright fixture solves the first problem. By extending the base test, every spec gets a ready-to-use makeAxe helper without importing the builder and re-declaring tags each time. Scoping with include/exclude solves the second: instead of globally disabling a rule (which hides real failures elsewhere), you exclude only the DOM subtree you do not control.

Configuration

Install the plugin alongside your existing Playwright install. The plugin depends on axe-core, so no separate axe install is needed:

npm install --save-dev @axe-core/playwright   # adds the AxeBuilder for Playwright

Create a fixture that exposes a configured scanner. Putting the tags and impact filter here means every test shares one rule set:

// tests/fixtures/axe.ts
import { test as base, expect } from '@playwright/test';
import { AxeBuilder } from '@axe-core/playwright';
import type { Result } from 'axe-core';

const GATING = new Set(['serious', 'critical']); // impacts that fail CI

type AxeFixture = {
  // Scan the current page; optionally scope or exclude subtrees.
  scan: (opts?: { include?: string; exclude?: string }) => Promise<Result[]>;
};

export const test = base.extend<AxeFixture>({
  scan: async ({ page }, use) => {
    await use(async (opts = {}) => {
      let builder = new AxeBuilder({ page }).withTags([
        'wcag2a', 'wcag2aa', 'wcag21aa', 'wcag22aa', // WCAG 2.2 AA scope
      ]);
      if (opts.include) builder = builder.include(opts.include);
      if (opts.exclude) builder = builder.exclude(opts.exclude);
      const results = await builder.analyze();
      return results.violations.filter((v) => GATING.has(v.impact ?? ''));
    });
  },
});

export { expect };

Now write a per-page scan. Import test from the fixture instead of @playwright/test, and reuse the routes your functional suite already covers:

// tests/a11y.spec.ts
import { test, expect } from './fixtures/axe';

const routes = ['/', '/search', '/account']; // pages you already test

for (const route of routes) {
  test(`accessibility: ${route}`, async ({ page, scan }) => {
    await page.goto(route);
    await page.getByRole('main').waitFor(); // ensure the page rendered
    const violations = await scan({
      exclude: '#intercom-frame', // third-party widget we cannot fix
    });
    expect(violations, JSON.stringify(violations, null, 2)).toEqual([]);
  });
}

To scan only a single component during development — useful when you are remediating one area — pass include with a selector so axe ignores the rest of the page:

test('accessibility: checkout form only', async ({ page, scan }) => {
  await page.goto('/checkout');
  const violations = await scan({ include: 'form#checkout' }); // scope to the form
  expect(violations).toEqual([]);
});

For the first CI run, add a job that installs browsers and runs the suite. Playwright’s non-zero exit on any failed assertion is the gate:

# .github/workflows/a11y.yml
name: accessibility
on: [pull_request]
jobs:
  axe:
    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  # browser + OS deps
      - run: npx playwright test tests/a11y.spec.ts        # exits 1 on violations
      - uses: actions/upload-artifact@v4
        if: always()                                       # keep the report even on failure
        with:
          name: a11y-report
          path: playwright-report/

Validation

Confirm the scanner actually reports failures before you trust a green run. Add a deliberately broken page or temporarily lower the gate to minor and watch a violation surface:

npx playwright test tests/a11y.spec.ts --reporter=list
# A failing assertion prints the violation id, impact, and the failing node, e.g.:
#   1) accessibility: /search
#      Error: expect(received).toEqual(expected)
#      [{ "id": "image-alt", "impact": "critical",
#         "nodes": [{ "target": ["img.hero"] }] }]

A passing run prints 0 violations per route and exits 0. Because the assertion serializes the violation array, the failure message itself names the rule and the CSS target, so you can fix it without opening a separate report.

Edge Cases & Conditional Guards

  • Authenticated pages: reuse Playwright’s storageState so the scan runs as a logged-in user; an unauthenticated redirect scans the login page instead of the target.
  • Animated or lazy content: wait on a stable role or test id before scanning. Scanning during a skeleton-loading state produces transient color-contrast noise.
  • Iframes you do not own: exclude them by selector rather than disabling the rule, so the same rule still guards your own DOM.

Pipeline Impact

The gate is binary and lives in the Playwright exit code: any serious or critical violation fails the job, which blocks the pull request when the check is marked required. The uploaded playwright-report/ artifact (kept even on failure via if: always()) gives reviewers the exact rule and node. Because the scan piggybacks on existing navigation, the added CI time is roughly one extra analyze() per route — typically a few hundred milliseconds — so the check is cheap enough to require on every PR.

Common Pitfalls

  • Importing test from @playwright/test in the spec instead of from the fixture, so the scan helper is undefined.
  • Globally disabling a rule to silence one third-party widget, which also hides real violations of that rule in your own code.
  • Scanning before the page settles, producing flaky color-contrast or aria-required-children failures.
  • Gating on all impacts including minor on day one, which floods the first PR and erodes trust in the check.
  • Forgetting --with-deps on the browser install, so CI fails on missing shared libraries rather than on real violations.

FAQ

Can I keep my accessibility tests in the same files as functional tests? Yes. Because the fixture extends the base test, you can call scan at the end of an existing flow test right after you have driven the UI into the state you care about. Many teams scan inside their critical-path E2E tests so the accessibility check covers real, interacted-with states rather than just initial loads.

Why filter by impact instead of using axe’s own severity? @axe-core/playwright returns every violation regardless of impact. Filtering to serious and critical is a deliberate gating policy: you still see lower-severity issues in the report, but only the ones most likely to block users fail the build. Adjust the set as your backlog shrinks.

Do I need a separate Playwright config for accessibility? Not initially. You can run the a11y spec under your existing config. A separate config becomes useful only when you want different reporters or a dedicated webServer, as when the suite grows.