testing and automating accessibility
End-to-End Accessibility Testing with Playwright
End-to-end accessibility testing exercises your application in a real browser, where layout, computed styles, focus order, and route transitions behave exactly as they do for users. This guide pairs Playwright with @axe-core/playwright to move beyond static markup assertions and validate the behavior that assistive technology depends on. It extends the broader strategy outlined in Testing and Automating Accessibility by covering the layer that unit-level tools structurally cannot reach: keyboard-driven flows, dynamic announcements, and focus restoration across navigation.
WCAG Success Criteria Addressed:
2.1.1 Keyboard2.4.3 Focus Order2.4.7 Focus Visible4.1.3 Status Messages1.4.3 Contrast (Minimum)
Core Testing Principles:
- Scan rendered pages with Axe to catch violations against the live accessibility tree.
- Drive interactions with the keyboard, never synthetic clicks, to prove keyboard operability.
- Assert focus state directly with
toBeFocused()rather than inferring it. - Read live-region text after an action to confirm announcements actually reach the DOM.
Why End-to-End Catches What jsdom and jest-axe Cannot
Unit-level accessibility tooling runs in jsdom, a simulated DOM with no rendering engine. That gap is not academic—it silently hides entire categories of violations that only surface in a real browser.
What jsdom structurally cannot evaluate:
- Color contrast. jsdom does not compute styles from a layout engine, so
1.4.3 Contrast (Minimum)checks are unreliable or skipped. Axe in a real browser reads the computed foreground and background colors. - Real layout and visibility. Elements hidden by
overflow, zero-size containers, off-screen positioning, or stacking context are only resolvable when the page is actually laid out. - Native focus behavior. jsdom approximates
document.activeElement, but tab order, scroll-into-view on focus, and:focus-visibleare browser concerns. - Route transitions. Single-page-app navigation, where the framework swaps the view and must move focus, plays out over real microtasks and animation frames that jsdom does not faithfully model.
This is complementary to, not a replacement for, component testing with jest-axe. Component tests give you fast, isolated feedback on a single unit. End-to-end tests verify the assembled application as a user experiences it. The diagram below shows the canonical shape of an end-to-end accessibility test: a single browser session that scans, then drives the keyboard, then asserts on the resulting focus and announcement state.
How to verify: Run the same suite once with the chromium project and confirm contrast violations appear that your jsdom-based suite never reported. Manually spot-check one flagged element in DevTools to confirm the computed contrast ratio matches Axe's report.
Scanning Pages with AxeBuilder and Asserting Zero Violations
The foundation of an end-to-end accessibility suite is a full-page scan against the live accessibility tree. @axe-core/playwright injects Axe into the page under test and returns structured results you can assert against.
Implementation Guidelines:
- Scan after the page has settled—wait for a known element or network idle so dynamic content is present.
- Tag scans to the standard you target (for example
wcag2a,wcag2aa) to keep results aligned with your compliance baseline. - Attach violation details to the test report so failures are actionable, not just red.
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
test('checkout page has no detectable WCAG A/AA violations', async ({ page }) => {
await page.goto('/checkout');
// Wait for the real, settled DOM so Axe scans rendered content, not a skeleton.
await page.getByRole('heading', { name: 'Checkout' }).waitFor();
const results = await new AxeBuilder({ page })
// Constrain the scan to the standards your compliance baseline targets.
.withTags(['wcag2a', 'wcag2aa'])
.analyze();
// A11y rationale: zero violations against the live accessibility tree,
// including contrast computed from real styles that jsdom cannot evaluate.
expect(results.violations).toEqual([]);
});
When a scan fails, surface the offending nodes rather than a bare count. This keeps the signal high and prevents engineers from re-running locally just to learn what broke.
test('product grid is accessible', async ({ page }, testInfo) => {
await page.goto('/products');
await page.getByRole('list', { name: 'Products' }).waitFor();
const results = await new AxeBuilder({ page }).analyze();
// Attach full violation data to the HTML report for triage.
await testInfo.attach('axe-results', {
body: JSON.stringify(results.violations, null, 2),
contentType: 'application/json',
});
expect(results.violations).toEqual([]);
});
For audits that prioritize remediation by severity or that combine performance and best-practice signals, pair these scans with accessibility audits with Lighthouse. Axe gives you deterministic, gateable rule checks; Lighthouse gives you a broader scored snapshot.
How to verify: Introduce a deliberate violation (remove a button's accessible name) and confirm the test fails with that rule ID in the attachment. Then revert and confirm a clean pass.
Driving Keyboard-Only Flows
Synthetic clicks (locator.click()) bypass the keyboard entirely, so a test built on them can pass while the UI is completely unusable without a mouse. To validate 2.1.1 Keyboard, you must move through the interface using only Tab, Enter, Space, and Escape.
Implementation Guidelines:
- Use
page.keyboard.press('Tab')to advance focus and assert on each landing element. - Activate controls with
EnterorSpace—the same keys a real keyboard user presses. - Treat any control you cannot reach or activate from the keyboard as a hard failure.
test('primary nav is fully keyboard operable', async ({ page }) => {
await page.goto('/');
// Start from a known anchor so the tab sequence is deterministic.
await page.getByRole('link', { name: 'Skip to content' }).focus();
await page.keyboard.press('Tab');
// A11y rationale: the first interactive control must be reachable by keyboard.
await expect(page.getByRole('link', { name: 'Home' })).toBeFocused();
await page.keyboard.press('Tab');
await expect(page.getByRole('link', { name: 'Pricing' })).toBeFocused();
// Activate with Enter, exactly as a keyboard user would.
await page.keyboard.press('Enter');
await expect(page).toHaveURL(/\/pricing/);
});
This pattern scales into complete dropdown and menu interactions. For the full sequence of opening a menu, arrowing through items, and closing it, see the dedicated guide on keyboard navigation tests in Playwright.
How to verify: Unplug or ignore the mouse and reproduce the test path by hand. If you reach the same controls in the same order, the test mirrors reality. Confirm 2.4.7 Focus Visible by watching for a visible focus ring at each step.
Asserting Focus with toBeFocused()
Focus is the single most important runtime state for keyboard and screen-reader users, and it is also the easiest to assert directly. Playwright's toBeFocused() matcher checks the live document.activeElement against a locator, with auto-waiting built in.
Implementation Guidelines:
- Assert focus immediately after the action that should move it, never several steps later.
- Prefer role-based locators so the assertion describes intent, not DOM structure.
- When focus should not move, assert that the previously focused element stays focused.
test('opening the dialog moves focus into it', async ({ page }) => {
await page.goto('/account');
const trigger = page.getByRole('button', { name: 'Edit profile' });
await trigger.focus();
await page.keyboard.press('Enter');
const dialog = page.getByRole('dialog', { name: 'Edit profile' });
await expect(dialog).toBeVisible();
// A11y rationale: 2.4.3 Focus Order — focus must enter the dialog on open
// so keyboard and screen-reader users are not stranded behind it.
await expect(dialog.getByRole('textbox', { name: 'Display name' })).toBeFocused();
});
Asserting focus order across a sequence, and verifying that focus never silently drops to <body>, is a deep enough topic to warrant its own treatment in asserting focus order in Playwright.
How to verify: Temporarily break the focus call in the component (comment out the .focus()), and confirm the assertion fails. With a real screen reader open, confirm the dialog's name and the first field are announced on open.
Verifying ARIA Live Region Announcements
A live region only helps users if its text content actually changes in the DOM after an action—screen readers announce the mutation, not your intent. End-to-end tests verify this by reading the region's text after triggering the event that should update it.
Implementation Guidelines:
- Locate the live region by role (
statusfor polite,alertfor assertive) or byaria-live. - Trigger the action, then assert the region's text with auto-waiting so async updates are captured.
- Confirm the politeness level matches the urgency of the message.
test('adding to cart announces a polite status update', async ({ page }) => {
await page.goto('/products/widget');
// The live region is mounted up front so screen readers observe its mutations.
const status = page.getByRole('status');
await page.getByRole('button', { name: 'Add to cart' }).click();
// A11y rationale: 4.1.3 Status Messages — the announcement must reach the
// DOM. toHaveText auto-waits for the async mutation to land.
await expect(status).toHaveText('Widget added to cart');
});
test('form error is announced assertively', async ({ page }) => {
await page.goto('/signup');
await page.getByRole('button', { name: 'Create account' }).click();
const alert = page.getByRole('alert');
await expect(alert).toBeVisible();
await expect(alert).toContainText('Email is required');
});
Note that Playwright verifies the DOM mutation, not synthesized speech. Pair these assertions with periodic manual NVDA or VoiceOver checks to confirm the announcement is actually spoken and not swallowed by an over-eager aria-atomic configuration.
How to verify: Run the test, then repeat the action manually with a screen reader running and confirm you hear the same text. Flip a region from polite to assertive and confirm the urgency change is justified by the message.
Testing Focus Restoration Across Route Changes
In a single-page app, the framework swaps the view without a full page load, which means the browser does not reset focus. Without explicit handling, focus is left on a control that no longer exists or drops to <body>, stranding keyboard and screen-reader users. End-to-end tests are the only place this behavior can be reliably proven.
Implementation Guidelines:
- After client-side navigation, assert that focus lands on a meaningful target (the new page heading or main landmark).
- After closing an overlay, assert focus returns to the element that opened it.
- Explicitly guard against focus falling to
document.body.
test('client-side navigation moves focus to the new page heading', async ({ page }) => {
await page.goto('/');
await page.getByRole('link', { name: 'Pricing' }).focus();
await page.keyboard.press('Enter');
// A11y rationale: 2.4.3 Focus Order — after an SPA route change, focus must
// move to a logical target so the next Tab continues from the right place.
const heading = page.getByRole('heading', { level: 1, name: 'Pricing' });
await expect(heading).toBeFocused();
// Guard: focus must never silently fall back to the document body.
const onBody = await page.evaluate(() => document.activeElement === document.body);
expect(onBody).toBe(false);
});
The framework-side patterns this test validates are covered in Focus Management Strategies for SPAs. Once these tests are green locally, wire them into your delivery pipeline so regressions are blocked before merge—see gating accessibility in CI/CD pipelines.
How to verify: Navigate the route by keyboard with a screen reader running and confirm the new page title is announced. Comment out the framework's focus-on-navigation logic and confirm the test fails by detecting focus on <body>.
Common a11y Mistakes
- Driving flows with
locator.click()instead of the keyboard. Clicks prove pointer operability only; they let keyboard-inaccessible UI pass. Usepage.keyboard.press()for any flow that claims keyboard support. - Scanning before the page settles. Running
AxeBuilder().analyze()against a loading skeleton yields false passes. Wait for a stable, known element first. - Inferring focus from side effects. Asserting that a URL changed does not prove focus moved. Assert focus directly with
toBeFocused(). - Asserting live-region text synchronously. Announcements often land a tick after the action. Use auto-waiting matchers like
toHaveTextrather than readingtextContentonce. - Ignoring focus restoration after route changes. SPA navigation does not reset focus; without an explicit assertion, focus loss to
<body>ships unnoticed. - Treating Axe as complete coverage. Automated rules catch roughly a third of issues. Keyboard, focus, and announcement assertions cover behavior Axe cannot see.
Frequently Asked Questions
Does @axe-core/playwright replace my jest-axe component tests?
No. They operate at different layers and catch different bugs. jest-axe gives fast, isolated feedback on a single component in jsdom; @axe-core/playwright scans the fully rendered application in a real browser, where contrast, layout, and focus actually resolve. Run both, and reserve end-to-end scans for assembled flows.
Why not just use locator.click() to drive my flows?
Because a click is a pointer event and proves nothing about keyboard operability. A control bound only to mouse events will pass a click-driven test while failing 2.1.1 Keyboard completely. Use page.keyboard.press() so your tests fail when a control cannot be reached or activated without a mouse.
How do I verify a screen reader actually announces something?
End-to-end tests verify the DOM mutation that drives the announcement—for example, that a role="status" region's text changes after an action. They do not capture synthesized speech. Pair the automated assertion with periodic manual NVDA or VoiceOver checks to confirm the text is spoken and not suppressed by aria-atomic or visibility issues.
Can Playwright detect color contrast problems?
Yes, when you scan with @axe-core/playwright in a real browser. Axe reads computed foreground and background colors from the layout engine, which jsdom cannot provide. This is a primary reason to run contrast-sensitive checks end-to-end rather than at the unit level.
Should accessibility tests run on every browser project? Run Axe scans and core keyboard flows on at least Chromium for speed, and extend critical paths to WebKit and Firefox. Focus and live-region behavior can differ subtly across engines, so cross-browser coverage on your highest-value flows is worth the runtime.
Related guides
- Testing and Automating Accessibility — the guide for your automated accessibility strategy.
- Component Testing with jest-axe — fast, isolated unit-level scans.
- Accessibility Audits with Lighthouse — broad scored snapshots for prioritization.
- Gating Accessibility in CI/CD Pipelines — block regressions before merge.
- Keyboard Navigation Tests in Playwright — tab, activate, and Escape flows in depth.
- Asserting Focus Order in Playwright — capture and lock down the focus sequence.