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.
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
storageStateso 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-contrastnoise. - 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
testfrom@playwright/testin the spec instead of from the fixture, so thescanhelper 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-contrastoraria-required-childrenfailures. - Gating on all impacts including
minoron day one, which floods the first PR and erodes trust in the check. - Forgetting
--with-depson 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.
Related
- Playwright Accessibility Plugin Integration — the parent section on wiring axe into Playwright.
- Comparing Playwright and Cypress for WCAG Compliance Testing — choose the runner before you build the fixture.
- axe-core Configuration & Setup — the rule tags and options the fixture imports.