SurfSense/.cursor/skills/playwright-testing/testing-patterns/accessibility.md
2026-05-10 04:19:55 +05:30

9 KiB

Accessibility Testing

Table of Contents

  1. Axe-Core Integration
  2. Keyboard Navigation
  3. ARIA Validation
  4. Focus Management
  5. Color & Contrast

Axe-Core Integration

Setup

npm install -D @axe-core/playwright

Basic A11y Test

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

test("homepage should have no a11y violations", async ({ page }) => {
  await page.goto("/");

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

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

Scoped Analysis

test("form accessibility", async ({ page }) => {
  await page.goto("/contact");

  // Analyze only the form
  const results = await new AxeBuilder({ page })
    .include("#contact-form")
    .analyze();

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

test("ignore known issues", async ({ page }) => {
  await page.goto("/legacy-page");

  const results = await new AxeBuilder({ page })
    .exclude(".legacy-widget") // Skip legacy component
    .disableRules(["color-contrast"]) // Disable specific rule
    .analyze();

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

A11y Fixture

// fixtures/a11y.fixture.ts
import { test as base } from "@playwright/test";
import AxeBuilder from "@axe-core/playwright";

type A11yFixtures = {
  makeAxeBuilder: () => AxeBuilder;
};

export const test = base.extend<A11yFixtures>({
  makeAxeBuilder: async ({ page }, use) => {
    await use(() =>
      new AxeBuilder({ page }).withTags([
        "wcag2a",
        "wcag2aa",
        "wcag21a",
        "wcag21aa",
      ]),
    );
  },
});

// Usage
test("dashboard a11y", async ({ page, makeAxeBuilder }) => {
  await page.goto("/dashboard");
  const results = await makeAxeBuilder().analyze();
  expect(results.violations).toEqual([]);
});

Detailed Violation Reporting

test("report a11y issues", async ({ page }) => {
  await page.goto("/");

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

  // Custom failure message with details
  const violations = results.violations.map((v) => ({
    id: v.id,
    impact: v.impact,
    description: v.description,
    nodes: v.nodes.map((n) => n.html),
  }));

  expect(violations, JSON.stringify(violations, null, 2)).toHaveLength(0);
});

Keyboard Navigation

Tab Order Testing

test("correct tab order in form", async ({ page }) => {
  await page.goto("/signup");

  // Start from the beginning
  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.getByRole("button", { name: "Sign up" })).toBeFocused();
});

Keyboard-Only Interaction

test("complete flow with keyboard only", async ({ page }) => {
  await page.goto("/products");

  // Navigate to product with keyboard
  await page.keyboard.press("Tab"); // Skip to main content
  await page.keyboard.press("Tab"); // First product
  await page.keyboard.press("Enter"); // Open product

  await expect(page).toHaveURL(/\/products\/\d+/);

  // Add to cart with keyboard
  await page.keyboard.press("Tab");
  await page.keyboard.press("Tab"); // Navigate to "Add to Cart"
  await page.keyboard.press("Enter");

  await expect(page.getByRole("alert")).toContainText("Added to cart");
});
test("skip link works", async ({ page }) => {
  await page.goto("/");

  await page.keyboard.press("Tab");
  const skipLink = page.getByRole("link", { name: /skip to main/i });
  await expect(skipLink).toBeFocused();

  await page.keyboard.press("Enter");

  // Focus should move to main content
  await expect(page.getByRole("main")).toBeFocused();
});

Escape Key Handling

test("escape closes modal", async ({ page }) => {
  await page.goto("/dashboard");
  await page.getByRole("button", { name: "Settings" }).click();

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

  await page.keyboard.press("Escape");

  await expect(modal).toBeHidden();
  // Focus should return to trigger
  await expect(page.getByRole("button", { name: "Settings" })).toBeFocused();
});

ARIA Validation

Role Verification

test("correct ARIA roles", async ({ page }) => {
  await page.goto("/dashboard");

  // Verify landmark roles
  await expect(page.getByRole("navigation")).toBeVisible();
  await expect(page.getByRole("main")).toBeVisible();
  await expect(page.getByRole("contentinfo")).toBeVisible(); // footer

  // Verify interactive roles
  await expect(page.getByRole("button", { name: "Menu" })).toBeVisible();
  await expect(page.getByRole("search")).toBeVisible();
});

ARIA States

test("aria-expanded updates correctly", async ({ page }) => {
  await page.goto("/faq");

  const accordion = page.getByRole("button", { name: "Shipping" });

  // Initially collapsed
  await expect(accordion).toHaveAttribute("aria-expanded", "false");

  await accordion.click();

  // Now expanded
  await expect(accordion).toHaveAttribute("aria-expanded", "true");

  // Content is visible
  const panel = page.getByRole("region", { name: "Shipping" });
  await expect(panel).toBeVisible();
});

Live Regions

test("live region announces updates", async ({ page }) => {
  await page.goto("/checkout");

  // Find live region
  const liveRegion = page.locator('[aria-live="polite"]');

  await page.getByLabel("Quantity").fill("3");

  // Live region should update with new total
  await expect(liveRegion).toContainText("Total: $29.97");
});

Focus Management

Focus Trap in Modal

test("focus trapped in modal", async ({ page }) => {
  await page.goto("/");
  await page.getByRole("button", { name: "Open Modal" }).click();

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

  // Get all focusable elements in modal
  const focusableElements = modal.locator(
    'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
  );
  const count = await focusableElements.count();

  // Tab through all elements, should stay in modal
  for (let i = 0; i < count + 1; i++) {
    await page.keyboard.press("Tab");
    const focused = page.locator(":focus");
    await expect(modal).toContainText((await focused.textContent()) || "");
  }
});

Focus Restoration

test("focus returns after modal close", async ({ page }) => {
  await page.goto("/");

  const trigger = page.getByRole("button", { name: "Delete Item" });
  await trigger.click();

  await page.getByRole("button", { name: "Cancel" }).click();

  // Focus should return to the trigger
  await expect(trigger).toBeFocused();
});

Color & Contrast

High Contrast Mode

test("works in high contrast mode", async ({ page }) => {
  await page.emulateMedia({ forcedColors: "active" });
  await page.goto("/");

  // Verify key elements are visible
  await expect(page.getByRole("navigation")).toBeVisible();
  await expect(page.getByRole("button", { name: "Sign In" })).toBeVisible();

  // Take screenshot for visual verification
  await expect(page).toHaveScreenshot("high-contrast.png");
});

Reduced Motion

test("respects reduced motion preference", async ({ page }) => {
  await page.emulateMedia({ reducedMotion: "reduce" });
  await page.goto("/");

  // Animations should be disabled
  const hero = page.getByTestId("hero-animation");
  const animation = await hero.evaluate(
    (el) => getComputedStyle(el).animationDuration,
  );

  expect(animation).toBe("0s");
});

CI Integration

A11y as CI Gate

// playwright.config.ts
export default defineConfig({
  projects: [
    {
      name: "a11y",
      testMatch: /.*\.a11y\.spec\.ts/,
      use: { ...devices["Desktop Chrome"] },
    },
  ],
});
# .github/workflows/a11y.yml
- name: Run accessibility tests
  run: npx playwright test --project=a11y

Anti-Patterns to Avoid

Anti-Pattern Problem Solution
Testing a11y only on homepage Misses issues on other pages Test all critical user flows
Ignoring all violations No value from tests Address or explicitly exclude known issues
Only automated testing Misses many a11y issues Combine with manual testing
Testing without screen reader Misses interaction issues Test with VoiceOver/NVDA periodically