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
| Level | What it covers | Realistic target |
|---|---|---|
| A | Most basic — alt text, keyboard, no flashing | Bare minimum, always |
| AA | Practical — colour contrast 4.5:1, focus indicators, no time-outs | Standard industry target |
| AAA | Highest — sign language, contrast 7:1, extended text | Hard 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
| Rule | What it catches |
|---|---|
| color-contrast | Text-to-background contrast below WCAG threshold |
| label | Form control without an accessible name |
| image-alt | <img> missing alt |
| button-name | Button has no accessible name (e.g. icon-only without aria-label) |
| link-name | Link with no text / aria-label |
| aria-required-attr | ARIA role missing required attributes |
| landmark-one-main | Page should have one <main> |
| document-title | <title> element present and non-empty |
| heading-order | h1→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
- Keyboard navigation — can a user reach every interactive element with Tab? Order makes sense?
- Focus visibility — does the focus ring show on every focusable element?
- Screen reader pass — VoiceOver / NVDA reads pages sensibly; correct headings, list announcements, form errors
- Cognitive — language plain, time limits avoidable, errors fixable
- Real users — best signal; partner with disability orgs for periodic audits
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)
- Focus Not Obscured — sticky headers/footers can hide focused element on scroll
- Focus Appearance — focus indicator must meet contrast + thickness
- Dragging Movements — drag must have single-pointer alternative
- Target Size (Minimum) — touch targets ≥ 24×24 CSS pixels
- Consistent Help — help links in same location across pages
- Accessible Authentication — no cognitive tests required (CAPTCHA exceptions)
12.10 Screen reader basics (NVDA / VoiceOver / JAWS)
- NVDA — free Windows; default Insert key. Test on Firefox/Chrome.
- VoiceOver — built into macOS/iOS. Cmd+F5. Use on Safari for parity with iPhone users.
- JAWS — paid Windows; gold standard for enterprise + government.
- TalkBack — Android.
Keyboard cheat sheet — test in every screen reader:
- H — next heading; 1-6 for level
- K — next link; B — next button; F — next form field
- Tab — next interactive; Shift+Tab — previous
- Esc — close popup
- Arrow keys — read line by line
12.11 ARIA — only when HTML doesn't suffice
| Need | HTML first | ARIA fallback |
|---|---|---|
| Clickable | <button> | role="button" tabindex="0" + keyDown handler |
| Toggle | <input type="checkbox"> | role="switch" aria-checked |
| Tabs | — | role tablist/tab/tabpanel |
| Modal | <dialog> | role="dialog" aria-modal="true" |
| Live notification | — | aria-live="polite" / role="alert" |
| Form field error | — | aria-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
| Issue | How to spot |
|---|---|
| Missing form label | axe + manual screen-reader pass |
| Contrast < 4.5:1 | axe automated; designer Chrome extension |
| Focus trap in modal missing | Tab cycles into background |
| Focus not visible | Tab through with default browser styles |
| Click-only menus | Try keyboard nav (Tab + Enter) |
| Icon-only button no label | Hover with VoiceOver — does it announce purpose? |
| Tab order doesn't match visual | Tab sequence reads top→bottom, left→right |
| Auto-playing media | Page 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
- Keyboard-only walkthrough — unplug mouse for 5 min
- Screen reader pass on critical flows quarterly
- 200% zoom — content still usable, no horizontal scroll
- Color contrast verify with WebAIM Contrast Checker on each new color
- Reduced motion — set
prefers-reduced-motion: reduce; animations should shorten / disappear - Real user testing with disabled users — best signal; partner with orgs like Fable
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.