# Playwright Accessibility Testing

> Implement accessibility (a11y) testing with Playwright and axe-core. Use when: validating WCAG compliance, finding accessibility violations, testing keyboard navigation, ensuring screen reader compatibility.

- Skill: `bavithiranhardy14/playwright-accessibility-testing` (Agent Skill)
- Install (CLI): `npx skillmds@latest add bavithiranhardy14/playwright-accessibility-testing`
- Raw SKILL.md: https://api.skillmd.com/api/skills/bavithiranhardy14/playwright-accessibility-testing/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Web & Frontend
- Author: bavithiranhardy14 (https://skillmd.com/u/bavithiranhardy14)
- Updated: 2026-09-22
- Page: https://skillmd.com/skills/bavithiranhardy14/playwright-accessibility-testing

---


# Playwright Accessibility Testing

## When to Use

- WCAG 2.1 AA compliance validation
- Finding missing ARIA labels, alt text, or color contrast issues
- Testing keyboard-only navigation flows
- Validating screen reader compatibility
- Accessibility audits before production release

---

## Procedure

### 1. Install Dependencies

```bash
pip install playwright==1.36.0 pytest-playwright==0.3.0 axe-playwright-python==0.1.3
```

---

### 2. Accessibility Helper

**utils/accessibility.py**

```python
from playwright.sync_api import Page
from axe_playwright_python.sync_playwright import Axe
from utils.logger import get_logger
import json
from pathlib import Path

logger = get_logger(__name__)
REPORTS_DIR = Path("reports/accessibility")


class AccessibilityChecker:
    """Accessibility checker using axe-core via Playwright."""

    def __init__(self, page: Page):
        self.page = page
        self.axe = Axe()
        REPORTS_DIR.mkdir(parents=True, exist_ok=True)

    def run_audit(self, context: str = None, tags: list = None) -> dict:
        """
        Run axe accessibility audit on current page.
        
        Args:
            context: CSS selector to scope the audit (None = full page)
            tags: WCAG tags e.g. ['wcag2a', 'wcag2aa', 'wcag21aa']
        """
        options = {}
        if tags:
            options["runOnly"] = {"type": "tag", "values": tags}

        results = self.axe.run(self.page, context=context, options=options)
        return results

    def assert_no_violations(self, context: str = None,
                              tags: list = None,
                              report_name: str = "a11y_report"):
        """Assert zero accessibility violations and save report."""
        results = self.run_audit(context=context, tags=tags)
        violations = results.get("violations", [])

        # Save report
        report_path = REPORTS_DIR / f"{report_name}.json"
        with open(report_path, "w") as f:
            json.dump(results, f, indent=2)
        logger.info(f"A11y report saved: {report_path}")

        if violations:
            summary = self._format_violations(violations)
            raise AssertionError(
                f"Found {len(violations)} accessibility violation(s):\n{summary}"
            )

        logger.info(f"No accessibility violations found on: {self.page.url}")

    def get_violations(self, tags: list = None) -> list:
        """Return list of violations without asserting."""
        results = self.run_audit(tags=tags)
        return results.get("violations", [])

    def _format_violations(self, violations: list) -> str:
        lines = []
        for v in violations:
            lines.append(f"\n[{v['impact'].upper()}] {v['id']}: {v['description']}")
            lines.append(f"  Help: {v['helpUrl']}")
            for node in v.get("nodes", [])[:2]:  # Show first 2 affected nodes
                lines.append(f"  Element: {node['html'][:100]}")
        return "\n".join(lines)
```

---

### 3. Accessibility Tests

**tests/accessibility/test_a11y.py**

```python
import pytest
from utils.accessibility import AccessibilityChecker
from playwright.sync_api import expect

@pytest.mark.accessibility
class TestAccessibility:
    """Accessibility tests using axe-core + Playwright."""

    @pytest.fixture(autouse=True)
    def setup(self, page):
        self.page = page.page
        self.a11y = AccessibilityChecker(self.page)

    # ── WCAG 2.1 AA Full Page Audits ──────────────────────────

    def test_login_page_wcag_aa(self):
        """Login page must pass WCAG 2.1 AA."""
        self.page.goto("https://app.example.com/login")
        self.page.wait_for_load_state("networkidle")
        self.a11y.assert_no_violations(
            tags=["wcag2a", "wcag2aa", "wcag21aa"],
            report_name="login_wcag_aa"
        )

    def test_dashboard_wcag_aa(self):
        """Dashboard must pass WCAG 2.1 AA."""
        self.page.goto("https://app.example.com/dashboard")
        self.page.wait_for_load_state("networkidle")
        self.a11y.assert_no_violations(
            tags=["wcag2a", "wcag2aa"],
            report_name="dashboard_wcag_aa"
        )

    # ── Component-Scoped Audits ────────────────────────────────

    def test_navigation_accessibility(self):
        """Navigation component must be accessible."""
        self.page.goto("https://app.example.com")
        self.a11y.assert_no_violations(
            context="#navbar",
            tags=["wcag2aa"],
            report_name="navbar_a11y"
        )

    def test_form_accessibility(self):
        """Login form must have proper labels and ARIA."""
        self.page.goto("https://app.example.com/login")
        self.a11y.assert_no_violations(
            context="form",
            tags=["wcag2aa"],
            report_name="form_a11y"
        )

    # ── Keyboard Navigation Tests ──────────────────────────────

    def test_keyboard_tab_through_login_form(self):
        """All form elements must be reachable via Tab key."""
        self.page.goto("https://app.example.com/login")

        # Tab through form elements
        self.page.keyboard.press("Tab")
        focused = self.page.evaluate("document.activeElement.id")
        assert focused == "username", f"First Tab should focus username, got: {focused}"

        self.page.keyboard.press("Tab")
        focused = self.page.evaluate("document.activeElement.id")
        assert focused == "password", f"Second Tab should focus password, got: {focused}"

        self.page.keyboard.press("Tab")
        focused = self.page.evaluate("document.activeElement.id")
        assert focused == "loginBtn", f"Third Tab should focus login button, got: {focused}"

    def test_keyboard_submit_login_form(self):
        """Login form must be submittable via Enter key."""
        self.page.goto("https://app.example.com/login")
        self.page.fill("#username", "admin@example.com")
        self.page.fill("#password", "password123")
        self.page.keyboard.press("Enter")
        self.page.wait_for_url("**/dashboard")
        assert "dashboard" in self.page.url

    def test_escape_closes_modal(self):
        """Modals must close on Escape key press."""
        self.page.goto("https://app.example.com/dashboard")
        self.page.click("#open-modal-btn")
        self.page.wait_for_selector("#modal")
        self.page.keyboard.press("Escape")
        expect(self.page.locator("#modal")).not_to_be_visible()

    # ── Color Contrast Check ───────────────────────────────────

    def test_color_contrast(self):
        """Page must meet WCAG AA color contrast ratios."""
        self.page.goto("https://app.example.com/login")
        violations = self.a11y.get_violations(tags=["color-contrast"])

        if violations:
            for v in violations:
                for node in v.get("nodes", []):
                    print(f"Contrast issue: {node['html'][:80]}")

        assert len(violations) == 0, f"{len(violations)} color contrast violation(s) found"

    # ── ARIA Attributes Check ──────────────────────────────────

    def test_images_have_alt_text(self):
        """All images must have alt text."""
        self.page.goto("https://app.example.com")
        violations = self.a11y.get_violations(tags=["image-alt"])
        assert len(violations) == 0, "Images missing alt text"

    def test_buttons_have_accessible_names(self):
        """All buttons must have accessible names."""
        self.page.goto("https://app.example.com")
        violations = self.a11y.get_violations(tags=["button-name"])
        assert len(violations) == 0, "Buttons missing accessible names"
```

---

### 4. Run Accessibility Tests

```bash
# Run all accessibility tests
pytest tests/accessibility/ -m accessibility

# Run WCAG AA only
pytest tests/accessibility/ -m accessibility -k "wcag_aa"

# Run keyboard navigation tests
pytest tests/accessibility/ -k "keyboard"

# Generate HTML report
pytest tests/accessibility/ --html=reports/a11y_report.html

# Run across all browsers
pytest tests/accessibility/ -m accessibility --browser chromium --browser firefox
```

---

### 5. CI/CD Integration

```yaml
# Fail CI if WCAG AA violations are found
- name: Run Accessibility Tests
  run: pytest tests/accessibility/ -m accessibility --html=reports/a11y_report.html

- name: Upload A11y Report
  uses: actions/upload-artifact@v3
  if: always()
  with:
    name: accessibility-report
    path: reports/accessibility/
```

## WCAG Tags Reference

| Tag | Standard | Coverage |
|---|---|---|
| `wcag2a` | WCAG 2.0 Level A | Minimum accessibility |
| `wcag2aa` | WCAG 2.0 Level AA | Standard requirement |
| `wcag21aa` | WCAG 2.1 Level AA | Modern standard |
| `wcag22aa` | WCAG 2.2 Level AA | Latest standard |
| `best-practice` | Axe best practices | Beyond WCAG |

## Best Practices

- Run `wcag2aa` as the minimum bar for all pages
- Scope audits to components to pinpoint issues faster
- Keyboard navigation tests are as important as axe audits
- Save JSON reports per page — track regressions over time
- Run in CI on every PR — catch issues before merge

