Skip to content

Accessibility testing

@lullabot/playwright-testing combines axe-core scans with explicit baselines, Playwright annotations, JSON attachments, and highlighted violation screenshots. Its defaults are framework-neutral: no selectors are excluded unless the caller supplies them.

Run a scan

import { test } from '@playwright/test';
import { checkAccessibility } from '@lullabot/playwright-testing';

test('about page is accessible', async ({ page }, testInfo) => {
  await page.goto('/about');
  await checkAccessibility(page, testInfo, {
    exclude: ['[data-third-party-widget]'],
    rules: {
      'color-contrast': { enabled: true },
    },
  });
});

checkAccessibility() runs a best-practice scan followed by a WCAG scan. It adds an @a11y annotation, attaches the complete axe results, and attaches a full-page screenshot with WCAG violations outlined in red. Set screenshotViolations: false to omit that image.

The most useful AccessibilityOptions are:

Field Default Purpose
wcagTags WCAG 2.0/2.1 A and AA Select the tags used for the WCAG scan.
exclude [] Exclude selectors from both scans.
bestPracticeExclude [] Exclude selectors only from the best-practice scan.
wcagExclude [] Exclude selectors only from the WCAG scan.
bestPracticeMode soft Use off to skip the best-practice scan.
rules none Enable or disable individual axe rules.
baseline none Supply an in-code allowlist instead of an on-disk baseline.
screenshotViolations true Attach a highlighted screenshot when WCAG violations exist.

On-disk baselines

Without an in-code baseline, each scan uses a JSON baseline colocated with the test's screenshots. On the first local run, the helper writes a file such as home-page-1.a11y-baseline.json and allows the run to complete. Fill in the reason and tracking link before committing it:

{
  "note": "Known accessibility violations accepted by this test.",
  "violations": [
    {
      "rule": "color-contrast",
      "targets": ["#footer .legal"],
      "reason": "Waiting for the approved brand palette update.",
      "willBeFixedIn": "https://example.com/issues/123"
    }
  ]
}

When CI is set, a missing baseline is still written and attached to the report, but the test fails. Download and commit the seed rather than silently accepting new violations. A committed legacy .txt accessibility snapshot is still honored, but new tests should use JSON baselines. Detection uses Playwright's anonymous text snapshot naming, including counter-dependent truncation and hashing, and resolves the exact path for the current project and snapshot suffix with testInfo.snapshotPath(). PNG snapshots and files belonging to other tests or projects do not select snapshot mode.

Detection supports templates that place {arg} in the filename, with a stable parent directory (which may use {testName}, {projectName}, and {ext}). Explicitly named snapshots and templates that put {arg} in a directory are not discovered. Tests whose titles sanitize to the same filename have Playwright's own collision limitations. Anonymous argument generation mirrors Playwright's naming algorithm because no public API generates an arbitrary counter; runner regression tests guard against upstream changes.

Entries that match current violations are reported as baselined annotations. New violations fail the assertion with a copy-pasteable entry, while entries that no longer match are reported as stale so they can be removed.

Shared baselines

Use defineAccessibilityBaseline() for a baseline shared by multiple tests or generated from test data. An explicit baseline takes precedence over a JSON file.

For an in-code baseline, an entry is reported as stale once per check only when none of the enabled scans matches it.

import { test } from '@playwright/test';
import {
  checkAccessibility,
  defineAccessibilityBaseline,
} from '@lullabot/playwright-testing';

const baseline = defineAccessibilityBaseline([
  {
    rule: 'color-contrast',
    targets: ['#footer .legal'],
    reason: 'Waiting for the approved brand palette update.',
    willBeFixedIn: 'https://example.com/issues/123',
  },
]);

test('about page', async ({ page }, testInfo) => {
  await page.goto('/about');
  await checkAccessibility(page, testInfo, { baseline });
});

The same baseline can be assigned at the top-level, group, or case level of a visual-diff configuration.

Combine accessibility with a stable screenshot

takeAccessibleScreenshot() waits for page media and fonts, removes incidental focus and hover state, performs Playwright's screenshot assertion, and then runs the accessibility scans:

import { test } from '@playwright/test';
import { takeAccessibleScreenshot } from '@lullabot/playwright-testing';

test('navigation', async ({ page }, testInfo) => {
  await page.goto('/');
  await takeAccessibleScreenshot(page, testInfo, {
    fullPage: true,
    interactionStates: [
      {
        locator: page.getByRole('button', { name: 'Menu' }),
        states: ['hover', 'focus'],
      },
    ],
    accessibility: { baseline: [] },
  });
});

See Stable screenshots and visual comparisons for capture options and URL-driven suites. See GitHub reporting to render accessibility results in a workflow summary and as annotations.