Loading source
Pulling the file list, source metadata, and syntax-aware rendering for this listing.
Source from repo
Comprehensive Playwright testing guide covering E2E, component, API, visual, accessibility, and security tests.
Files
Skill
Size
Entrypoint
Format
Open file
Syntax-highlighted preview of this file as included in the skill package.
testing-patterns/accessibility.md
1# Accessibility Testing23## Table of Contents451. [Axe-Core Integration](#axe-core-integration)62. [Keyboard Navigation](#keyboard-navigation)73. [ARIA Validation](#aria-validation)84. [Focus Management](#focus-management)95. [Color & Contrast](#color--contrast)1011## Axe-Core Integration1213### Setup1415```bash16npm install -D @axe-core/playwright17```1819### Basic A11y Test2021```typescript22import { test, expect } from "@playwright/test";23import AxeBuilder from "@axe-core/playwright";2425test("homepage should have no a11y violations", async ({ page }) => {26await page.goto("/");2728const results = await new AxeBuilder({ page }).analyze();2930expect(results.violations).toEqual([]);31});32```3334### Scoped Analysis3536```typescript37test("form accessibility", async ({ page }) => {38await page.goto("/contact");3940// Analyze only the form41const results = await new AxeBuilder({ page })42.include("#contact-form")43.analyze();4445expect(results.violations).toEqual([]);46});4748test("ignore known issues", async ({ page }) => {49await page.goto("/legacy-page");5051const results = await new AxeBuilder({ page })52.exclude(".legacy-widget") // Skip legacy component53.disableRules(["color-contrast"]) // Disable specific rule54.analyze();5556expect(results.violations).toEqual([]);57});58```5960### A11y Fixture6162```typescript63// fixtures/a11y.fixture.ts64import { test as base } from "@playwright/test";65import AxeBuilder from "@axe-core/playwright";6667type A11yFixtures = {68makeAxeBuilder: () => AxeBuilder;69};7071export const test = base.extend<A11yFixtures>({72makeAxeBuilder: async ({ page }, use) => {73await use(() =>74new AxeBuilder({ page }).withTags([75"wcag2a",76"wcag2aa",77"wcag21a",78"wcag21aa",79]),80);81},82});8384// Usage85test("dashboard a11y", async ({ page, makeAxeBuilder }) => {86await page.goto("/dashboard");87const results = await makeAxeBuilder().analyze();88expect(results.violations).toEqual([]);89});90```9192### Detailed Violation Reporting9394```typescript95test("report a11y issues", async ({ page }) => {96await page.goto("/");9798const results = await new AxeBuilder({ page }).analyze();99100// Custom failure message with details101const violations = results.violations.map((v) => ({102id: v.id,103impact: v.impact,104description: v.description,105nodes: v.nodes.map((n) => n.html),106}));107108expect(violations, JSON.stringify(violations, null, 2)).toHaveLength(0);109});110```111112## Keyboard Navigation113114### Tab Order Testing115116```typescript117test("correct tab order in form", async ({ page }) => {118await page.goto("/signup");119120// Start from the beginning121await page.keyboard.press("Tab");122await expect(page.getByLabel("Email")).toBeFocused();123124await page.keyboard.press("Tab");125await expect(page.getByLabel("Password")).toBeFocused();126127await page.keyboard.press("Tab");128await expect(page.getByRole("button", { name: "Sign up" })).toBeFocused();129});130```131132### Keyboard-Only Interaction133134```typescript135test("complete flow with keyboard only", async ({ page }) => {136await page.goto("/products");137138// Navigate to product with keyboard139await page.keyboard.press("Tab"); // Skip to main content140await page.keyboard.press("Tab"); // First product141await page.keyboard.press("Enter"); // Open product142143await expect(page).toHaveURL(/\/products\/\d+/);144145// Add to cart with keyboard146await page.keyboard.press("Tab");147await page.keyboard.press("Tab"); // Navigate to "Add to Cart"148await page.keyboard.press("Enter");149150await expect(page.getByRole("alert")).toContainText("Added to cart");151});152```153154### Skip Links155156```typescript157test("skip link works", async ({ page }) => {158await page.goto("/");159160await page.keyboard.press("Tab");161const skipLink = page.getByRole("link", { name: /skip to main/i });162await expect(skipLink).toBeFocused();163164await page.keyboard.press("Enter");165166// Focus should move to main content167await expect(page.getByRole("main")).toBeFocused();168});169```170171### Escape Key Handling172173```typescript174test("escape closes modal", async ({ page }) => {175await page.goto("/dashboard");176await page.getByRole("button", { name: "Settings" }).click();177178const modal = page.getByRole("dialog");179await expect(modal).toBeVisible();180181await page.keyboard.press("Escape");182183await expect(modal).toBeHidden();184// Focus should return to trigger185await expect(page.getByRole("button", { name: "Settings" })).toBeFocused();186});187```188189## ARIA Validation190191### Role Verification192193```typescript194test("correct ARIA roles", async ({ page }) => {195await page.goto("/dashboard");196197// Verify landmark roles198await expect(page.getByRole("navigation")).toBeVisible();199await expect(page.getByRole("main")).toBeVisible();200await expect(page.getByRole("contentinfo")).toBeVisible(); // footer201202// Verify interactive roles203await expect(page.getByRole("button", { name: "Menu" })).toBeVisible();204await expect(page.getByRole("search")).toBeVisible();205});206```207208### ARIA States209210```typescript211test("aria-expanded updates correctly", async ({ page }) => {212await page.goto("/faq");213214const accordion = page.getByRole("button", { name: "Shipping" });215216// Initially collapsed217await expect(accordion).toHaveAttribute("aria-expanded", "false");218219await accordion.click();220221// Now expanded222await expect(accordion).toHaveAttribute("aria-expanded", "true");223224// Content is visible225const panel = page.getByRole("region", { name: "Shipping" });226await expect(panel).toBeVisible();227});228```229230### Live Regions231232```typescript233test("live region announces updates", async ({ page }) => {234await page.goto("/checkout");235236// Find live region237const liveRegion = page.locator('[aria-live="polite"]');238239await page.getByLabel("Quantity").fill("3");240241// Live region should update with new total242await expect(liveRegion).toContainText("Total: $29.97");243});244```245246## Focus Management247248### Focus Trap in Modal249250```typescript251test("focus trapped in modal", async ({ page }) => {252await page.goto("/");253await page.getByRole("button", { name: "Open Modal" }).click();254255const modal = page.getByRole("dialog");256await expect(modal).toBeVisible();257258// Get all focusable elements in modal259const focusableElements = modal.locator(260'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',261);262const count = await focusableElements.count();263264// Tab through all elements, should stay in modal265for (let i = 0; i < count + 1; i++) {266await page.keyboard.press("Tab");267const focused = page.locator(":focus");268await expect(modal).toContainText((await focused.textContent()) || "");269}270});271```272273### Focus Restoration274275```typescript276test("focus returns after modal close", async ({ page }) => {277await page.goto("/");278279const trigger = page.getByRole("button", { name: "Delete Item" });280await trigger.click();281282await page.getByRole("button", { name: "Cancel" }).click();283284// Focus should return to the trigger285await expect(trigger).toBeFocused();286});287```288289## Color & Contrast290291### High Contrast Mode292293```typescript294test("works in high contrast mode", async ({ page }) => {295await page.emulateMedia({ forcedColors: "active" });296await page.goto("/");297298// Verify key elements are visible299await expect(page.getByRole("navigation")).toBeVisible();300await expect(page.getByRole("button", { name: "Sign In" })).toBeVisible();301302// Take screenshot for visual verification303await expect(page).toHaveScreenshot("high-contrast.png");304});305```306307### Reduced Motion308309```typescript310test("respects reduced motion preference", async ({ page }) => {311await page.emulateMedia({ reducedMotion: "reduce" });312await page.goto("/");313314// Animations should be disabled315const hero = page.getByTestId("hero-animation");316const animation = await hero.evaluate(317(el) => getComputedStyle(el).animationDuration,318);319320expect(animation).toBe("0s");321});322```323324## CI Integration325326### A11y as CI Gate327328```typescript329// playwright.config.ts330export default defineConfig({331projects: [332{333name: "a11y",334testMatch: /.*\.a11y\.spec\.ts/,335use: { ...devices["Desktop Chrome"] },336},337],338});339```340341```yaml342# .github/workflows/a11y.yml343- name: Run accessibility tests344run: npx playwright test --project=a11y345```346347## Anti-Patterns to Avoid348349| Anti-Pattern | Problem | Solution |350| ----------------------------- | ---------------------------- | ------------------------------------------ |351| Testing a11y only on homepage | Misses issues on other pages | Test all critical user flows |352| Ignoring all violations | No value from tests | Address or explicitly exclude known issues |353| Only automated testing | Misses many a11y issues | Combine with manual testing |354| Testing without screen reader | Misses interaction issues | Test with VoiceOver/NVDA periodically |355356## Related References357358- **Locators**: See [locators.md](../core/locators.md) for role-based selectors359- **Visual testing**: See [test-suite-structure.md](../core/test-suite-structure.md) for screenshot comparison360