Python Accessibility Testing — Deep Dive

A production accessibility testing pipeline goes beyond running axe-core on a homepage. It needs to handle single-page apps with dynamic content, test authenticated flows, track compliance scores over time, and integrate with design systems.

axe-core Configuration Deep Dive

axe-core is configurable. You can target specific WCAG levels, exclude known issues, and add custom rules.

Running with Specific WCAG Tags

from playwright.sync_api import sync_playwright
from axe_playwright_python.sync_playwright import Axe

def test_wcag_aa_compliance():
    with sync_playwright() as p:
        browser = p.chromium.launch()
        page = browser.new_page()
        page.goto("https://yourapp.com/dashboard")

        axe = Axe()
        results = axe.run(page, context=None, options={
            "runOnly": {
                "type": "tag",
                "values": ["wcag2a", "wcag2aa", "wcag22aa"]
            }
        })

        serious = [v for v in results.response["violations"]
                   if v["impact"] in ("critical", "serious")]
        assert len(serious) == 0

        browser.close()

WCAG tags in axe-core:

  • wcag2a — Level A (minimum)
  • wcag2aa — Level AA (standard target)
  • wcag2aaa — Level AAA (enhanced)
  • wcag22aa — WCAG 2.2 additions
  • best-practice — Not WCAG but recommended

Excluding Known Issues

During remediation, you may need to exclude elements being actively fixed:

results = axe.run(page, context={
    "exclude": [
        ["#legacy-widget"],
        [".third-party-embed"]
    ]
}, options={
    "rules": {
        "color-contrast": {"enabled": True},
        "region": {"enabled": False}  # Disable specific rule
    }
})

Testing Single-Page Applications

SPAs change the DOM after initial load. You need to wait for state changes before running axe.

def test_spa_navigation_accessibility():
    with sync_playwright() as p:
        browser = p.chromium.launch()
        page = browser.new_page()
        page.goto("https://yourapp.com")

        axe = Axe()

        # Test initial state
        results_home = axe.run(page)
        assert_no_critical(results_home)

        # Navigate via SPA router
        page.click("text=Settings")
        page.wait_for_selector("[data-page='settings']")

        # Test new state
        results_settings = axe.run(page)
        assert_no_critical(results_settings)

        # Test modal dialog
        page.click("text=Delete Account")
        page.wait_for_selector("[role='dialog']")

        # Scope axe to just the modal
        results_modal = axe.run(page, context={"include": [["[role='dialog']"]]})
        assert_no_critical(results_modal)

        browser.close()

def assert_no_critical(results):
    critical = [v for v in results.response["violations"]
                if v["impact"] in ("critical", "serious")]
    if critical:
        report = []
        for v in critical:
            report.append(f"{v['impact']}: {v['id']} - {v['description']}")
            for node in v["nodes"][:2]:
                report.append(f"  {node['html'][:120]}")
        raise AssertionError("\n".join(report))

Testing Authenticated Routes

import pytest
from playwright.sync_api import sync_playwright

@pytest.fixture(scope="session")
def authenticated_context():
    with sync_playwright() as p:
        browser = p.chromium.launch()
        context = browser.new_context()
        page = context.new_page()

        # Log in once
        page.goto("https://yourapp.com/login")
        page.fill("#email", "test@example.com")
        page.fill("#password", "testpass123")
        page.click("button[type='submit']")
        page.wait_for_url("**/dashboard")

        # Save auth state
        storage = context.storage_state()
        page.close()
        context.close()

        # Create new context with saved cookies
        auth_context = browser.new_context(storage_state=storage)
        yield auth_context
        auth_context.close()
        browser.close()

def test_dashboard_accessibility(authenticated_context):
    page = authenticated_context.new_page()
    page.goto("https://yourapp.com/dashboard")
    axe = Axe()
    results = axe.run(page)
    assert len(results.response["violations"]) == 0
    page.close()

Keyboard Navigation Testing

axe-core checks static ARIA attributes but doesn’t simulate keyboard interaction. Use Playwright for that:

def test_keyboard_navigation():
    with sync_playwright() as p:
        browser = p.chromium.launch()
        page = browser.new_page()
        page.goto("https://yourapp.com")

        # Tab through interactive elements
        focused_elements = []
        for _ in range(20):
            page.keyboard.press("Tab")
            tag = page.evaluate("""
                () => {
                    const el = document.activeElement;
                    return {
                        tag: el.tagName,
                        role: el.getAttribute('role'),
                        text: el.textContent?.trim().slice(0, 50),
                        visible: el.offsetParent !== null
                    }
                }
            """)
            focused_elements.append(tag)

        # Verify: all focused elements should be visible
        invisible = [e for e in focused_elements if not e["visible"]]
        assert len(invisible) == 0, f"Focus reached invisible elements: {invisible}"

        # Verify: focus never gets trapped (unless in a modal)
        # If we Tab 20 times, we should move through different elements
        unique_texts = set(e["text"] for e in focused_elements if e["text"])
        assert len(unique_texts) > 3, "Focus appears trapped"

        browser.close()

Color Contrast Analysis

Beyond axe-core’s basic contrast check, you can extract computed colors for detailed analysis:

def check_contrast_ratio(page, selector):
    """Extract foreground/background colors and compute WCAG contrast ratio."""
    colors = page.evaluate(f"""
        () => {{
            const el = document.querySelector('{selector}');
            const style = window.getComputedStyle(el);
            return {{
                color: style.color,
                background: style.backgroundColor,
                fontSize: parseFloat(style.fontSize),
                fontWeight: style.fontWeight
            }};
        }}
    """)

    fg = parse_rgb(colors["color"])
    bg = parse_rgb(colors["background"])
    ratio = contrast_ratio(fg, bg)

    # WCAG AA: 4.5:1 for normal text, 3:1 for large text (>=18pt or >=14pt bold)
    is_large = (colors["fontSize"] >= 24 or
                (colors["fontSize"] >= 18.66 and int(colors["fontWeight"]) >= 700))
    threshold = 3.0 if is_large else 4.5

    return ratio, threshold, ratio >= threshold

def relative_luminance(rgb):
    """WCAG 2.0 relative luminance formula."""
    r, g, b = [c / 255.0 for c in rgb]
    r = r / 12.92 if r <= 0.03928 else ((r + 0.055) / 1.055) ** 2.4
    g = g / 12.92 if g <= 0.03928 else ((g + 0.055) / 1.055) ** 2.4
    b = b / 12.92 if b <= 0.03928 else ((b + 0.055) / 1.055) ** 2.4
    return 0.2126 * r + 0.7152 * g + 0.0722 * b

def contrast_ratio(fg, bg):
    l1 = relative_luminance(fg) + 0.05
    l2 = relative_luminance(bg) + 0.05
    return max(l1, l2) / min(l1, l2)

def parse_rgb(css_color):
    """Parse 'rgb(r, g, b)' to tuple."""
    nums = css_color.replace("rgb(", "").replace(")", "").split(",")
    return tuple(int(n.strip()) for n in nums)

Building an Accessibility Scorecard

Track compliance over time with structured reporting:

import json
from datetime import datetime
from pathlib import Path

def generate_a11y_report(page_results: dict, output_dir: str = "reports"):
    """Generate a structured accessibility report from axe results."""
    Path(output_dir).mkdir(exist_ok=True)

    summary = {
        "timestamp": datetime.utcnow().isoformat(),
        "pages": {}
    }

    for url, results in page_results.items():
        violations = results.response["violations"]
        summary["pages"][url] = {
            "total_violations": len(violations),
            "by_impact": {
                "critical": len([v for v in violations if v["impact"] == "critical"]),
                "serious": len([v for v in violations if v["impact"] == "serious"]),
                "moderate": len([v for v in violations if v["impact"] == "moderate"]),
                "minor": len([v for v in violations if v["impact"] == "minor"]),
            },
            "rules_violated": [v["id"] for v in violations],
            "passes": len(results.response.get("passes", [])),
        }

    # Compute overall score (0-100)
    total_violations = sum(p["total_violations"] for p in summary["pages"].values())
    total_passes = sum(p["passes"] for p in summary["pages"].values())
    total_checks = total_violations + total_passes
    summary["score"] = round((total_passes / total_checks * 100) if total_checks > 0 else 0, 1)

    report_path = Path(output_dir) / f"a11y-{datetime.utcnow():%Y%m%d-%H%M%S}.json"
    report_path.write_text(json.dumps(summary, indent=2))

    return summary

CI Pipeline with Thresholds

# GitHub Actions
accessibility-audit:
  runs-on: ubuntu-latest
  steps:
    - uses: actions/checkout@v4
    - name: Install dependencies
      run: |
        pip install playwright axe-playwright-python pytest
        playwright install chromium --with-deps
    - name: Start app
      run: |
        docker compose up -d
        sleep 10
    - name: Run a11y tests
      run: pytest tests/accessibility/ -v --tb=short --junitxml=a11y-results.xml
    - name: Upload report
      if: always()
      uses: actions/upload-artifact@v4
      with:
        name: accessibility-report
        path: reports/

WCAG 2.2 Specific Checks

WCAG 2.2 added new success criteria that require targeted testing:

Focus Not Obscured (2.4.11)

def test_focus_not_obscured():
    """Verify focused elements are not hidden behind sticky headers/footers."""
    with sync_playwright() as p:
        browser = p.chromium.launch()
        page = browser.new_page()
        page.goto("https://yourapp.com")

        # Tab to each interactive element
        for _ in range(15):
            page.keyboard.press("Tab")
            is_visible = page.evaluate("""
                () => {
                    const el = document.activeElement;
                    const rect = el.getBoundingClientRect();
                    // Check if element is within viewport
                    return rect.top >= 0 &&
                           rect.bottom <= window.innerHeight &&
                           rect.left >= 0 &&
                           rect.right <= window.innerWidth;
                }
            """)
            if not is_visible:
                tag_info = page.evaluate("() => document.activeElement.outerHTML.slice(0, 100)")
                raise AssertionError(f"Focused element obscured: {tag_info}")

        browser.close()

Target Size (2.5.8)

def test_minimum_target_size():
    """WCAG 2.5.8: Interactive targets should be at least 24x24 CSS pixels."""
    page.evaluate("""
        () => {
            const issues = [];
            const interactive = document.querySelectorAll(
                'a, button, input, select, textarea, [role="button"], [role="link"]'
            );
            for (const el of interactive) {
                const rect = el.getBoundingClientRect();
                if (rect.width > 0 && rect.height > 0 &&
                    (rect.width < 24 || rect.height < 24)) {
                    issues.push({
                        html: el.outerHTML.slice(0, 80),
                        width: rect.width,
                        height: rect.height
                    });
                }
            }
            return issues;
        }
    """)

Combining Automated + Manual in a Test Plan

A complete accessibility test plan:

  1. Automated (CI, every PR): axe-core via Playwright covering all routes
  2. Semi-automated (weekly): Keyboard navigation tests, focus management
  3. Manual (per release): Screen reader testing (NVDA on Windows, VoiceOver on macOS)
  4. User testing (quarterly): Sessions with actual disabled users

Python handles layers 1 and 2 entirely. For layer 3, Python can generate test scripts that guide manual testers through each page.

The one thing to remember: Production accessibility testing combines axe-core automation in CI, keyboard navigation scripts, WCAG 2.2 checks for focus and target size, and structured scorecards — but always complement with manual screen reader testing for the issues automation can’t detect.

pythonaccessibilitya11ytestingWCAGaxe-coreCI/CDproduction

See Also

  • Python Headless Browser Testing How Python tests websites using invisible browsers that click buttons and fill forms without anyone watching — explained for beginners.
  • Ci Cd Why big apps can ship updates every day without turning your phone into a glitchy mess — CI/CD is the behind-the-scenes quality gate and delivery truck.
  • Containerization Why does software that works on your computer break on everyone else's? Containers fix that — and they're why Netflix can deploy 100 updates a day without the site going down.
  • Python 310 New Features Python 3.10 gave programmers a shape-sorting machine, friendlier error messages, and cleaner ways to say 'this or that' in type hints.
  • Python 311 New Features Python 3.11 made everything faster, error messages smarter, and let you catch several mistakes at once instead of stopping at the first one.