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
pip install playwright==1.36.0 pytest-playwright==0.3.0 axe-playwright-python==0.1.3
2. Accessibility Helper
utils/accessibility.py
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
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
# 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
# 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
wcag2aaas 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