Accessibility Testing

12Accessibility (a11y) Testing

What you will master here

  • What WCAG levels mean (A / AA / AAA) and what to target
  • axe-core integration with Playwright
  • Role / name / focus / keyboard / contrast checks
  • What automation catches vs what needs human review
  • CI gating on a11y violations

12.1 Why a11y testing

15% of users have a disability of some kind. Beyond ethics, accessibility is increasingly a legal requirement (EU Accessibility Act 2025, ADA in US, Section 508). And — usefully for SDETs — accessible markup is also the markup Playwright's role-based locators rely on. Improving a11y improves test stability for free.

12.2 WCAG levels

LevelWhat it coversRealistic target
AMost basic — alt text, keyboard, no flashingBare minimum, always
AAPractical — colour contrast 4.5:1, focus indicators, no time-outsStandard industry target
AAAHighest — sign language, contrast 7:1, extended textHard to fully meet; pick where it counts

12.3 axe-core + Playwright

axe-core is the industry-standard a11y engine (used by Lighthouse and many tools). Install the Playwright wrapper:

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

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

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

If there are violations, the message lists each rule (e.g. color-contrast, label) with the failing nodes and a help URL — actionable, not noise.

12.4 Common rules to gate on

RuleWhat it catches
color-contrastText-to-background contrast below WCAG threshold
labelForm control without an accessible name
image-alt<img> missing alt
button-nameButton has no accessible name (e.g. icon-only without aria-label)
link-nameLink with no text / aria-label
aria-required-attrARIA role missing required attributes
landmark-one-mainPage should have one <main>
document-title<title> element present and non-empty
heading-orderh1→h2→h3 logical order (no skipping)

12.5 Scoping the scan

// Only scan a specific component
const results = await new AxeBuilder({ page })
  .include('.product-card')
  .exclude('.third-party-widget')
  .withTags(['wcag2aa'])
  .disableRules(['color-contrast'])  // temporary while a contrast fix is in progress
  .analyze();

12.6 Manual checks the automation can't do

12.7 Keyboard + focus tests (DIY without axe)

test('tab order goes through every form field', async ({ page }) => {
  await page.goto('/signup');
  await page.keyboard.press('Tab');
  await expect(page.getByLabel('Email')).toBeFocused();
  await page.keyboard.press('Tab');
  await expect(page.getByLabel('Password')).toBeFocused();
  await page.keyboard.press('Tab');
  await expect(page.getByLabel('Confirm password')).toBeFocused();
  await page.keyboard.press('Tab');
  await expect(page.getByRole('button', { name: 'Sign up' })).toBeFocused();
});

test('error message is announced to screen readers', async ({ page }) => {
  await page.goto('/login');
  await page.getByRole('button', { name: 'Sign in' }).click();
  const err = page.getByRole('alert');           // role="alert" = aria-live=assertive
  await expect(err).toBeVisible();
  await expect(err).toHaveText(/email is required/i);
});

12.8 CI gating

// tests/a11y.spec.ts — run as part of regular suite
import AxeBuilder from '@axe-core/playwright';

const PAGES = ['/', '/login', '/signup', '/products', '/cart', '/checkout'];
for (const path of PAGES) {
  test(`a11y · ${path}`, async ({ page }) => {
    await page.goto(path);
    const results = await new AxeBuilder({ page })
      .withTags(['wcag2a', 'wcag2aa'])
      .analyze();
    /* Hard fail on any violation. Or soft-fail and report a count. */
    expect(results.violations, JSON.stringify(results.violations, null, 2)).toEqual([]);
  });
}
Roll-out tipStarting from an existing app, gating "zero violations" will fail loudly. Capture a baseline of allowed violations, gate on "no new ones", and burn down the baseline over sprints.

Module 14 — Interview Q&A bank

What's the most common WCAG target?
WCAG 2.1 / 2.2 Level AA. Covers contrast 4.5:1, keyboard nav, focus visible, no flashing, alternatives for non-text content. AAA is aspirational; A is bare minimum.
How does axe-core fit with Playwright?
Via @axe-core/playwright. You create an AxeBuilder({ page }), configure tags/includes/excludes, call .analyze(). It injects axe-core into the page, runs the rules, returns a JSON report. Assert violations is empty.
What can automated a11y NOT catch?
Tab order sensibility, focus ring visibility, screen-reader phrasing quality, cognitive load, alternative-text accuracy, time-limit avoidability. Axe catches ~30% of issues; the rest needs keyboard testing and screen-reader passes.
How do you start a11y on an app with thousands of existing violations?
Capture a baseline, gate "no new violations" against that baseline, burn down per sprint. Don't add a thousand bugs to a hundred-bug backlog and then ignore them.
How do you write a keyboard navigation test?
Use page.keyboard.press('Tab') repeatedly, asserting toBeFocused() on each expected element in turn. Ensures focus order matches visual reading order.
How do you assert an error message is announced to a screen reader?
Confirm the error container has role="alert" (or aria-live="assertive"). Then assert it's visible and has the expected text. The role is what triggers the announcement, not the visibility.
What is "accessible name" in one sentence?
The string a screen reader speaks for an element — computed from aria-labelledby, aria-label, associated label, alt, visible text, in that priority.
Should a11y tests block PR merges?
Yes — but only on the rules you've fully cleared. Add a "new violation" gate, not a "zero violations" gate, in a legacy app. New code must not regress accessibility.
How is a11y tied to test stability?
Role-based locators (getByRole) work because the markup is semantic. Improving a11y (correct roles, labels, headings) makes your tests less brittle. Bad markup hurts both users and SDETs.

12.9 WCAG 2.2 (new since 2.1)

12.10 Screen reader basics (NVDA / VoiceOver / JAWS)

Keyboard cheat sheet — test in every screen reader:

12.11 ARIA — only when HTML doesn't suffice

NeedHTML firstARIA fallback
Clickable<button>role="button" tabindex="0" + keyDown handler
Toggle<input type="checkbox">role="switch" aria-checked
Tabsrole tablist/tab/tabpanel
Modal<dialog>role="dialog" aria-modal="true"
Live notificationaria-live="polite" / role="alert"
Form field erroraria-invalid aria-describedby
First rule of ARIADon't use it. Native HTML is always better — built-in behaviour + keyboard + screen reader support. Only reach for ARIA when no HTML element fits.

12.12 Common WCAG failures & how to test

IssueHow to spot
Missing form labelaxe + manual screen-reader pass
Contrast < 4.5:1axe automated; designer Chrome extension
Focus trap in modal missingTab cycles into background
Focus not visibleTab through with default browser styles
Click-only menusTry keyboard nav (Tab + Enter)
Icon-only button no labelHover with VoiceOver — does it announce purpose?
Tab order doesn't match visualTab sequence reads top→bottom, left→right
Auto-playing mediaPage load — does anything start?

12.13 Programmatic a11y in CI (full pipeline)

// Page object with built-in scan
import AxeBuilder from '@axe-core/playwright';

export class A11yPage {
  constructor(protected page: any) {}
  async scan(opts: { exclude?: string[]; tags?: string[] } = {}) {
    let b = new AxeBuilder({ page: this.page });
    if (opts.exclude) opts.exclude.forEach(s => b = b.exclude(s));
    b = b.withTags(opts.tags ?? ['wcag2a', 'wcag2aa', 'wcag22aa']);
    const r = await b.analyze();
    return r.violations;
  }
}

// Per-PR baseline gate
import baseline from './a11y-baseline.json';
test('no new a11y violations', async ({ page }) => {
  await page.goto('/');
  const violations = await new A11yPage(page).scan();
  const newViolations = violations.filter(v =>
    !baseline.find((b: any) => b.id === v.id && b.nodes.length >= v.nodes.length)
  );
  expect(newViolations, JSON.stringify(newViolations, null, 2)).toEqual([]);
});

12.14 Beyond axe — manual checks

Module 14 — More A11y Q&A

What's new in WCAG 2.2?
Focus not obscured (sticky headers), focus appearance (contrast/thickness), dragging movements (single-pointer alt), target size (≥ 24×24), consistent help, accessible authentication.
Why "ARIA first" is wrong?
Native HTML elements have built-in keyboard support, focus management, and screen reader semantics. Custom ARIA reimplements all of this and usually wrong. First rule of ARIA: don't use it unless no HTML element fits.
Focus trap in modal — how?
On open: save current focus, move to first focusable in modal. On Tab at last element: wrap to first. On Esc: close + restore focus. Libraries: focus-trap, headless-ui dialog.
How do you test screen reader output programmatically?
Limited automation possible. Use Playwright's accessibility tree snapshot: page.accessibility.snapshot(). Combine with Snapshot tests. Real screen reader output requires manual passes or paid services like Assistiv Labs.
How to handle "passes axe but still inaccessible"?
Axe catches ~30%. Add keyboard-only test, screen reader spot checks, real-user testing. Common gaps axe misses: tab order, dynamic content announcements, cognitive issues.