Browser Accessibility Testing

Advanced WCAG compliance and accessibility testing using Playwright browser automation

LoadForge can record your browser, graphically build tests, scan your site with a wizard and more. Sign up now to run your first test.

Sign up now


This guide demonstrates advanced accessibility testing using Playwright browser automation in LoadForge. Test real user interactions, dynamic content, and complex accessibility scenarios that HTML parsing cannot detect.

Use Cases

  • Testing keyboard navigation and focus management
  • Validating ARIA live regions and dynamic content
  • Real color contrast analysis and visual accessibility
  • Screen reader compatibility testing
  • Form accessibility with dynamic validation
  • Complex user journey accessibility flows

Simple Browser Accessibility Testing

from locust import task
from locust_plugins.users.playwright import PlaywrightUser, PageWithRetry, pw, event
import time

class SimpleAccessibilityTester(PlaywrightUser):
    
    def on_start(self):
        """Initialize accessibility testing"""
        self.accessibility_issues = []
        self.pages_tested = 0
        
    @task(5)
    @pw
    async def test_keyboard_navigation(self, page: PageWithRetry):
        """Test keyboard navigation and focus management"""
        if self.pages_tested == 0:
            # Start with homepage and discover pages
            await self._test_page_keyboard_accessibility(page, '/')
            await self._discover_pages(page)
            self.pages_tested += 1
        elif hasattr(self, 'discovered_pages') and self.discovered_pages:
            # Test discovered pages
            import random
            page_url = random.choice(self.discovered_pages)
            await self._test_page_keyboard_accessibility(page, page_url)
    
    async def _discover_pages(self, page: PageWithRetry):
        """Discover internal pages to test"""
        if hasattr(self, 'discovered_pages'):
            return
            
        self.discovered_pages = []
        
        # Find internal links on homepage
        links = await page.locator('a[href^="/"], a[href^="./"]').all()
        
        for link in links[:10]:  # Limit to 10 pages for simple testing
            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 accessibility 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 anchors, resources, external protocols
        skip_patterns = ['#', 'mailto:', 'tel:', 'javascript:', '.pdf', '.jpg', '.png', '.css', '.js']
        return not any(pattern in href.lower() for pattern in skip_patterns)

    async def _test_page_keyboard_accessibility(self, page: PageWithRetry, page_url: str):
        """Test keyboard accessibility on a single page"""
        current_issues = []
        
        async with event(self, f"ACCESSIBILITY: {page_url}"):
            await page.goto(page_url)
            
            # Test 1: Check for skip links
            skip_links = await page.locator('a[href^="#"]:has-text("skip")').count()
            if skip_links == 0:
                current_issues.append("Missing skip navigation links")
            
            # Test 2: Test tab navigation through interactive elements
            interactive_elements = await page.locator('a, button, input, select, textarea').count()
            
            if interactive_elements > 0:
                # Focus first interactive element
                await page.keyboard.press('Tab')
                
                # Test if focus is visible
                focused_element = await page.locator(':focus').count()
                if focused_element == 0:
                    current_issues.append("No visible focus indicators")
                
                # Test tab navigation through first 5 elements
                for i in range(min(4, interactive_elements - 1)):
                    await page.keyboard.press('Tab')
                    
                    # Check if focus is trapped properly
                    focused = await page.locator(':focus').count()
                    if focused == 0:
                        current_issues.append("Focus lost during tab navigation")
                        break
            
            # Test 3: Test heading structure
            h1_count = await page.locator('h1').count()
            if h1_count == 0:
                current_issues.append("Page missing H1 heading")
            elif h1_count > 1:
                current_issues.append("Multiple H1 headings found")
            
            # Test 4: Check for images without alt text
            images_without_alt = await page.locator('img:not([alt])').count()
            if images_without_alt > 0:
                current_issues.append(f"{images_without_alt} images missing alt text")
            
            # Test 5: Check for empty links
            empty_links = await page.locator('a:empty').count()
            if empty_links > 0:
                current_issues.append(f"{empty_links} empty links found")
            
            # Test 6: Test form labels
            inputs_without_labels = await page.locator('input:not([type="hidden"]):not([aria-label]):not([aria-labelledby])').count()
            labels_for_inputs = await page.locator('label[for]').count()
            
            if inputs_without_labels > labels_for_inputs:
                current_issues.append("Form inputs missing proper labels")
        
        # Record results
        self.accessibility_issues.extend(current_issues)
        
        # Report to LoadForge
        if current_issues:
            # This will show as failed in LoadForge
            raise Exception(f"❌ {len(current_issues)} accessibility issues: {'; '.join(current_issues[:2])}")
        
        print(f"✅ {page_url} - No accessibility issues found")

    @task(2)
    @pw
    async def test_color_contrast(self, page: PageWithRetry):
        """Test color contrast by analyzing page elements"""
        async with event(self, "Color Contrast Analysis"):
            await page.goto('/')
            
            # Get computed styles for text elements
            text_elements = await page.locator('p, h1, h2, h3, h4, h5, h6, a, span, div').all()
            
            contrast_issues = 0
            for element in text_elements[:10]:  # Test first 10 elements
                try:
                    # Get computed styles
                    color = await element.evaluate('el => getComputedStyle(el).color')
                    bg_color = await element.evaluate('el => getComputedStyle(el).backgroundColor')
                    
                    # Basic contrast check (simplified)
                    if 'rgba(0, 0, 0, 0)' in bg_color and 'rgb(255, 255, 255)' in color:
                        contrast_issues += 1
                except:
                    pass
            
            if contrast_issues > 3:
                raise Exception(f"❌ Potential color contrast issues detected ({contrast_issues} elements)")

    @task(1)
    @pw
    async def test_dynamic_content_accessibility(self, page: PageWithRetry):
        """Test accessibility of dynamic content and ARIA live regions"""
        async with event(self, "Dynamic Content Accessibility"):
            await page.goto('/')
            
            # Look for interactive elements that might show dynamic content
            buttons = await page.locator('button, [role="button"]').all()
            
            for button in buttons[:3]:  # Test first 3 buttons
                try:
                    # Click button to trigger dynamic content
                    await button.click()
                    
                    # Wait for potential dynamic content
                    await page.wait_for_timeout(1000)
                    
                    # Check for ARIA live regions
                    live_regions = await page.locator('[aria-live]').count()
                    if live_regions == 0:
                        # Check if content changed without live region
                        modals = await page.locator('[role="dialog"], .modal').count()
                        if modals > 0:
                            # Modal should have proper focus management
                            focused_in_modal = await page.locator('.modal :focus, [role="dialog"] :focus').count()
                            if focused_in_modal == 0:
                                raise Exception("❌ Modal opened without proper focus management")
                    
                    # Close any modals
                    close_buttons = await page.locator('[aria-label*="close"], .close, [data-dismiss]').all()
                    for close_btn in close_buttons:
                        try:
                            await close_btn.click()
                            break
                        except:
                            pass
                            
                except Exception as e:
                    if "accessibility" in str(e).lower():
                        raise e
                    pass

    def on_stop(self):
        """Final accessibility report"""
        print("\n" + "="*50)
        print("BROWSER ACCESSIBILITY TEST COMPLETE")
        print("="*50)
        print(f"Pages tested: {self.pages_tested}")
        print(f"Total accessibility issues: {len(self.accessibility_issues)}")
        
        if self.accessibility_issues:
            print("\nACCESSIBILITY ISSUES FOUND:")
            for issue in self.accessibility_issues[:10]:
                print(f"❌ {issue}")
        else:
            print("✅ No accessibility issues detected!")

Comprehensive WCAG Compliance Testing

from locust import task
from locust_plugins.users.playwright import PlaywrightUser, PageWithRetry, pw, event
import json

class WCAGComplianceTester(PlaywrightUser):
    
    def on_start(self):
        """Initialize WCAG compliance testing"""
        self.wcag_violations = []
        self.pages_crawled = []
        self.focus_issues = []
        
    @task(3)
    @pw
    async def comprehensive_wcag_test(self, page: PageWithRetry):
        """Comprehensive WCAG 2.1 AA compliance testing"""
        # Discover pages to test
        if not self.pages_crawled:
            await self._discover_pages(page)
        
        # Test random page from discovered pages
        if self.pages_crawled:
            import random
            page_url = random.choice(self.pages_crawled)
            await self._test_wcag_compliance(page, page_url)
    
    async def _discover_pages(self, page: PageWithRetry):
        """Discover internal pages to test"""
        async with event(self, "Page Discovery"):
            await page.goto('/')
            
            # Find internal links
            links = await page.locator('a[href^="/"], a[href^="./"]').all()
            
            for link in links[:20]:  # Limit to 20 pages
                try:
                    href = await link.get_attribute('href')
                    if href and href not in self.pages_crawled:
                        self.pages_crawled.append(href)
                except:
                    pass
    
    async def _test_wcag_compliance(self, page: PageWithRetry, page_url: str):
        """Test WCAG compliance on a single page"""
        violations = []
        
        async with event(self, f"WCAG Test: {page_url}"):
            await page.goto(page_url)
            
            # WCAG 1.1.1 - Non-text Content
            decorative_images = await page.locator('img[alt=""]').count()
            images_without_alt = await page.locator('img:not([alt])').count()
            
            if images_without_alt > 0:
                violations.append(f"WCAG 1.1.1: {images_without_alt} images missing alt text")
            
            # WCAG 1.3.1 - Info and Relationships
            tables_without_headers = await page.locator('table:not(:has(th))').count()
            if tables_without_headers > 0:
                violations.append(f"WCAG 1.3.1: {tables_without_headers} tables missing headers")
            
            # WCAG 1.4.3 - Contrast (Minimum)
            await self._test_contrast_compliance(page, violations)
            
            # WCAG 2.1.1 - Keyboard Navigation
            await self._test_keyboard_compliance(page, violations)
            
            # WCAG 2.4.1 - Bypass Blocks
            skip_links = await page.locator('a[href^="#"]:has-text("skip")').count()
            if skip_links == 0:
                violations.append("WCAG 2.4.1: Missing skip navigation links")
            
            # WCAG 2.4.6 - Headings and Labels
            empty_headings = await page.locator('h1:empty, h2:empty, h3:empty, h4:empty, h5:empty, h6:empty').count()
            if empty_headings > 0:
                violations.append(f"WCAG 2.4.6: {empty_headings} empty headings")
            
            # WCAG 3.3.2 - Labels or Instructions
            unlabeled_inputs = await page.locator('input:not([type="hidden"]):not([aria-label]):not([aria-labelledby])').count()
            labels = await page.locator('label[for]').count()
            
            if unlabeled_inputs > labels:
                violations.append("WCAG 3.3.2: Form inputs missing proper labels")
            
            # WCAG 4.1.2 - Name, Role, Value
            await self._test_aria_compliance(page, violations)
        
        # Record violations
        self.wcag_violations.extend(violations)
        
        # Report results
        if violations:
            violation_summary = f"❌ {len(violations)} WCAG violations"
            raise Exception(violation_summary)
        
        print(f"✅ WCAG Compliant: {page_url}")
    
    async def _test_contrast_compliance(self, page: PageWithRetry, violations):
        """Test color contrast compliance"""
        # Test common elements for contrast
        elements_to_test = ['a', 'button', 'p', 'h1', 'h2', 'h3']
        
        for selector in elements_to_test:
            elements = await page.locator(selector).all()
            
            for element in elements[:3]:  # Test first 3 of each type
                try:
                    # Get computed styles
                    color = await element.evaluate('el => getComputedStyle(el).color')
                    bg_color = await element.evaluate('el => getComputedStyle(el).backgroundColor')
                    
                    # Simplified contrast check
                    if 'rgba(0, 0, 0, 0)' in bg_color and 'rgb(255, 255, 255)' in color:
                        violations.append(f"WCAG 1.4.3: Potential low contrast on {selector}")
                        break
                except:
                    pass
    
    async def _test_keyboard_compliance(self, page: PageWithRetry, violations):
        """Test keyboard navigation compliance"""
        # Test tab navigation
        interactive_elements = await page.locator('a, button, input, select, textarea').count()
        
        if interactive_elements > 0:
            # Start tab navigation
            await page.keyboard.press('Tab')
            
            # Test focus visibility
            focused_element = await page.locator(':focus').count()
            if focused_element == 0:
                violations.append("WCAG 2.1.1: No visible focus indicators")
            
            # Test tab order
            for i in range(min(5, interactive_elements - 1)):
                await page.keyboard.press('Tab')
                
                # Check if focus is still visible
                focused = await page.locator(':focus').count()
                if focused == 0:
                    violations.append("WCAG 2.1.1: Focus lost during keyboard navigation")
                    break
    
    async def _test_aria_compliance(self, page: PageWithRetry, violations):
        """Test ARIA implementation compliance"""
        # Test for invalid ARIA attributes
        elements_with_aria = await page.locator('[aria-label], [aria-labelledby], [aria-describedby]').all()
        
        for element in elements_with_aria[:5]:  # Test first 5
            try:
                # Check if aria-labelledby references exist
                labelledby = await element.get_attribute('aria-labelledby')
                if labelledby:
                    referenced_element = await page.locator(f'#{labelledby}').count()
                    if referenced_element == 0:
                        violations.append(f"WCAG 4.1.2: aria-labelledby references non-existent element")
                
                # Check if aria-describedby references exist
                describedby = await element.get_attribute('aria-describedby')
                if describedby:
                    referenced_element = await page.locator(f'#{describedby}').count()
                    if referenced_element == 0:
                        violations.append(f"WCAG 4.1.2: aria-describedby references non-existent element")
            except:
                pass

    @task(1)
    @pw
    async def test_form_accessibility_flow(self, page: PageWithRetry):
        """Test complete form accessibility flow"""
        async with event(self, "Form Accessibility Flow"):
            await page.goto('/')
            
            # Find forms on the page
            forms = await page.locator('form').all()
            
            for form in forms[:2]:  # Test first 2 forms
                try:
                    # Test form field accessibility
                    inputs = await form.locator('input:not([type="hidden"]), textarea, select').all()
                    
                    for input_element in inputs:
                        # Test if input has proper labeling
                        aria_label = await input_element.get_attribute('aria-label')
                        aria_labelledby = await input_element.get_attribute('aria-labelledby')
                        input_id = await input_element.get_attribute('id')
                        
                        has_label = False
                        if aria_label or aria_labelledby:
                            has_label = True
                        elif input_id:
                            label_for_input = await page.locator(f'label[for="{input_id}"]').count()
                            if label_for_input > 0:
                                has_label = True
                        
                        if not has_label:
                            raise Exception("❌ Form input missing accessible label")
                        
                        # Test focus management
                        await input_element.click()
                        focused = await page.locator(':focus').count()
                        if focused == 0:
                            raise Exception("❌ Form input not focusable")
                
                except Exception as e:
                    if "accessibility" in str(e).lower():
                        raise e
                    pass

    def on_stop(self):
        """Final WCAG compliance report"""
        print("\n" + "="*50)
        print("WCAG COMPLIANCE TEST COMPLETE")
        print("="*50)
        print(f"Pages tested: {len(self.pages_crawled)}")
        print(f"WCAG violations found: {len(self.wcag_violations)}")
        
        if self.wcag_violations:
            print("\nWCAG VIOLATIONS:")
            for violation in self.wcag_violations[:10]:
                print(f"❌ {violation}")
        else:
            print("✅ WCAG 2.1 AA Compliant!")

Advanced Screen Reader Simulation

from locust import task
from locust_plugins.users.playwright import PlaywrightUser, PageWithRetry, pw, event

class ScreenReaderSimulation(PlaywrightUser):
    
    @task(2)
    @pw
    async def simulate_screen_reader_navigation(self, page: PageWithRetry):
        """Simulate screen reader navigation through page content"""
        async with event(self, "Screen Reader Simulation"):
            await page.goto('/')
            
            # Test heading navigation (screen readers jump between headings)
            headings = await page.locator('h1, h2, h3, h4, h5, h6').all()
            
            if not headings:
                raise Exception("❌ No headings found for screen reader navigation")
            
            # Simulate screen reader jumping through headings
            for heading in headings:
                try:
                    # Focus on heading
                    await heading.focus()
                    
                    # Get heading text
                    heading_text = await heading.text_content()
                    
                    # Check if heading is meaningful
                    if not heading_text or heading_text.strip() == "":
                        raise Exception("❌ Empty heading found - poor screen reader experience")
                    
                    # Check heading level
                    tag_name = await heading.evaluate('el => el.tagName.toLowerCase()')
                    
                except Exception as e:
                    if "screen reader" in str(e).lower():
                        raise e
                    pass
            
            # Test landmark navigation
            landmarks = await page.locator('[role="main"], [role="navigation"], [role="banner"], [role="contentinfo"], main, nav, header, footer').count()
            
            if landmarks < 2:
                raise Exception("❌ Insufficient landmarks for screen reader navigation")

    @task(1)
    @pw
    async def test_aria_live_regions(self, page: PageWithRetry):
        """Test ARIA live regions for dynamic content announcements"""
        async with event(self, "ARIA Live Regions Test"):
            await page.goto('/')
            
            # Look for buttons that might trigger dynamic content
            trigger_buttons = await page.locator('button, [role="button"]').all()
            
            for button in trigger_buttons[:3]:
                try:
                    # Click button
                    await button.click()
                    
                    # Wait for dynamic content
                    await page.wait_for_timeout(1000)
                    
                    # Check for ARIA live regions
                    live_regions = await page.locator('[aria-live="polite"], [aria-live="assertive"]').count()
                    
                    # Check for dynamic content without live regions
                    dynamic_content = await page.locator('.alert, .notification, .toast, .message').count()
                    
                    if dynamic_content > 0 and live_regions == 0:
                        raise Exception("❌ Dynamic content without ARIA live regions")
                    
                    # Test that live region content is meaningful 
                    if live_regions > 0:
                        live_region_text = await page.locator('[aria-live]').first.text_content()
                        if not live_region_text or live_region_text.strip() == "":
                            raise Exception("❌ Empty ARIA live region")
                    
                except Exception as e:
                    if "ARIA" in str(e) or "live region" in str(e).lower():
                        raise e
                    pass

Key Advanced Features

  1. Real Browser Interaction: Tests actual focus management and keyboard navigation
  2. Dynamic Content Testing: Validates ARIA live regions and modal accessibility
  3. Visual Analysis: Real color contrast and visibility testing
  4. WCAG 2.1 Compliance: Comprehensive testing against specific WCAG criteria
  5. Screen Reader Simulation: Tests heading and landmark navigation
  6. Form Flow Testing: Complete accessible form interaction testing
  7. LoadForge Integration: Failed accessibility tests show as red in reports

This advanced browser-based testing catches accessibility issues that HTML parsing alone cannot detect, providing comprehensive WCAG compliance validation through real user interaction simulation.

Ready to run your test?
Run your test today with LoadForge.