This is the third article in a series on accessibility testing for QA engineers. The first one covered Cypress with axe-core and went into why accessibility testing matters, what the 2025 WebAIM Million report found, and the legal landscape around ADA and the European Accessibility Act. The second explored wick-a11y as an alternative Cypress plugin with built-in HTML reporting.
This article adapts every example to Playwright using @axe-core/playwright, Deque's official integration. The accessibility concepts are identical. The code is different.
Introducing the tools
Playwright is a cross-browser testing framework from Microsoft. It runs tests against Chromium, Firefox, and WebKit, handles auto-waiting natively, and has had keyboard methods (page.keyboard.press()) since its first release. No plugin needed for that. Playwright added locator.ariaSnapshot() in v1.49, which captures the accessibility tree as structured YAML. Version 1.59 promoted it to a page-level method (page.ariaSnapshot()) and added depth and mode options for controlling how much of the tree you get back. I'll show how that pairs with axe scans later in this article.
Axe-core (currently at v4.11.1) is an accessibility testing engine built by Deque Systems. It evaluates DOM elements against WCAG rules and returns a structured list of violations with severity levels and references to specific guidelines. A recent release corrected the luminance threshold used in color contrast checks, so if you upgrade from an older version, expect some borderline contrast results to shift.
The @axe-core/playwright package (also v4.11.1, versioned in lockstep with axe-core) connects the two. It provides an AxeBuilder class with a chainable API for scoping, filtering, and running scans. Unlike cypress-axe, there's no injectAxe() step. The package handles injection automatically.
Installation and setup
One package:
npm install --save-dev @axe-core/playwright
No support file to edit, no import to register globally. You import AxeBuilder in each test file where you need it:
import AxeBuilder from '@axe-core/playwright';
Your first test:
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
test('home page has no detectable violations', async ({ page }) => {
await page.goto('/');
const results = await new AxeBuilder({ page }).analyze();
expect(results.violations).toEqual([]);
});
The terminal is filled with a structured violations array. Playwright's default reporter made it easier to read.
Basic usage patterns
Before jumping into full examples, here are a few patterns that have held up over time.
Scope your checks
You don't need to scan the entire page after every interaction. The include and exclude methods scope the check to specific elements:
const results = await new AxeBuilder({ page })
.include('[role="dialog"]')
.analyze();
const results = await new AxeBuilder({ page })
.include('form')
.exclude('.third-party-widget')
.analyze();
Filter by WCAG tags
If you need to check against a specific standard, use withTags:
const results = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa'])
.analyze();
Filter by severity after analysis
@axe-core/playwright doesn't have a built-in severity filter like cypress-axe's includedImpacts. You filter the results array instead:
const results = await new AxeBuilder({ page }).analyze();
const critical = results.violations.filter(
(v) => v.impact === 'critical'
);
expect(critical).toEqual([]);
This approach is actually more flexible. You can fail the test on critical violations while logging serious ones to the console in the same run:
const results = await new AxeBuilder({ page }).analyze();
const critical = results.violations.filter(
(v) => v.impact === 'critical'
);
const serious = results.violations.filter(
(v) => v.impact === 'serious'
);
if (serious.length > 0) {
console.log('Serious violations (not failing):', serious);
}
expect(critical).toEqual([]);
Wait for single-page apps to finish rendering
await page.waitForSelector('.loading-spinner', { state: 'hidden' });
await page.waitForSelector('[data-loaded="true"]');
const results = await new AxeBuilder({ page }).analyze();
Readable violation output
Playwright's default test reporter already formats assertion failures better than most tools. But when you want a clean summary in CI logs, a small helper does the job:
import { AxeResults } from 'axe-core';
function logViolations(results: AxeResults) {
const violations = results.violations;
if (violations.length === 0) return;
console.log(
`${violations.length} accessibility violation${
violations.length === 1 ? '' : 's'
} detected`
);
console.table(
violations.map(({ id, impact, description, nodes }) => ({
id,
impact,
description,
nodes: nodes.length,
}))
);
}
Usage:
const results = await new AxeBuilder({ page }).analyze();
logViolations(results);
expect(results.violations).toEqual([]);
Example 1: E2E test with real API calls
This example uses a weather app that calls a live API. The test types a city name, waits for search results, selects a city, verifies the weather display updates, and then runs an accessibility check on the final state.
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
test.describe('E2E: city search + accessibility', () => {
test('checks accessibility after a search', async ({ page }) => {
await page.goto('/');
// set up the listener before the action that triggers the request
const searchResponsePromise = page.waitForResponse(
(resp) => resp.url().includes('/search?q=') && resp.status() === 200
);
await page.locator('#city-input').fill('Toronto');
await searchResponsePromise;
// same pattern: listener first, then the action
const weatherResponsePromise = page.waitForResponse(
(resp) => resp.url().includes('/weather?') && resp.status() === 200
);
await page.locator('#city-select').selectOption({ index: 1 });
const weatherResponse = await weatherResponsePromise;
const weatherData = await weatherResponse.json();
// verify weather display
await expect(
page.locator('[data-cy="weather-display"]')
).toContainText(weatherData.name);
// accessibility check after dynamic content loads
const results = await new AxeBuilder({ page }).analyze();
expect(results.violations).toEqual([]);
});
});
One thing I learned running this: Playwright's waitForResponse gives you precise control over when the page is ready for an accessibility scan. No guessing with arbitrary timeouts. The key detail is setting up the response promise before the action that triggers the request. If you await page.waitForResponse(...) after fill(), a fast API can respond before the listener is active, and the test fails.
Example 2: Scoped checks for modals and dynamic content
Modals and form error states are where I've seen the most accessibility regressions ship. The content appears dynamically, and ARIA attributes on dialogs are the kind of thing that works fine until someone changes the markup.
Scoping the check to the modal DOM avoids noise from the rest of the page:
test('checks modal accessibility', async ({ page }) => {
await page.goto('/');
await page.getByRole('button', { name: 'Open Modal' }).click();
await expect(page.locator('[data-cy="modal-overlay"]')).toBeVisible();
// scan only the modal
const results = await new AxeBuilder({ page })
.include('[data-cy="modal-overlay"]')
.analyze();
expect(results.violations).toEqual([]);
});
That .include() call tells axe to evaluate only the modal, not the entire page behind it.
For forms, the same idea applies: check accessibility both before and after triggering validation errors. Error messages need aria-live regions to be announced by screen readers, and invalid fields need aria-invalid plus aria-describedby linking them to the error text.
Example 3: Keyboard navigation
Playwright has had page.keyboard.press() since its first release. No plugin required. This is useful for testing focus management, which is one of the most common accessibility failures and one that axe-core can't catch on its own.
test('modal traps focus and closes on Escape', async ({ page }) => {
await page.goto('/');
await page.getByRole('button', { name: 'Open Modal' }).click();
await expect(page.locator('[data-cy="modal-overlay"]')).toBeVisible();
// focus should start on the first focusable element
await expect(page.locator('[data-cy="modal-close-btn"]')).toBeFocused();
// Tab should stay inside the modal
await page.keyboard.press('Tab');
await expect(
page.locator('[data-cy="modal-overlay"]').locator(':focus')
).toBeVisible();
// Escape should close the modal
await page.keyboard.press('Escape');
await expect(page.locator('[data-cy="modal-overlay"]')).toHaveCount(0);
// focus should return to the trigger button
await expect(
page.getByRole('button', { name: 'Open Modal' })
).toBeFocused();
// run axe after the modal closes
const results = await new AxeBuilder({ page }).analyze();
expect(results.violations).toEqual([]);
});
This test checks four things that axe-core can't: that focus moves into the modal on open, that Tab doesn't escape the modal boundary, that Escape closes the dialog, and that focus returns to the button that triggered it. Those four behaviors are all WCAG 2.4.3 (Focus Order) requirements.
Example 4: Storybook integration
If your team maintains a component library, Storybook is a natural place to catch accessibility issues before they spread into applications. The test visits the Storybook iframe URL for each component story:
test.describe('Storybook: CitySelector', () => {
test('has no accessibility violations', async ({ page }) => {
await page.goto(
'/iframe.html?id=components-cityselector--default'
);
// wait for the component to render
await page.locator('[data-cy="city-selector"]').waitFor();
const results = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa'])
.analyze();
expect(results.violations).toEqual([]);
});
});
Each story gets its own scan. Missing labels and incorrect ARIA roles show up at the component level, long before anyone integrates the component into a page.
Example 5: Component tests
Playwright has experimental component testing for React and Vue. It mounts the component directly in a real browser. The feedback loop is fast.
import { test, expect } from '@playwright/experimental-ct-react';
import AxeBuilder from '@axe-core/playwright';
import CitySelector from './CitySelector';
test('<CitySelector /> has no critical violations', async ({ mount, page }) => {
await mount(<CitySelector />);
const results = await new AxeBuilder({ page }).analyze();
const critical = results.violations.filter(
(v) => v.impact === 'critical' || v.impact === 'serious'
);
expect(critical).toEqual([]);
await expect(page.locator('[data-cy="city-selector"]')).toBeVisible();
});
This is the closest you can get to catching problems while you're still building the component. If the label is missing here, it will be missing everywhere it's used.
Note: Playwright 1.59 removed the @playwright/experimental-ct-svelte package. React and Vue component testing are still supported.
CI/CD integration
Playwright has a built-in GitHub Actions setup. Here's a minimal workflow:
name: Playwright Accessibility Tests
on:
pull_request:
branches: [develop, intg]
jobs:
playwright-run:
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
- run: npx playwright install --with-deps chromium
- run: npx playwright test --fail-on-flaky-tests
- uses: actions/upload-artifact@v4
if: failure()
with:
name: playwright-report
path: playwright-report/
If a run fails, the uploaded HTML report includes screenshots, traces, and the full list of violations. Playwright's built-in reporter is detailed enough.
The --fail-on-flaky-tests flag changes how retries are handled. By default, the test runner exits with code 0 when a failed test passes on retry. With this flag, it exits with code 1 instead. Accessibility tests that flake because a spinner wasn't gone when axe ran are exactly the kind of false pass you want to catch in CI.
The same release added a 'retain-on-failure-and-retries' trace mode. It records a trace for every attempt and keeps them all when a test fails.
Playwright 1.59 also added npx playwright trace, which lets you explore traces from the command line without opening the trace viewer. After a CI failure, you can run npx playwright trace actions --grep="expect" to jump straight to the failing assertion. I've found this faster than downloading the HTML report when I already know the test name and just need to see what axe returned.
Start with a narrow gate. Filter for critical violations only and widen it over time as the backlog shrinks.
Inspecting the accessibility tree
Axe-core tells you what's broken. It doesn't show you what the tree looks like. Playwright added locator.ariaSnapshot() in v1.49 for capturing the accessibility tree of a specific element. Version 1.59 added the page-level page.ariaSnapshot() shorthand (equivalent to page.locator('body').ariaSnapshot()) and new depth and mode options so you can control how deep the snapshot goes. The output is structured YAML:
const snapshot = await page.ariaSnapshot();
console.log(snapshot);
The output reads like what a screen reader sees: headings with levels, buttons with names, links with text, form fields with labels. If a button has no accessible name, it shows up as an empty string. If a heading hierarchy jumps from h2 to h5, you see it.
I started using this alongside axe scans. Axe catches the WCAG violations. The snapshot shows structural problems that axe doesn't flag: a reading order that makes no sense, or a landmark region with the wrong content. You can scope it to a specific element, too:
const modalSnapshot = await page.locator('[role="dialog"]').ariaSnapshot();
console.log(modalSnapshot);
For regression testing, toMatchAriaSnapshot() lets you lock down the tree structure:
await expect(page.locator('nav')).toMatchAriaSnapshot(`
- navigation:
- link "Home"
- link "Products"
- link "Contact"
`);
If someone removes a nav link or changes a heading level, the test fails with a diff.
Playwright MCP and the accessibility tree
Microsoft released @playwright/mcp in early 2025, and it has been getting updates with every Playwright release since. It's a Model Context Protocol server that gives AI agents browser control through structured accessibility snapshots. Instead of processing screenshots, the agent reads the same tree that screen readers use: element names, roles, and states.
The connection to this article is practical. If you're already running axe scans, you can point an AI agent at your app through this MCP server, and the agent navigates using the accessibility tree. If your app's tree is broken (missing names or incorrect roles), the AI agent struggles in the same way a screen reader user does. Accessibility testing and AI-driven testing share the same foundation: a well-structured accessibility tree.
Playwright 1.59 added browser.bind(), which lets you attach @playwright/mcp or playwright-cli to a browser your tests already started. You run your accessibility suite, and an AI agent can inspect the same session. Set PLAYWRIGHT_DASHBOARD=1 to open a live dashboard of all browser sessions.
npx @playwright/mcp@latest
npm install -g @playwright/cli@latest
These tools are separate from @axe-core/playwright and serve a different purpose. Axe finds WCAG violations. Playwright MCP and CLI let agents interact with your app through its accessibility tree. Together, they cover automated rule checking and structural navigation.
Recording accessibility walkthroughs
Playwright 1.59 also shipped a Screencast API (page.screencast) that records video with precise start/stop control. What makes it relevant here is the showActions() method: it annotates the recording with visual highlights on every element the test interacts with.
await page.screencast.start({ path: 'a11y-keyboard-walkthrough.webm' });
await page.screencast.showActions({ position: 'top-right' });
// ... run the keyboard navigation test ...
await page.screencast.stop();
You can also add chapter titles with showChapter() to break the video into labeled sections.
The part automation can't do
Automated scanners catch roughly 30 to 50 percent of accessibility issues. They find missing alt text and broken ARIA references. They can't tell you whether alt text is meaningful or whether a screen reader user would understand your page's reading order.
Dynamic content in single-page apps can also cause false negatives. If the page hasn't finished rendering when axe runs, it'll miss real problems or flag temporary states. Wait for your app's "ready" signal:
await page.waitForSelector('.loading-spinner', { state: 'hidden' });
await page.waitForSelector('[data-loaded="true"]');
const results = await new AxeBuilder({ page }).analyze();
Manual checks remain necessary. Tab through the page and run a screen reader (NVDA on Windows, VoiceOver on macOS) before each release. Ten minutes of that catches things no scanner will find.
Resources
- Axe Core documentation (Deque)
- @axe-core/playwright on npm
- Playwright accessibility testing guide
- Playwright aria snapshots guide
- Playwright MCP
- Playwright 1.59 release notes
- WCAG 2.1 quick reference (W3C)
- Companion article: Cypress + Axe Core
Find me on LinkedIn.
Top comments (0)