View all services
Talk to QA Advisor
/Blog/Playwright Accessibility Testing: A Practical Guide for QA Teams
Accessibility Testing5 min read

Playwright Accessibility Testing: A Practical Guide for QA Teams

Wiring axe-core into Playwright, scoping scans so they are actionable, testing interaction states, and being honest about what automation cannot reach.

Published September 22, 2026Last updated September 22, 2026
On this page

Playwright does not test accessibility on its own. It drives the browser, and @axe-core/playwright runs the axe engine inside the page it has loaded. Understanding that split is what stops teams believing a green suite means an accessible product.

This guide covers wiring axe into a Playwright suite, scoping scans so they are actionable, and being honest about the large share of accessibility work automation cannot reach.

The short version
Playwright drives the browser; @axe-core/playwright runs the axe engine inside the loaded page. Scope scans with include and exclude so output is actionable, filter with withTags to the standard you are actually held to, and scan interaction states rather than only the initial render. Keyboard and focus behaviour needs explicit Playwright assertions, because axe cannot judge it. A passing suite is a regression guard, not a compliance audit.

What automated accessibility testing actually covers

Playwright's own documentation carries the caveat, and it is worth repeating before any code:

> Automated accessibility tests can detect some common accessibility problems such as > missing or invalid properties. But many accessibility problems can only be discovered > through manual testing.

Automation is reliable at machine-checkable properties: a missing alt, an input with no label, insufficient colour contrast, an invalid ARIA attribute. These are real defects and catching them automatically is worth doing.

It cannot evaluate whether your alt text is meaningful, whether focus order matches visual order in a way a keyboard user can follow, or whether an error message makes sense when read aloud in a screen reader. Those need a person.

What an automated scan catches, and what it cannot
IssueAutomated scanNeeds a person
Missing alt attributeCatches it-
Whether alt text is meaningfulCannot judgeYes, requires understanding the image
Form input with no labelCatches it-
Whether the label describes the fieldCannot judgeYes
Colour contrast below thresholdCatches it-
Meaning conveyed by colour aloneCannot judgeYes
Invalid ARIA attributeCatches it-
Whether ARIA matches actual behaviourCannot judgeYes
Focus order matching visual orderCannot judgeYes, keyboard walkthrough
Screen reader announcement qualityCannot judgeYes, assistive technology testing
Source: playwright.dev/docs/accessibility-testing

The correct framing for stakeholders: automated scans are a regression guard against mechanical regressions. They are not a compliance audit, and describing a passing suite as "WCAG compliant" is a claim your suite cannot support.

Setting it up

bash
npm install --save-dev @playwright/test @axe-core/playwright

A scan of a full page is four lines of meaningful code.

typescript
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';

test('checkout page has no automatically detectable violations', async ({ page }) => {
  await page.goto('/checkout');

  const results = await new AxeBuilder({ page }).analyze();

  expect(results.violations).toEqual([]);
});

analyze() runs axe inside the loaded page and returns violations, passes, incomplete results and inapplicable rules. Asserting on violations alone is the common starting point. For more, see our guide to Playwright test hooks.

Scoping scans so the output is actionable

A full-page scan on a complex application returns violations from headers, footers, cookie banners and third-party widgets. The genuine defect in the component you changed is buried, so people stop reading the output.

Scan a component, not the page

typescript
test('payment form is accessible', async ({ page }) => {
  await page.goto('/checkout');

  const results = await new AxeBuilder({ page })
    .include('#payment-form')
    .analyze();

  expect(results.violations).toEqual([]);
});

Exclude what you do not control

typescript
const results = await new AxeBuilder({ page })
  .exclude('#intercom-widget')
  .exclude('.third-party-ad')
  .analyze();

Excluding a third-party widget is a legitimate engineering decision, because you cannot fix someone else's iframe. Record the exclusion and raise it with that vendor rather than letting it silently disappear.

Filter to the standard you are held to

typescript
const results = await new AxeBuilder({ page })
  .withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'])
  .analyze();

Tag filtering matters commercially. If your obligation is WCAG 2.1 Level AA, scanning against best-practice rules produces findings nobody is required to fix, which makes the report easy to dismiss. For more, see our guide to accessibility testing tools.

🔬 From our work
We hold ISO 27001:2022 and CMMI Dev ML3 and run accessibility work as part of our QA delivery, but we have published no case study on accessibility specifically, so we have no measured figures to offer on this topic and will not invent any. The observation we can make from delivery is about sequencing: teams that wire an axe scan into CI before agreeing which WCAG version and conformance level they are held to end up with a backlog nobody is obliged to fix, and the suite gets muted. Agree the standard, then automate against it.

Testing the states a scan never reaches

The biggest gap in most accessibility suites is not rule configuration. It is that scans run against the initial render, and accessibility defects concentrate in states users reach by interacting.

typescript
test('validation errors are announced', async ({ page }) => {
  await page.goto('/checkout');

  // Trigger the error state before scanning
  await page.getByRole('button', { name: 'Place order' }).click();
  await expect(page.getByText('Card number is required')).toBeVisible();

  const results = await new AxeBuilder({ page })
    .include('#payment-form')
    .analyze();

  expect(results.violations).toEqual([]);
});

Scan after opening modals, expanding accordions, triggering validation and loading asynchronous content. A form that passes empty and fails with errors displayed is the normal case, not the exception.

Keyboard and focus, which axe cannot judge

Keyboard operability is where automation stops and Playwright still helps, because you can assert on focus directly.

typescript
test('modal traps focus and restores it on close', async ({ page }) => {
  await page.goto('/settings');
  await page.getByRole('button', { name: 'Delete account' }).click();

  const dialog = page.getByRole('dialog');
  await expect(dialog).toBeVisible();

  // Focus must move into the dialog
  await expect(dialog.getByRole('button', { name: 'Cancel' })).toBeFocused();

  // Escape closes it and focus returns to the trigger
  await page.keyboard.press('Escape');
  await expect(dialog).toBeHidden();
  await expect(page.getByRole('button', { name: 'Delete account' })).toBeFocused();
});

This is not an axe scan and it catches a defect axe cannot: focus lost to the document body after closing a dialog, which strands keyboard users.

Using getByRole throughout your normal suite is itself an accessibility check. If a button cannot be found by its accessible name, screen reader users cannot find it either.

Reporting violations usefully

expect(results.violations).toEqual([]) fails with an unreadable object diff. Attach the findings instead.

typescript
test('product page accessibility', async ({ page }, testInfo) => {
  await page.goto('/product/sku-1024');
  const results = await new AxeBuilder({ page }).analyze();

  await testInfo.attach('axe-results', {
    body: JSON.stringify(results.violations, null, 2),
    contentType: 'application/json',
  });

  expect(results.violations.map(v => `${v.id}: ${v.help}`)).toEqual([]);
});

Mapping to id: help before asserting means the failure message names the rule and the fix rather than dumping a nested object. The full detail stays attached to the report for whoever picks it up.

Handling known violations without muting the suite

Every existing product has accessibility debt. A suite that fails on all of it from day one gets skipped, so you need a way to hold the line on new defects while legacy findings are worked through.

The approach that survives is a baseline file: record the violations that exist today, fail only on violations that are not in it, and shrink it deliberately.

typescript
import knownViolations from './a11y-baseline.json';

test('catalogue page has no NEW violations', async ({ page }) => {
  await page.goto('/catalogue');
  const results = await new AxeBuilder({ page })
    .withTags(['wcag2a', 'wcag2aa', 'wcag21aa'])
    .analyze();

  const newOnes = results.violations.filter(
    v => !knownViolations.includes(v.id),
  );

  expect(newOnes.map(v => `${v.id}: ${v.help}`)).toEqual([]);
});

Two rules keep this honest. The baseline may only shrink, never grow, enforced in review. And every entry needs an owner and a target date, otherwise the file becomes a permanent exemption list rather than a temporary one.

Rules worth disabling, and rules worth arguing about

Occasionally a rule genuinely does not apply. disableRules exists for that.

typescript
const results = await new AxeBuilder({ page })
  .disableRules(['region'])
  .analyze();

Disable a rule only when you can state why in one sentence, and record that sentence next to the code. "The component renders inside a host page we do not control, so landmark regions are not ours to define" is a reason. "It was noisy" is a decision to ship inaccessible software, written as a configuration change.

Colour contrast is the rule teams most often try to disable, usually because it conflicts with a brand palette. That conflict is real and it is a design decision, not a testing one. Escalate it to whoever owns the palette rather than silencing the check.

Common mistakes

Common mistakes in Playwright accessibility suites
MistakeWhat it causesFix
Calling a passing suite WCAG compliantA claim the evidence cannot supportAutomated scans are a regression guard; compliance needs a manual audit
Scanning only the initial renderModals, validation and expanded content never testedDrive the interaction, then scan
Full-page scans on every testReal defects buried under header and third-party noiseScope with include, exclude what you do not control
No tag filterBest-practice findings nobody is obliged to fixwithTags for the standard you are actually held to
Failing the build on legacy debtSuite gets disabled within weeksBaseline known violations, fail only on new ones
Disabling noisy rulesShipping inaccessible software as a config changeRecord a one-sentence reason, or escalate to design
No keyboard assertionsFocus traps and lost focus ship undetectedAssert toBeFocused around dialogs and menus

The first is the one that causes commercial trouble. A passing axe suite tells you the mechanical checks passed, and saying "we are WCAG 2.1 AA compliant" on that basis is a claim your evidence does not support. If someone needs that statement, it requires a manual audit against every applicable success criterion.

A realistic CI shape

Where accessibility checks belong in CI
StageWhat runsOn failure
Pull requestaxe scan scoped to changed components, WCAG AA tagsFail the build
Pull requestKeyboard and focus assertions for changed flowsFail the build
NightlyFull-page scans across key journeysReport, do not block
NightlyInteraction states: modals, validation, expanded contentReport, do not block
Pre-releaseManual keyboard walkthrough of changed journeysBlocks release if operability is broken
QuarterlyAssistive technology testing and expert auditFeeds the remediation backlog

Fail the build on new violations in components under active change. Report, rather than fail, on pages with known legacy debt, otherwise the suite gets disabled in week two.

Agree the exact standard first: WCAG version, conformance level, and which pages are in scope. Without that, "accessible" is a matter of opinion and the suite has no defined target.

If you need an accessibility audit that covers what automation cannot reach, our accessibility audit service combines automated scanning with manual and assistive-technology testing.

Frequently Asked Questions

How do I run accessibility tests in Playwright?

Install @axe-core/playwright, then construct an AxeBuilder with the page and call analyze(). Playwright drives the browser and axe runs inside the loaded page, returning violations, passes, incomplete results and inapplicable rules. Asserting that violations is empty is the usual starting point.

Does passing an axe scan mean my site is WCAG compliant?

No, and claiming it is a commercial risk. Playwright's own documentation states that automated tests detect some common problems but many accessibility issues can only be discovered through manual testing. Automated scans catch machine-checkable properties such as missing alt attributes, unlabelled inputs and contrast failures. They cannot judge whether alt text is meaningful or whether focus order makes sense.

How do I stop accessibility scans returning irrelevant violations?

Scope them. Use include to scan the component you changed rather than the whole page, and exclude third-party widgets you cannot fix. Then use withTags to filter to the standard you are actually held to, such as wcag2a, wcag2aa and wcag21aa, so you are not shown best-practice findings nobody is obliged to act on.

Why do my accessibility tests miss defects users report?

Almost always because the scan runs against the initial render. Accessibility defects concentrate in states users reach by interacting: open modals, expanded accordions, displayed validation errors, asynchronously loaded content. Drive the interaction first, then scan. A form that passes when empty and fails with errors displayed is the normal case.

Can Playwright test keyboard accessibility?

Yes, and this is where it adds most value beyond axe. Assert focus directly with toBeFocused: check that focus moves into a dialog when it opens and returns to the trigger when it closes. Axe cannot evaluate this, and focus lost to the document body strands keyboard users completely.

How do I handle existing accessibility violations without disabling the suite?

Use a baseline file listing the violations that exist today, and fail only on violations not in it. Two rules keep it honest: the baseline may only shrink, enforced in code review, and every entry needs an owner and a target date. Otherwise it becomes a permanent exemption list.

When is it acceptable to disable an axe rule?

Only when you can state the reason in one sentence and record it next to the code. A component rendering inside a host page you do not control is a reason to disable landmark region checks. Noise is not a reason. Colour contrast in particular is usually a design decision that belongs with whoever owns the palette, not a check to silence.

Should accessibility tests fail the build?

Fail on new violations in components under active change, and report rather than block on pages with known legacy debt. A suite that fails on everything from day one gets disabled within weeks, and a disabled suite still costs CI minutes while giving the appearance of coverage.

Free Assessment

Get a free QA audit for your project

Identify quality gaps before they become production bugs.

Get Free Audit

Ship software with confidence

Talk to a QA advisor and find out how QAble can help your team build quality in at every stage.

No sales pitch
Technical walkthrough
No lock-in commitment

Talk to QA Advisor

Direct access to QAble's QA specialists.

Response within 24 hours