This guide demonstrates automated accessibility testing using axe-core library injected into web pages via Playwright. This provides the most comprehensive and accurate WCAG compliance testing available.
Use Cases
- Automated WCAG 2.1 AA/AAA compliance scanning
- Comprehensive accessibility rule validation
- Real browser accessibility testing with industry-standard tools
- Detailed violation reporting with specific remediation guidance
- Large-scale accessibility auditing across entire websites
Simple Axe-Core Accessibility Testing
from locust import task
from locust_plugins.users.playwright import PlaywrightUser, PageWithRetry, pw, event
import json
class SimpleAxeAccessibilityTester(PlaywrightUser):
def on_start(self):
"""Initialize axe-core accessibility testing"""
self.accessibility_violations = []
self.pages_tested = 0
self.discovered_pages = []
@task(5)
@pw
async def test_page_accessibility_with_axe(self, page: PageWithRetry):
"""Test page accessibility using axe-core"""
if self.pages_tested == 0:
# Start with homepage and discover pages
await self._test_axe_accessibility(page, '/')
await self._discover_pages(page)
self.pages_tested += 1
elif self.discovered_pages:
# Test discovered pages
import random
page_url = random.choice(self.discovered_pages)
await self._test_axe_accessibility(page, page_url)
async def _discover_pages(self, page: PageWithRetry):
"""Discover internal pages to test"""
if self.discovered_pages:
return
# Find internal links
links = await page.locator('a[href^="/"], a[href^="./"], a[href*="' + page.url.split('/')[2] + '"]').all()
for link in links[:20]: # Limit to 20 pages
try:
href = await link.get_attribute('href')
if href and self._is_valid_page_link(href):
self.discovered_pages.append(href)
except:
pass
print(f"Discovered {len(self.discovered_pages)} pages for axe testing")
def _is_valid_page_link(self, href):
"""Check if href is a valid page link to test"""
if not href or href in self.discovered_pages:
return False
# Skip resources and external protocols
skip_patterns = ['#', 'mailto:', 'tel:', 'javascript:', '.pdf', '.jpg', '.jpeg', '.png', '.gif', '.css', '.js', '.zip']
return not any(pattern in href.lower() for pattern in skip_patterns)
async def _test_axe_accessibility(self, page: PageWithRetry, page_url: str):
"""Test accessibility using axe-core on a single page"""
current_violations = []
async with event(self, f"AXE ACCESSIBILITY: {page_url}"):
await page.goto(page_url)
# Inject axe-core library
await self._inject_axe_core(page)
# Run axe accessibility scan
axe_results = await self._run_axe_scan(page)
if axe_results and axe_results.get('violations'):
violations = axe_results['violations']
current_violations = self._process_axe_violations(page_url, violations)
# Report violations to LoadForge
violation_count = len(violations)
critical_count = len([v for v in violations if v.get('impact') == 'critical'])
serious_count = len([v for v in violations if v.get('impact') == 'serious'])
failure_msg = f"❌ {violation_count} axe violations ({critical_count} critical, {serious_count} serious)"
raise Exception(failure_msg)
else:
print(f"✅ {page_url} - No accessibility violations found")
async def _inject_axe_core(self, page: PageWithRetry):
"""Inject axe-core library into the page"""
# Load axe-core from CDN
await page.add_script_tag(url="https://cdnjs.cloudflare.com/ajax/libs/axe-core/4.8.2/axe.min.js")
# Wait for axe to be available
await page.wait_for_function("typeof axe !== 'undefined'")
async def _run_axe_scan(self, page: PageWithRetry):
"""Run axe accessibility scan on the current page"""
try:
# Configure axe to run WCAG 2.1 AA rules
axe_config = {
'runOnly': {
'type': 'tag',
'values': ['wcag2a', 'wcag2aa', 'wcag21aa']
}
}
# Run axe scan
results = await page.evaluate("""
(config) => {
return new Promise((resolve) => {
axe.run(config, (err, results) => {
if (err) {
resolve(null);
} else {
resolve(results);
}
});
});
}
""", axe_config)
return results
except Exception as e:
print(f"Error running axe scan: {str(e)}")
return None
def _process_axe_violations(self, page_url, violations):
"""Process and log axe violations"""
processed_violations = []
for violation in violations:
violation_data = {
'page': page_url,
'rule_id': violation.get('id', 'unknown'),
'impact': violation.get('impact', 'unknown'),
'description': violation.get('description', ''),
'help': violation.get('help', ''),
'help_url': violation.get('helpUrl', ''),
'nodes_count': len(violation.get('nodes', [])),
'tags': violation.get('tags', [])
}
processed_violations.append(violation_data)
self.accessibility_violations.append(violation_data)
# Log violation details
impact = violation_data['impact'].upper()
rule_id = violation_data['rule_id']
description = violation_data['description']
nodes_count = violation_data['nodes_count']
print(f"AXE VIOLATION [{impact}]: {rule_id} - {description} ({nodes_count} elements) on {page_url}")
return processed_violations
@task(1)
@pw
async def run_comprehensive_axe_scan(self, page: PageWithRetry):
"""Run comprehensive axe scan with all rules"""
async with event(self, "Comprehensive Axe Scan"):
await page.goto('/')
# Inject axe-core
await self._inject_axe_core(page)
# Run comprehensive scan with all rules
comprehensive_results = await page.evaluate("""
() => {
return new Promise((resolve) => {
// Run all axe rules
axe.run((err, results) => {
if (err) {
resolve(null);
} else {
resolve({
violations: results.violations.length,
passes: results.passes.length,
incomplete: results.incomplete.length,
inapplicable: results.inapplicable.length,
total_rules: results.violations.length + results.passes.length + results.incomplete.length + results.inapplicable.length
});
}
});
});
}
""")
if comprehensive_results:
print(f"COMPREHENSIVE AXE SCAN: {comprehensive_results['violations']} violations, "
f"{comprehensive_results['passes']} passes, "
f"{comprehensive_results['total_rules']} total rules checked")
def on_stop(self):
"""Final axe accessibility report"""
print("\n" + "="*50)
print("AXE ACCESSIBILITY TEST COMPLETE")
print("="*50)
print(f"Pages tested: {len(self.discovered_pages) + 1}")
print(f"Total axe violations: {len(self.accessibility_violations)}")
if self.accessibility_violations:
# Group violations by impact
impact_counts = {}
for violation in self.accessibility_violations:
impact = violation['impact']
impact_counts[impact] = impact_counts.get(impact, 0) + 1
print(f"\nVIOLATIONS BY IMPACT:")
for impact, count in sorted(impact_counts.items()):
print(f" {impact.upper()}: {count}")
print(f"\nTOP VIOLATIONS:")
for violation in self.accessibility_violations[:5]:
print(f"❌ [{violation['impact'].upper()}] {violation['rule_id']}: {violation['description']}")
print(f" Page: {violation['page']} ({violation['nodes_count']} elements)")
else:
print("✅ No accessibility violations found!")
Comprehensive Axe-Core Site Scanning
from locust import task
from locust_plugins.users.playwright import PlaywrightUser, PageWithRetry, pw, event
import json
class ComprehensiveAxeSiteTester(PlaywrightUser):
def on_start(self):
"""Initialize comprehensive axe site testing"""
self.all_violations = []
self.pages_scanned = set()
self.pages_to_scan = []
self.scan_summary = {
'total_pages': 0,
'total_violations': 0,
'critical_violations': 0,
'serious_violations': 0,
'moderate_violations': 0,
'minor_violations': 0
}
@task(3)
@pw
async def comprehensive_site_scan(self, page: PageWithRetry):
"""Comprehensive axe scan across entire site"""
if not self.pages_to_scan:
await self._discover_all_pages(page)
if self.pages_to_scan:
page_url = self.pages_to_scan.pop(0)
if page_url not in self.pages_scanned:
await self._comprehensive_axe_test(page, page_url)
self.pages_scanned.add(page_url)
async def _discover_all_pages(self, page: PageWithRetry):
"""Discover all internal pages for comprehensive testing"""
async with event(self, "Site Discovery"):
await page.goto('/')
# Get all internal links
links = await page.locator('a[href]').all()
for link in links[:50]: # Limit to 50 pages for comprehensive testing
try:
href = await link.get_attribute('href')
if href and self._is_internal_page(href, page.url):
normalized_url = self._normalize_url(href, page.url)
if normalized_url and normalized_url not in self.pages_to_scan:
self.pages_to_scan.append(normalized_url)
except:
pass
print(f"Discovered {len(self.pages_to_scan)} pages for comprehensive axe scanning")
def _is_internal_page(self, href, base_url):
"""Check if URL is internal page"""
if not href:
return False
# Skip anchors, external protocols, and resources
skip_patterns = ['#', 'mailto:', 'tel:', 'javascript:', '.pdf', '.jpg', '.jpeg', '.png', '.gif', '.css', '.js', '.zip', '.doc', '.xml']
if any(pattern in href.lower() for pattern in skip_patterns):
return False
# Check if internal
if href.startswith('/'):
return True
elif href.startswith('http'):
base_domain = base_url.split('/')[2]
return base_domain in href
else:
return True # Relative links
def _normalize_url(self, href, base_url):
"""Normalize URL for consistency"""
if href.startswith('/'):
return href
elif href.startswith('http'):
base_domain = base_url.split('/')[2]
if base_domain in href:
return '/' + href.split(base_domain, 1)[1].lstrip('/')
else:
# Relative URL
return '/' + href.lstrip('./')
return None
async def _comprehensive_axe_test(self, page: PageWithRetry, page_url: str):
"""Comprehensive axe test on a single page"""
async with event(self, f"COMPREHENSIVE AXE: {page_url}"):
await page.goto(page_url)
# Inject axe-core
await self._inject_axe_core(page)
# Run comprehensive axe scan with all rule sets
axe_results = await self._run_comprehensive_axe_scan(page)
if axe_results:
self._process_comprehensive_results(page_url, axe_results)
violations = axe_results.get('violations', [])
if violations:
critical = len([v for v in violations if v.get('impact') == 'critical'])
serious = len([v for v in violations if v.get('impact') == 'serious'])
failure_msg = f"❌ {len(violations)} axe violations ({critical} critical, {serious} serious)"
raise Exception(failure_msg)
else:
print(f"✅ {page_url} - No violations found")
async def _inject_axe_core(self, page: PageWithRetry):
"""Inject axe-core library"""
await page.add_script_tag(url="https://cdnjs.cloudflare.com/ajax/libs/axe-core/4.8.2/axe.min.js")
await page.wait_for_function("typeof axe !== 'undefined'")
async def _run_comprehensive_axe_scan(self, page: PageWithRetry):
"""Run comprehensive axe scan with all rules"""
try:
# Configure comprehensive scan
axe_config = {
'runOnly': {
'type': 'tag',
'values': ['wcag2a', 'wcag2aa', 'wcag21aa', 'wcag22aa', 'best-practice', 'experimental']
}
}
results = await page.evaluate("""
(config) => {
return new Promise((resolve) => {
axe.run(config, (err, results) => {
if (err) {
resolve(null);
} else {
resolve(results);
}
});
});
}
""", axe_config)
return results
except Exception as e:
print(f"Error running comprehensive axe scan: {str(e)}")
return None
def _process_comprehensive_results(self, page_url, results):
"""Process comprehensive axe results"""
violations = results.get('violations', [])
self.scan_summary['total_pages'] += 1
self.scan_summary['total_violations'] += len(violations)
for violation in violations:
impact = violation.get('impact', 'unknown')
# Count by impact
if impact == 'critical':
self.scan_summary['critical_violations'] += 1
elif impact == 'serious':
self.scan_summary['serious_violations'] += 1
elif impact == 'moderate':
self.scan_summary['moderate_violations'] += 1
elif impact == 'minor':
self.scan_summary['minor_violations'] += 1
# Store detailed violation
violation_detail = {
'page': page_url,
'rule_id': violation.get('id'),
'impact': impact,
'description': violation.get('description'),
'help': violation.get('help'),
'help_url': violation.get('helpUrl'),
'tags': violation.get('tags', []),
'nodes': len(violation.get('nodes', []))
}
self.all_violations.append(violation_detail)
# Log violation
print(f"AXE [{impact.upper()}]: {violation_detail['rule_id']} - {violation_detail['description']} "
f"({violation_detail['nodes']} elements) on {page_url}")
@task(1)
@pw
async def generate_accessibility_audit_report(self, page: PageWithRetry):
"""Generate comprehensive accessibility audit report"""
if self.scan_summary['total_pages'] < 3:
return
async with event(self, "Accessibility Audit Report"):
# Calculate compliance score
total_possible_score = self.scan_summary['total_pages'] * 100
penalty_score = (
self.scan_summary['critical_violations'] * 10 +
self.scan_summary['serious_violations'] * 5 +
self.scan_summary['moderate_violations'] * 2 +
self.scan_summary['minor_violations'] * 1
)
compliance_score = max(0, 100 - (penalty_score / total_possible_score * 100))
print(f"\nACCESSIBILITY AUDIT REPORT:")
print(f"Pages scanned: {self.scan_summary['total_pages']}")
print(f"Total violations: {self.scan_summary['total_violations']}")
print(f"Critical: {self.scan_summary['critical_violations']}")
print(f"Serious: {self.scan_summary['serious_violations']}")
print(f"Moderate: {self.scan_summary['moderate_violations']}")
print(f"Minor: {self.scan_summary['minor_violations']}")
print(f"Compliance Score: {compliance_score:.1f}/100")
def on_stop(self):
"""Final comprehensive report"""
print("\n" + "="*60)
print("COMPREHENSIVE AXE ACCESSIBILITY AUDIT COMPLETE")
print("="*60)
print(f"Total pages scanned: {len(self.pages_scanned)}")
print(f"Total violations found: {len(self.all_violations)}")
if self.all_violations:
# Top violation types
rule_counts = {}
for violation in self.all_violations:
rule_id = violation['rule_id']
rule_counts[rule_id] = rule_counts.get(rule_id, 0) + 1
print("\nTOP VIOLATION TYPES:")
sorted_rules = sorted(rule_counts.items(), key=lambda x: x[1], reverse=True)
for rule_id, count in sorted_rules[:10]:
print(f" {rule_id}: {count} violations")
# Most problematic pages
page_counts = {}
for violation in self.all_violations:
page = violation['page']
page_counts[page] = page_counts.get(page, 0) + 1
print("\nMOST PROBLEMATIC PAGES:")
sorted_pages = sorted(page_counts.items(), key=lambda x: x[1], reverse=True)
for page, count in sorted_pages[:5]:
print(f" {page}: {count} violations")
else:
print("✅ No accessibility violations found across entire site!")
Key Axe-Core Features
- Industry Standard: Uses axe-core, the same engine used by accessibility professionals
- Comprehensive WCAG Coverage: Tests WCAG 2.1 AA/AAA compliance automatically
- Detailed Violation Reports: Specific rule violations with remediation guidance
- Real Browser Testing: Accurate results from actual browser rendering
- Scalable Site Auditing: Can scan entire websites automatically
- LoadForge Integration: Violations show as failures in LoadForge reports
- Professional Reporting: Impact levels (critical, serious, moderate, minor)
This axe-core integration provides enterprise-grade accessibility testing that matches tools used by accessibility consultants and compliance teams.