// guide
Accessibility Testing with Playwright and axe-core
Playwright drives a real browser through real user flows — which makes it the ideal place to catch accessibility violations on interactive states a page crawler never reaches. This guide sets up @axe-core/playwright, writes your first scan, scopes it with tags and selectors, tests open modals and mid-flow forms, and wraps it all in a reusable fixture you can drop into any test.
// 01 · why test accessibility in playwright
Why Test Accessibility in Playwright
Most automated accessibility testing scans pages in their initial, static state — a crawler loads a URL and checks what renders. That misses an entire category of problems, because a huge share of accessibility bugs live in the states a page reaches after the user interacts: an open dialog that does not trap focus, a menu that never announces itself, a form that shows an error with no programmatic association.
Playwright already drives those interactions in your end-to-end tests. Adding the official axe-core Playwright integration (@axe-core/playwright) lets you run the axe-core engine at any point in a test — after a click, after a route change, after validation fires — so you assert accessibility on the real, interactive page. It is the depth layer that complements a crawler's breadth.
// 02 · setup: installing @axe-core/playwright
Setup: Installing @axe-core/playwright
Install the official Deque integration alongside your existing Playwright setup. It brings its own copy of axe-core, so there is nothing else to configure.
# Install as a dev dependency
npm install --save-dev @axe-core/playwright
@axe-core/playwright (maintained by Deque, recommended by the Playwright docs) — not the older third-party axe-playwright, which has a different API. This guide uses the AxeBuilder class from the official package throughout.
// 03 · your first accessibility test
Your First Accessibility Test
The pattern is: navigate, construct an AxeBuilder around the page, call .analyze(), and assert that the violations array is empty. An empty array means axe found no automatically detectable issues.
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
test('home page has no automatically detectable a11y violations', async ({ page }) => {
await page.goto('/');
const results = await new AxeBuilder({ page }).analyze();
expect(results.violations).toEqual([]);
});
When this fails, Playwright prints the diff of the violations array — but the raw output is dense. Two things make failures readable: scoping the scan (next section) and attaching the full results as a test artifact (later section). For now, know that each violation object carries the axe rule id, an impact level, a help URL, and the exact nodes (with CSS selectors) that failed.
// 04 · scoping scans with tags and selectors
Scoping Scans with Tags and Selectors
A raw .analyze() runs every axe rule against the whole page. In real projects you usually want to constrain it — to a specific WCAG conformance level, to one region of the page, or away from a third-party widget you cannot fix. AxeBuilder is chainable, so these compose.
Constrain to a WCAG level with .withTags()
Pin the scan to the success criteria you are targeting so results stay stable across axe-core upgrades and match the standard you are conforming to:
const results = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22aa'])
.analyze();
Focus or exclude regions with .include() / .exclude()
.include() restricts the scan to a selector and its descendants; .exclude() removes a region from an otherwise full-page scan — the standard escape hatch for a third-party embed:
const results = await new AxeBuilder({ page })
.include('#main-content')
.exclude('#third-party-chat-widget')
.analyze();
.withTags() set that your Pa11y CI config and axe DevTools extension target. When every layer runs the same rules at the same conformance level, a violation in a Playwright test reproduces one-to-one in the browser extension — no "passes here, fails there" confusion.
// 05 · testing interactive states
Testing Interactive States
This is the reason to test accessibility in Playwright rather than a crawler. Because axe runs against the live DOM, you drive the component into the state you care about — then scan. A dialog is the classic example: closed, it is invisible to a crawler; open, it is where focus traps, labelling, and escape handling actually matter.
test('the edit-address dialog is accessible when open', async ({ page }) => {
await page.goto('/checkout');
// Drive the UI into the state we want to test
await page.getByRole('button', { name: 'Edit address' }).click();
await expect(page.getByRole('dialog')).toBeVisible();
// Scan just the open dialog
const results = await new AxeBuilder({ page })
.include('[role="dialog"]')
.analyze();
expect(results.violations).toEqual([]);
});
The same approach covers every post-interaction state: expand a menu then scan it, submit a form with bad input then scan the error summary, load a data table then open its filter panel. Each is a state a sitemap crawler would score as "clean" simply because it never got there. For the accessibility requirements these components must meet in the first place, cross-reference the modal / dialog pattern and the rest of the pattern library.
page.keyboard.press('Tab'), expect(locator).toBeFocused()) alongside the axe scan. See the keyboard testing guide for what to check.
// 06 · a reusable accessibility fixture
A Reusable Accessibility Fixture
Repeating the same .withTags([...]) chain in every test invites drift — one test scans against WCAG 2.2, another forgets and scans everything. Playwright fixtures solve this: define the configured builder once and inject it everywhere, so every test starts from the same baseline.
import { test as base } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
type AxeFixture = {
makeAxeBuilder: () => AxeBuilder;
};
// Extend the base test with a pre-configured AxeBuilder factory
export const test = base.extend<AxeFixture>({
makeAxeBuilder: async ({ page }, use) => {
const makeAxeBuilder = () =>
new AxeBuilder({ page }).withTags([
'wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22aa',
]);
await use(makeAxeBuilder);
},
});
export { expect } from '@playwright/test';
Import test and expect from the fixture instead of from Playwright directly, and every test gets the same WCAG-scoped scanner for free:
import { test, expect } from './axe-fixture';
test('pricing page is accessible', async ({ page, makeAxeBuilder }) => {
await page.goto('/pricing');
const results = await makeAxeBuilder().analyze();
expect(results.violations).toEqual([]);
});
// 07 · attaching results and debugging failures
Attaching Results and Debugging Failures
When a scan fails in CI, you want the full violation report in the test artifacts — not just the truncated console diff. Playwright's testInfo.attach() writes the complete axe results into the HTML report, where you can read every rule, impact, and failing selector:
test('checkout is accessible', async ({ page, makeAxeBuilder }, testInfo) => {
await page.goto('/checkout');
const results = await makeAxeBuilder().analyze();
// Attach the full JSON to the Playwright report for debugging
await testInfo.attach('accessibility-scan-results', {
body: JSON.stringify(results, null, 2),
contentType: 'application/json',
});
expect(results.violations).toEqual([]);
});
When you hit a known violation you cannot fix immediately — usually a third-party widget — disable the specific rule narrowly and leave a tracking comment. Never blanket-ignore:
// TODO(A11Y-142): vendor widget fails color-contrast; remove when fixed upstream
const results = await new AxeBuilder({ page })
.disableRules(['color-contrast'])
.analyze();
.disableRules() or .exclude() is a violation you have chosen to stop reporting. Track each one like a bug, keep it scoped to the offending rule or selector, and delete it the moment the underlying issue is fixed — otherwise your green build is quietly hiding regressions.
// 08 · running in ci
Running in CI
The best part of testing accessibility this way is that it needs no separate pipeline. These are ordinary Playwright tests, so they run in your existing Playwright CI job — the accessibility assertions fail the same build as a broken checkout flow, and the HTML report already carries the attached results.
If you keep accessibility specs in their own folder, you can run just them, or run them as part of the full suite:
# Run the whole Playwright suite (a11y specs included)
npx playwright test
# Or just the accessibility specs
npx playwright test tests/a11y
Frequently asked questions
Does Playwright test accessibility out of the box?
Not on its own. Playwright drives the browser and has accessibility-aware locators like getByRole, but it does not evaluate a page against WCAG. For that you add @axe-core/playwright, Deque's official integration, which runs the axe-core engine inside the page Playwright already has open. It is a one-line install and a two-line assertion — no separate test runner.
What is the difference between @axe-core/playwright and axe-playwright?
@axe-core/playwright is the official package maintained by Deque (the makers of axe-core), and it is what Playwright's own documentation recommends. axe-playwright is an older third-party wrapper with a different API. Use @axe-core/playwright and its AxeBuilder class — it stays current with axe-core releases and matches the examples in the Playwright docs.
How do I test a modal or dropdown for accessibility in Playwright?
Interact first, then scan. Because @axe-core/playwright evaluates the live DOM, you open the component in the test — click the trigger, wait for the dialog to be visible — and only then call .analyze(). Scope the scan to the open component with .include('[role="dialog"]') so the report is focused. This is the single biggest reason to test accessibility in Playwright rather than a crawler: a crawler only ever sees the closed, initial state.
How do I ignore a known accessibility violation in a Playwright test?
Use .disableRules(['rule-id']) to skip a specific axe rule, or .exclude('selector') to omit a region you cannot fix yet (typically a third-party widget). Do this narrowly and leave a comment linking to the tracking issue — a broad ignore silently hides real regressions. Prefer fixing over excluding; an exclusion should be a temporary, documented exception, not the default.
Should I use Playwright or Pa11y for accessibility testing?
Both, for different jobs. Pa11y CI crawls your sitemap and gives you breadth — every published URL scanned on every build in its default state. Playwright with axe gives you depth — accessibility assertions on authenticated pages and interactive states (open dialogs, expanded menus, forms mid-validation) that a crawler cannot reach. A common setup runs Pa11y CI for site-wide coverage and Playwright axe checks on the handful of critical flows.
Can Playwright accessibility tests replace manual testing?
No. Like every axe-based tool, Playwright accessibility tests catch only the deterministic subset of WCAG — realistically between a third and a half. They cannot judge whether focus order is logical, whether a screen reader announces a widget correctly, or whether alt text is meaningful. Playwright is excellent for locking in the automated checks and catching regressions in interactive states, but keyboard and screen reader testing by a person are still required.