Claude
Skills
Sign in
Back

a11y-playwright-testing

Included with Lifetime
$97 forever

Accessibility testing for web applications using Playwright (@playwright/test) with TypeScript and axe-core. Use when asked to write, run, or debug automated accessibility checks, keyboard navigation tests, focus management, ARIA/semantic validations, screen reader compatibility, or WCAG 2.1 Level AA compliance testing. Covers axe-core integration, POUR principles (perceivable, operable, understandable, robust), color contrast, form labels, landmarks, and accessible names.

Code Review

What this skill does


# Playwright Accessibility Testing (TypeScript)

Comprehensive toolkit for automated accessibility testing using Playwright with TypeScript and axe-core. Enables WCAG 2.1 Level AA compliance verification, keyboard operability testing, semantic validation, and accessibility regression prevention.

> **Activation:** This skill is triggered when working with accessibility testing, WCAG compliance, axe-core scans, keyboard navigation tests, focus management, ARIA validation, or screen reader compatibility.

## When to Use This Skill

- **Automated a11y scans** with axe-core for WCAG 2.1 AA compliance
- **Keyboard navigation tests** for Tab/Enter/Space/Escape/Arrow key operability
- **Focus management** validation for dialogs, menus, and dynamic content
- **Semantic structure** assertions for landmarks, headings, and ARIA
- **Form accessibility** testing for labels, errors, and instructions
- **Color contrast** and visual accessibility verification
- **Screen reader** compatibility testing patterns

## Prerequisites

| Requirement | Details                        |
| ----------- | ------------------------------ |
| Node.js     | v18+ recommended               |
| Playwright  | `@playwright/test` installed   |
| axe-core    | `@axe-core/playwright` package |
| TypeScript  | Configured in project          |

### Quick Setup

```bash
# Add axe-core to existing Playwright project
npm install -D @axe-core/playwright axe-core
```

## First Questions to Ask

Before writing accessibility tests, clarify:

1. **Scope**: Which pages/flows are in scope? What's explicitly excluded?
2. **Standard**: WCAG 2.1 AA (default) or specific organizational policy?
3. **Priority**: Which components are highest risk (forms, modals, navigation, checkout)?
4. **Exceptions**: Known constraints (legacy markup, third-party widgets)?
5. **Assistive Tech**: Which screen readers/browsers need manual testing?

---

## Core Principles

### 1. Automation Limitations

> ⚠️ **Critical**: Automated tooling can detect ~30-40% of accessibility issues. Use automation to prevent regressions and catch common failures; **manual audits are required** for full WCAG conformance.

### 2. Semantic HTML First

Prefer native HTML semantics over ARIA. Use ARIA only when native elements cannot achieve the required semantics.

```typescript
// ✅ Semantic HTML - inherently accessible
await page.getByRole("button", { name: "Submit" }).click();

// ❌ ARIA override - requires manual keyboard/focus handling
await page.locator('[role="button"]').click(); // Often a <div>
```

### 3. Locator Strategy as A11y Signal

If you **cannot locate an element by role or label**, it's often an accessibility defect.

| Locator Success                              | Accessibility Signal       |
| -------------------------------------------- | -------------------------- |
| `getByRole('button', { name: 'Submit' })` ✅ | Button has accessible name |
| `getByLabel('Email')` ✅                     | Input properly labeled     |
| `getByRole('navigation')` ✅                 | Landmark exists            |
| `locator('.submit-btn')` ⚠️                  | May lack accessible name   |

---

## Key Workflows

### Automated Axe Scan (WCAG 2.1 AA)

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

test("page has no WCAG 2.1 AA violations", async ({ page }) => {
  await page.goto("/");

  const results = await new AxeBuilder({ page })
    .withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"])
    .analyze();

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

### Scoped Axe Scan (Component-Level)

```typescript
test("form component is accessible", async ({ page }) => {
  await page.goto("/contact");

  const results = await new AxeBuilder({ page })
    .include("#contact-form") // Scope to specific component
    .withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"])
    .analyze();

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

### Keyboard Navigation Test

```typescript
test("form is keyboard navigable", async ({ page }) => {
  await page.goto("/login");

  // Tab to first field
  await page.keyboard.press("Tab");
  await expect(page.getByLabel("Email")).toBeFocused();

  // Tab to password
  await page.keyboard.press("Tab");
  await expect(page.getByLabel("Password")).toBeFocused();

  // Tab to submit button
  await page.keyboard.press("Tab");
  await expect(page.getByRole("button", { name: "Sign in" })).toBeFocused();

  // Submit with Enter
  await page.keyboard.press("Enter");
  await expect(page).toHaveURL(/dashboard/);
});
```

### Dialog Focus Management

```typescript
test("dialog traps and returns focus", async ({ page }) => {
  await page.goto("/settings");
  const trigger = page.getByRole("button", { name: "Delete account" });

  // Open dialog
  await trigger.click();
  const dialog = page.getByRole("dialog");
  await expect(dialog).toBeVisible();

  // Focus should be inside dialog
  await expect(dialog.getByRole("button", { name: "Cancel" })).toBeFocused();

  // Tab should stay trapped in dialog
  await page.keyboard.press("Tab");
  await expect(dialog.getByRole("button", { name: "Confirm" })).toBeFocused();
  await page.keyboard.press("Tab");
  await expect(dialog.getByRole("button", { name: "Cancel" })).toBeFocused();

  // Escape closes and returns focus to trigger
  await page.keyboard.press("Escape");
  await expect(dialog).toBeHidden();
  await expect(trigger).toBeFocused();
});
```

### Skip Link Validation

```typescript
test("skip link moves focus to main content", async ({ page }) => {
  await page.goto("/");

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

  // Activating skip link moves focus to main
  await page.keyboard.press("Enter");
  await expect(page.locator('#main, [role="main"]').first()).toBeFocused();
});
```

---

## POUR Principles Reference

| Principle          | Focus Areas                               | Example Tests                                     |
| ------------------ | ----------------------------------------- | ------------------------------------------------- |
| **Perceivable**    | Alt text, captions, contrast, structure   | Image alternatives, color contrast ratio          |
| **Operable**       | Keyboard, focus, timing, navigation       | Tab order, focus visibility, skip links           |
| **Understandable** | Labels, instructions, errors, consistency | Form labels, error messages, predictable behavior |
| **Robust**         | Valid HTML, ARIA, name/role/value         | Semantic structure, accessible names              |

---

## Axe-Core Tags Reference

| Tag             | WCAG Level   | Use Case                   |
| --------------- | ------------ | -------------------------- |
| `wcag2a`        | Level A      | Minimum compliance         |
| `wcag2aa`       | Level AA     | **Standard target**        |
| `wcag2aaa`      | Level AAA    | Enhanced (rarely full)     |
| `wcag21a`       | 2.1 Level A  | WCAG 2.1 specific A        |
| `wcag21aa`      | 2.1 Level AA | **WCAG 2.1 standard**      |
| `best-practice` | Beyond WCAG  | Additional recommendations |

### Default Tags (WCAG 2.1 AA)

```typescript
const WCAG21AA_TAGS = ["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"];
```

---

## Exception Handling

When exceptions are unavoidable:

1. **Scope narrowly** - specific component/route only
2. **Document impact** - which WCAG criterion, user impact
3. **Set expiration** - owner + remediation date
4. **Track ticket** - link to remediation issue

```typescript
// ❌ Avoid: Global rule disable
new AxeBuilder({ page }).disableRules(["color-contrast"]);

// ✅ Better: Scoped exclusion with documentation
new AxeBuilder({ page })
  .exclude("#third-party-widget") // Known issue: JIRA-1234, fix by Q2
  .withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"])
  .analyze();
```

---

Related in Code Review