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 additionsbest-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:
- Automated (CI, every PR): axe-core via Playwright covering all routes
- Semi-automated (weekly): Keyboard navigation tests, focus management
- Manual (per release): Screen reader testing (NVDA on Windows, VoiceOver on macOS)
- 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.
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.