accessibility 17 min read

Inclusive by Default: Building Accessibility into Your Selenium Test Automation

Learn how to build test automation where accessibility checks are built-in, not bolted-on. Includes 5 reusable helper functions for WCAG compliance with Selenium and Python.

Illustration for Inclusive by Default: Building Accessibility into Your Selenium Test Automation

Most teams add accessibility testing as an afterthought—a separate checklist that’s easy to skip when deadlines loom. But what if your Selenium tests caught accessibility issues automatically, just like they catch broken buttons or failed logins?

In this guide, I’ll show you how to build test automation where accessibility checks are built-in, not bolted-on. You’ll get working code templates, a starter framework structure, and clear guidance on which accessibility checks to automate first.

No prior accessibility knowledge required—just basic Selenium experience and a willingness to make your tests more inclusive.

The Problem: Accessibility as an Afterthought

Here’s a typical Selenium test for a login form:

def test_login_form():
    driver.get("https://example.com/login")
    driver.find_element(By.ID, "username").send_keys("testuser")
    driver.find_element(By.ID, "password").send_keys("password123")
    driver.find_element(By.CSS_SELECTOR, "button[type='submit']").click()

    assert "Dashboard" in driver.title

This test passes ✅ but completely misses:

  • ❌ The username field has no visible label
  • ❌ The submit button has no accessible name (just an icon)
  • ❌ Keyboard users can’t navigate the form
  • ❌ Color contrast on error messages is unreadable

The fix isn’t adding a separate accessibility test suite—it’s making every test accessibility-aware.

The Solution: Accessibility-First Test Architecture

Instead of bolting on accessibility checks later, we’ll build them into our test infrastructure from day one.

Project Structure

inclusive-testing-demo/
├── src/
│   ├── helpers/
│   │   ├── __init__.py
│   │   ├── accessibility_helpers.py    # 5 reusable helper functions
│   │   ├── custom_assertions.py        # Self-documenting assertions
│   │   └── report_generator.py         # Unified reporting
│   ├── pages/
│   │   ├── base_page.py               # Page Object with a11y built-in
│   │   └── login_page.py              # Example page object
│   └── tests/
│       ├── conftest.py                # Pytest fixtures with axe-core
│       └── test_login_accessibility.py
├── requirements.txt
└── pytest.ini

The 5 Essential Helper Functions

These helper functions cover the most impactful WCAG criteria that can be reliably automated. Each one is designed to be dropped into any existing Selenium test suite.

1. Check Accessible Names (WCAG 4.1.2)

The most common accessibility issue: interactive elements without accessible names.

# helpers/accessibility_helpers.py

from selenium.webdriver.remote.webdriver import WebDriver
from selenium.webdriver.remote.webelement import WebElement
from typing import List, Dict, Optional
import json


def check_accessible_names(driver: WebDriver, selector: str = None) -> Dict:
    """
    Verify all interactive elements have accessible names.

    WCAG 4.1.2: Name, Role, Value
    Elements need accessible names for screen readers to announce them.

    Args:
        driver: Selenium WebDriver instance
        selector: Optional CSS selector to scope the check

    Returns:
        Dict with 'passed', 'failed', and 'issues' lists
    """
    script = """
    function getAccessibleName(element) {
        // Priority order for accessible name calculation
        // 1. aria-labelledby
        const labelledBy = element.getAttribute('aria-labelledby');
        if (labelledBy) {
            const labels = labelledBy.split(' ')
                .map(id => document.getElementById(id))
                .filter(el => el)
                .map(el => el.textContent.trim())
                .join(' ');
            if (labels) return labels;
        }

        // 2. aria-label
        const ariaLabel = element.getAttribute('aria-label');
        if (ariaLabel && ariaLabel.trim()) return ariaLabel.trim();

        // 3. <label> element (for form controls)
        if (element.id) {
            const label = document.querySelector(`label[for="${element.id}"]`);
            if (label) return label.textContent.trim();
        }

        // 4. Wrapped in <label>
        const parentLabel = element.closest('label');
        if (parentLabel) {
            const clone = parentLabel.cloneNode(true);
            const input = clone.querySelector('input, select, textarea');
            if (input) input.remove();
            const text = clone.textContent.trim();
            if (text) return text;
        }

        // 5. title attribute (last resort)
        const title = element.getAttribute('title');
        if (title && title.trim()) return title.trim();

        // 6. Text content (for buttons, links)
        const textContent = element.textContent.trim();
        if (textContent) return textContent;

        // 7. alt text (for images, image inputs)
        const alt = element.getAttribute('alt');
        if (alt && alt.trim()) return alt.trim();

        // 8. value (for submit/button inputs)
        if (element.tagName === 'INPUT' &&
            ['submit', 'button', 'reset'].includes(element.type)) {
            return element.value || '';
        }

        return '';
    }

    const scope = arguments[0]
        ? document.querySelector(arguments[0])
        : document;

    if (!scope) return { error: 'Selector not found' };

    const interactiveSelectors = [
        'a[href]',
        'button',
        'input:not([type="hidden"])',
        'select',
        'textarea',
        '[role="button"]',
        '[role="link"]',
        '[role="checkbox"]',
        '[role="radio"]',
        '[role="tab"]',
        '[role="menuitem"]',
        '[tabindex]:not([tabindex="-1"])'
    ];

    const elements = scope.querySelectorAll(interactiveSelectors.join(','));
    const results = { passed: [], failed: [], issues: [] };

    elements.forEach((el, index) => {
        const name = getAccessibleName(el);
        const info = {
            tag: el.tagName.toLowerCase(),
            type: el.type || null,
            id: el.id || null,
            class: el.className || null,
            role: el.getAttribute('role'),
            accessibleName: name,
            outerHTML: el.outerHTML.substring(0, 200)
        };

        if (name) {
            results.passed.push(info);
        } else {
            results.failed.push(info);
            results.issues.push({
                element: info,
                issue: 'Missing accessible name',
                wcag: '4.1.2 Name, Role, Value',
                impact: 'critical',
                suggestion: 'Add aria-label, aria-labelledby, or associate with <label>'
            });
        }
    });

    return results;
    """

    return driver.execute_script(script, selector)


def assert_all_interactive_elements_named(driver: WebDriver, selector: str = None):
    """
    Assertion helper that raises if any interactive elements lack names.

    Usage:
        assert_all_interactive_elements_named(driver)
        assert_all_interactive_elements_named(driver, "#login-form")
    """
    results = check_accessible_names(driver, selector)

    if results.get('error'):
        raise ValueError(f"Selector error: {results['error']}")

    if results['failed']:
        failed_elements = "\n".join([
            f"  - <{el['tag']}> {el['outerHTML'][:100]}..."
            for el in results['failed'][:5]  # Show first 5
        ])
        raise AssertionError(
            f"Found {len(results['failed'])} elements without accessible names:\n"
            f"{failed_elements}\n\n"
            f"WCAG 4.1.2: All interactive elements must have accessible names."
        )

2. Verify Keyboard Navigation (WCAG 2.1.1)

Ensure users can navigate and operate all functionality using only a keyboard.

def verify_keyboard_navigation(
    driver: WebDriver,
    expected_focus_order: List[str] = None,
    check_focus_trap: bool = True
) -> Dict:
    """
    Verify keyboard navigation works correctly.

    WCAG 2.1.1: Keyboard
    All functionality must be operable via keyboard.

    Args:
        driver: Selenium WebDriver instance
        expected_focus_order: Optional list of selectors for expected tab order
        check_focus_trap: Whether to check for focus traps

    Returns:
        Dict with navigation results and any issues found
    """
    from selenium.webdriver.common.keys import Keys
    from selenium.webdriver.common.action_chains import ActionChains

    results = {
        'focus_order': [],
        'issues': [],
        'focus_trap_detected': False,
        'unreachable_elements': []
    }

    # Get all focusable elements
    focusable_script = """
    const focusableSelectors = [
        'a[href]',
        'button:not([disabled])',
        'input:not([disabled]):not([type="hidden"])',
        'select:not([disabled])',
        'textarea:not([disabled])',
        '[tabindex]:not([tabindex="-1"])',
        '[contenteditable="true"]'
    ];

    return Array.from(document.querySelectorAll(focusableSelectors.join(',')))
        .filter(el => {
            const style = window.getComputedStyle(el);
            return style.display !== 'none' &&
                   style.visibility !== 'hidden' &&
                   el.offsetParent !== null;
        })
        .map(el => ({
            tag: el.tagName.toLowerCase(),
            id: el.id || null,
            class: el.className || null,
            text: el.textContent.trim().substring(0, 50),
            selector: el.id ? '#' + el.id :
                      el.className ? '.' + el.className.split(' ')[0] :
                      el.tagName.toLowerCase()
        }));
    """

    expected_focusable = driver.execute_script(focusable_script)

    # Start from body and tab through elements
    body = driver.find_element("tag name", "body")
    body.click()

    actions = ActionChains(driver)
    visited_elements = []
    max_tabs = len(expected_focusable) + 10  # Safety limit

    for i in range(max_tabs):
        actions.send_keys(Keys.TAB).perform()

        # Get currently focused element
        focused = driver.execute_script("""
            const el = document.activeElement;
            if (!el || el === document.body) return null;
            return {
                tag: el.tagName.toLowerCase(),
                id: el.id || null,
                class: el.className || null,
                text: el.textContent.trim().substring(0, 50),
                outerHTML: el.outerHTML.substring(0, 150)
            };
        """)

        if not focused:
            continue

        # Check for focus trap (same element focused repeatedly)
        if check_focus_trap and len(visited_elements) >= 3:
            last_three = [str(v) for v in visited_elements[-3:]]
            if len(set(last_three)) == 1:
                results['focus_trap_detected'] = True
                results['issues'].append({
                    'issue': 'Focus trap detected',
                    'element': focused,
                    'wcag': '2.1.2 No Keyboard Trap',
                    'impact': 'critical',
                    'suggestion': 'Ensure users can navigate away using Tab or Escape'
                })
                break

        visited_elements.append(focused)
        results['focus_order'].append(focused)

        # Check if we've cycled back to the start
        if len(visited_elements) > 1:
            if (visited_elements[-1].get('id') == visited_elements[0].get('id') and
                visited_elements[-1].get('id')):
                break

    # Verify expected focus order if provided
    if expected_focus_order:
        actual_ids = [el.get('id') for el in results['focus_order'] if el.get('id')]
        for expected_id in expected_focus_order:
            if expected_id not in actual_ids:
                results['unreachable_elements'].append(expected_id)
                results['issues'].append({
                    'issue': f'Element #{expected_id} not reachable via keyboard',
                    'wcag': '2.1.1 Keyboard',
                    'impact': 'serious',
                    'suggestion': 'Ensure element is focusable and in the tab order'
                })

    return results


def assert_keyboard_accessible(driver: WebDriver, interactive_selectors: List[str]):
    """
    Assert that all specified interactive elements are keyboard accessible.

    Usage:
        assert_keyboard_accessible(driver, ['#submit-btn', '#cancel-btn', '#username'])
    """
    results = verify_keyboard_navigation(driver, interactive_selectors)

    if results['focus_trap_detected']:
        raise AssertionError(
            "Keyboard focus trap detected! Users cannot navigate away.\n"
            "WCAG 2.1.2: No Keyboard Trap"
        )

    if results['unreachable_elements']:
        raise AssertionError(
            f"Elements not reachable via keyboard: {results['unreachable_elements']}\n"
            "WCAG 2.1.1: All functionality must be keyboard accessible."
        )

3. Assert Color Contrast (WCAG 1.4.3)

Check that text meets minimum contrast requirements.

def check_color_contrast(
    driver: WebDriver,
    selector: str = None,
    level: str = 'AA'
) -> Dict:
    """
    Check color contrast ratios for text elements.

    WCAG 1.4.3: Contrast (Minimum) - Level AA
    WCAG 1.4.6: Contrast (Enhanced) - Level AAA

    Requirements:
    - Normal text: 4.5:1 (AA) or 7:1 (AAA)
    - Large text (18pt+ or 14pt+ bold): 3:1 (AA) or 4.5:1 (AAA)

    Args:
        driver: Selenium WebDriver instance
        selector: Optional CSS selector to scope the check
        level: 'AA' or 'AAA' compliance level

    Returns:
        Dict with contrast results and issues
    """
    script = """
    function getLuminance(r, g, b) {
        const [rs, gs, bs] = [r, g, b].map(c => {
            c = c / 255;
            return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
        });
        return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs;
    }

    function getContrastRatio(color1, color2) {
        const l1 = getLuminance(...color1);
        const l2 = getLuminance(...color2);
        const lighter = Math.max(l1, l2);
        const darker = Math.min(l1, l2);
        return (lighter + 0.05) / (darker + 0.05);
    }

    function parseColor(colorStr) {
        const match = colorStr.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
        if (match) {
            return [parseInt(match[1]), parseInt(match[2]), parseInt(match[3])];
        }
        return [0, 0, 0];
    }

    function isLargeText(element) {
        const style = window.getComputedStyle(element);
        const fontSize = parseFloat(style.fontSize);
        const fontWeight = parseInt(style.fontWeight) || 400;
        // Large text: 18pt (24px) or 14pt (18.66px) bold
        return fontSize >= 24 || (fontSize >= 18.66 && fontWeight >= 700);
    }

    const level = arguments[1] || 'AA';
    const scope = arguments[0]
        ? document.querySelector(arguments[0])
        : document;

    if (!scope) return { error: 'Selector not found' };

    // Get all text-containing elements
    const textElements = scope.querySelectorAll(
        'p, span, a, button, label, h1, h2, h3, h4, h5, h6, li, td, th, div, input, textarea'
    );

    const results = { passed: [], failed: [], issues: [] };

    textElements.forEach(el => {
        const style = window.getComputedStyle(el);

        // Skip hidden elements
        if (style.display === 'none' || style.visibility === 'hidden') return;

        // Skip elements without text
        const hasDirectText = Array.from(el.childNodes)
            .some(node => node.nodeType === 3 && node.textContent.trim());
        if (!hasDirectText && !['INPUT', 'TEXTAREA'].includes(el.tagName)) return;

        const fgColor = parseColor(style.color);
        const bgColor = parseColor(style.backgroundColor);

        // If background is transparent, try to get parent background
        let effectiveBg = bgColor;
        if (style.backgroundColor === 'rgba(0, 0, 0, 0)') {
            let parent = el.parentElement;
            while (parent) {
                const parentStyle = window.getComputedStyle(parent);
                if (parentStyle.backgroundColor !== 'rgba(0, 0, 0, 0)') {
                    effectiveBg = parseColor(parentStyle.backgroundColor);
                    break;
                }
                parent = parent.parentElement;
            }
            if (!parent) effectiveBg = [255, 255, 255]; // Assume white
        }

        const ratio = getContrastRatio(fgColor, effectiveBg);
        const largeText = isLargeText(el);

        // Determine required ratio
        let requiredRatio;
        if (level === 'AAA') {
            requiredRatio = largeText ? 4.5 : 7;
        } else {
            requiredRatio = largeText ? 3 : 4.5;
        }

        const info = {
            tag: el.tagName.toLowerCase(),
            text: el.textContent.trim().substring(0, 50),
            foreground: `rgb(${fgColor.join(',')})`,
            background: `rgb(${effectiveBg.join(',')})`,
            ratio: Math.round(ratio * 100) / 100,
            required: requiredRatio,
            largeText: largeText,
            passes: ratio >= requiredRatio
        };

        if (info.passes) {
            results.passed.push(info);
        } else {
            results.failed.push(info);
            results.issues.push({
                element: info,
                issue: `Contrast ratio ${info.ratio}:1 is below ${requiredRatio}:1`,
                wcag: level === 'AAA' ? '1.4.6 Contrast (Enhanced)' : '1.4.3 Contrast (Minimum)',
                impact: 'serious',
                suggestion: `Increase contrast to at least ${requiredRatio}:1`
            });
        }
    });

    return results;
    """

    return driver.execute_script(script, selector, level)


def assert_color_contrast_aa(driver: WebDriver, selector: str = None):
    """
    Assert all text meets WCAG AA contrast requirements.

    Usage:
        assert_color_contrast_aa(driver)
        assert_color_contrast_aa(driver, "#main-content")
    """
    results = check_color_contrast(driver, selector, 'AA')

    if results.get('error'):
        raise ValueError(f"Selector error: {results['error']}")

    if results['failed']:
        failures = "\n".join([
            f"  - '{el['text'][:30]}...' has {el['ratio']}:1 (needs {el['required']}:1)"
            for el in results['failed'][:5]
        ])
        raise AssertionError(
            f"Found {len(results['failed'])} elements with insufficient contrast:\n"
            f"{failures}\n\n"
            f"WCAG 1.4.3: Text must have at least 4.5:1 contrast ratio."
        )

4. Validate ARIA Attributes (WCAG 4.1.2)

Ensure ARIA attributes are used correctly.

def validate_aria_attributes(driver: WebDriver, selector: str = None) -> Dict:
    """
    Validate correct usage of ARIA attributes.

    WCAG 4.1.2: Name, Role, Value
    ARIA attributes must be valid and used correctly.

    Args:
        driver: Selenium WebDriver instance
        selector: Optional CSS selector to scope the check

    Returns:
        Dict with validation results and issues
    """
    script = """
    const validRoles = [
        'alert', 'alertdialog', 'application', 'article', 'banner', 'button',
        'cell', 'checkbox', 'columnheader', 'combobox', 'complementary',
        'contentinfo', 'definition', 'dialog', 'directory', 'document',
        'feed', 'figure', 'form', 'grid', 'gridcell', 'group', 'heading',
        'img', 'link', 'list', 'listbox', 'listitem', 'log', 'main',
        'marquee', 'math', 'menu', 'menubar', 'menuitem', 'menuitemcheckbox',
        'menuitemradio', 'navigation', 'none', 'note', 'option', 'presentation',
        'progressbar', 'radio', 'radiogroup', 'region', 'row', 'rowgroup',
        'rowheader', 'scrollbar', 'search', 'searchbox', 'separator', 'slider',
        'spinbutton', 'status', 'switch', 'tab', 'table', 'tablist', 'tabpanel',
        'term', 'textbox', 'timer', 'toolbar', 'tooltip', 'tree', 'treegrid', 'treeitem'
    ];

    const requiredAttributes = {
        'checkbox': ['aria-checked'],
        'combobox': ['aria-expanded'],
        'heading': ['aria-level'],
        'meter': ['aria-valuenow'],
        'option': ['aria-selected'],
        'radio': ['aria-checked'],
        'scrollbar': ['aria-controls', 'aria-valuenow'],
        'slider': ['aria-valuenow'],
        'spinbutton': ['aria-valuenow'],
        'switch': ['aria-checked']
    };

    const scope = arguments[0]
        ? document.querySelector(arguments[0])
        : document;

    if (!scope) return { error: 'Selector not found' };

    const results = { passed: [], failed: [], issues: [] };

    // Check all elements with ARIA attributes
    const ariaElements = scope.querySelectorAll('[role], [aria-label], [aria-labelledby], [aria-describedby], [aria-hidden], [aria-expanded], [aria-checked], [aria-selected], [aria-controls]');

    ariaElements.forEach(el => {
        const role = el.getAttribute('role');
        const issues = [];

        // Check for valid role
        if (role && !validRoles.includes(role)) {
            issues.push({
                issue: `Invalid ARIA role: "${role}"`,
                suggestion: `Use a valid ARIA role from the WAI-ARIA specification`
            });
        }

        // Check for required attributes
        if (role && requiredAttributes[role]) {
            requiredAttributes[role].forEach(attr => {
                if (!el.hasAttribute(attr)) {
                    issues.push({
                        issue: `Missing required attribute "${attr}" for role="${role}"`,
                        suggestion: `Add ${attr} attribute to elements with role="${role}"`
                    });
                }
            });
        }

        // Check aria-labelledby references exist
        const labelledBy = el.getAttribute('aria-labelledby');
        if (labelledBy) {
            labelledBy.split(' ').forEach(id => {
                if (!document.getElementById(id)) {
                    issues.push({
                        issue: `aria-labelledby references non-existent id: "${id}"`,
                        suggestion: `Ensure element with id="${id}" exists`
                    });
                }
            });
        }

        // Check aria-describedby references exist
        const describedBy = el.getAttribute('aria-describedby');
        if (describedBy) {
            describedBy.split(' ').forEach(id => {
                if (!document.getElementById(id)) {
                    issues.push({
                        issue: `aria-describedby references non-existent id: "${id}"`,
                        suggestion: `Ensure element with id="${id}" exists`
                    });
                }
            });
        }

        // Check aria-controls references exist
        const controls = el.getAttribute('aria-controls');
        if (controls) {
            controls.split(' ').forEach(id => {
                if (!document.getElementById(id)) {
                    issues.push({
                        issue: `aria-controls references non-existent id: "${id}"`,
                        suggestion: `Ensure element with id="${id}" exists`
                    });
                }
            });
        }

        // Check aria-hidden isn't on focusable elements
        if (el.getAttribute('aria-hidden') === 'true') {
            const focusable = el.matches('a[href], button, input, select, textarea, [tabindex]:not([tabindex="-1"])');
            if (focusable) {
                issues.push({
                    issue: 'aria-hidden="true" on focusable element',
                    suggestion: 'Remove aria-hidden or make element non-focusable'
                });
            }
        }

        const info = {
            tag: el.tagName.toLowerCase(),
            role: role,
            id: el.id || null,
            ariaAttributes: Array.from(el.attributes)
                .filter(attr => attr.name.startsWith('aria-'))
                .map(attr => `${attr.name}="${attr.value}"`)
                .join(', '),
            outerHTML: el.outerHTML.substring(0, 150)
        };

        if (issues.length === 0) {
            results.passed.push(info);
        } else {
            info.issues = issues;
            results.failed.push(info);
            issues.forEach(issue => {
                results.issues.push({
                    element: info,
                    issue: issue.issue,
                    wcag: '4.1.2 Name, Role, Value',
                    impact: 'serious',
                    suggestion: issue.suggestion
                });
            });
        }
    });

    return results;
    """

    return driver.execute_script(script, selector)


def assert_valid_aria(driver: WebDriver, selector: str = None):
    """
    Assert all ARIA attributes are valid and properly used.

    Usage:
        assert_valid_aria(driver)
        assert_valid_aria(driver, "#modal-dialog")
    """
    results = validate_aria_attributes(driver, selector)

    if results.get('error'):
        raise ValueError(f"Selector error: {results['error']}")

    if results['failed']:
        failures = "\n".join([
            f"  - <{el['tag']}> {el['issues'][0]['issue']}"
            for el in results['failed'][:5]
        ])
        raise AssertionError(
            f"Found {len(results['failed'])} ARIA validation errors:\n"
            f"{failures}\n\n"
            f"WCAG 4.1.2: ARIA attributes must be valid and correctly used."
        )

5. Check Focus Visible (WCAG 2.4.7)

Ensure focused elements have visible focus indicators.

def check_focus_visible(driver: WebDriver, selector: str = None) -> Dict:
    """
    Check that focused elements have visible focus indicators.

    WCAG 2.4.7: Focus Visible
    Keyboard focus indicator must be visible.

    Args:
        driver: Selenium WebDriver instance
        selector: Optional CSS selector to scope the check

    Returns:
        Dict with focus visibility results and issues
    """
    script = """
    const scope = arguments[0]
        ? document.querySelector(arguments[0])
        : document;

    if (!scope) return { error: 'Selector not found' };

    const focusableSelectors = [
        'a[href]',
        'button:not([disabled])',
        'input:not([disabled]):not([type="hidden"])',
        'select:not([disabled])',
        'textarea:not([disabled])',
        '[tabindex]:not([tabindex="-1"])'
    ];

    const elements = scope.querySelectorAll(focusableSelectors.join(','));
    const results = { passed: [], failed: [], issues: [] };

    elements.forEach(el => {
        // Get styles before focus
        const beforeStyle = window.getComputedStyle(el);
        const beforeOutline = beforeStyle.outline;
        const beforeBoxShadow = beforeStyle.boxShadow;
        const beforeBorder = beforeStyle.border;
        const beforeBackground = beforeStyle.backgroundColor;

        // Focus the element
        el.focus();

        // Get styles after focus
        const afterStyle = window.getComputedStyle(el);
        const afterOutline = afterStyle.outline;
        const afterBoxShadow = afterStyle.boxShadow;
        const afterBorder = afterStyle.border;
        const afterBackground = afterStyle.backgroundColor;

        // Check if there's a visible change
        const hasOutlineChange = afterOutline !== beforeOutline &&
            !afterOutline.includes('0px') &&
            afterOutline !== 'none';
        const hasBoxShadowChange = afterBoxShadow !== beforeBoxShadow &&
            afterBoxShadow !== 'none';
        const hasBorderChange = afterBorder !== beforeBorder;
        const hasBackgroundChange = afterBackground !== beforeBackground;

        const hasFocusIndicator = hasOutlineChange || hasBoxShadowChange ||
                                   hasBorderChange || hasBackgroundChange;

        // Check for outline: none which often removes focus
        const outlineRemoved = afterOutline === 'none' ||
            afterOutline.includes('0px') ||
            afterStyle.outlineStyle === 'none';

        const info = {
            tag: el.tagName.toLowerCase(),
            id: el.id || null,
            class: el.className || null,
            text: el.textContent.trim().substring(0, 30),
            focusStyles: {
                outline: afterOutline,
                boxShadow: afterBoxShadow,
                border: afterBorder
            },
            hasFocusIndicator: hasFocusIndicator,
            outlineRemoved: outlineRemoved
        };

        // Blur to reset
        el.blur();

        if (hasFocusIndicator || !outlineRemoved) {
            results.passed.push(info);
        } else {
            results.failed.push(info);
            results.issues.push({
                element: info,
                issue: 'No visible focus indicator',
                wcag: '2.4.7 Focus Visible',
                impact: 'serious',
                suggestion: 'Add :focus styles with outline, box-shadow, or border change'
            });
        }
    });

    return results;
    """

    return driver.execute_script(script, selector)


def assert_focus_visible(driver: WebDriver, selector: str = None):
    """
    Assert all focusable elements have visible focus indicators.

    Usage:
        assert_focus_visible(driver)
        assert_focus_visible(driver, "#navigation")
    """
    results = check_focus_visible(driver, selector)

    if results.get('error'):
        raise ValueError(f"Selector error: {results['error']}")

    if results['failed']:
        failures = "\n".join([
            f"  - <{el['tag']}> #{el['id'] or el['class'] or 'no-id'}: {el['text'][:20]}..."
            for el in results['failed'][:5]
        ])
        raise AssertionError(
            f"Found {len(results['failed'])} elements without visible focus indicators:\n"
            f"{failures}\n\n"
            f"WCAG 2.4.7: Focus indicator must be visible when elements receive keyboard focus."
        )

Putting It All Together: An Accessible Test

Now let’s see how these helpers transform a regular test into an accessibility-first test:

# tests/test_login_accessibility.py

import pytest
from selenium import webdriver
from selenium.webdriver.common.by import By
from helpers.accessibility_helpers import (
    assert_all_interactive_elements_named,
    assert_keyboard_accessible,
    assert_color_contrast_aa,
    assert_valid_aria,
    assert_focus_visible
)


@pytest.fixture
def driver():
    driver = webdriver.Chrome()
    driver.implicitly_wait(10)
    yield driver
    driver.quit()


class TestLoginAccessibility:
    """
    Login form tests with built-in accessibility checks.

    Every functional test automatically verifies accessibility.
    """

    def test_login_form_is_accessible(self, driver):
        """Test that the login form meets accessibility requirements."""
        driver.get("https://example.com/login")

        # Accessibility checks built into the test
        assert_all_interactive_elements_named(driver, "#login-form")
        assert_color_contrast_aa(driver, "#login-form")
        assert_valid_aria(driver, "#login-form")
        assert_focus_visible(driver, "#login-form")

    def test_login_keyboard_navigation(self, driver):
        """Test that login can be completed using only keyboard."""
        driver.get("https://example.com/login")

        # Verify keyboard accessibility
        assert_keyboard_accessible(driver, [
            "#username",
            "#password",
            "#remember-me",
            "#submit-btn"
        ])

    def test_successful_login_accessible(self, driver):
        """Test login flow with accessibility checks at each step."""
        driver.get("https://example.com/login")

        # Step 1: Verify form accessibility
        assert_all_interactive_elements_named(driver, "#login-form")

        # Step 2: Fill and submit
        driver.find_element(By.ID, "username").send_keys("testuser")
        driver.find_element(By.ID, "password").send_keys("password123")
        driver.find_element(By.ID, "submit-btn").click()

        # Step 3: Verify dashboard accessibility
        assert "Dashboard" in driver.title
        assert_all_interactive_elements_named(driver, "#dashboard")
        assert_color_contrast_aa(driver, "#dashboard")

    def test_login_error_accessible(self, driver):
        """Test that error messages are accessible."""
        driver.get("https://example.com/login")

        # Submit empty form
        driver.find_element(By.ID, "submit-btn").click()

        # Verify error messages are accessible
        error = driver.find_element(By.CSS_SELECTOR, "[role='alert']")
        assert error.is_displayed()

        # Error message should have sufficient contrast
        assert_color_contrast_aa(driver, "[role='alert']")

        # Error should be properly announced
        assert_valid_aria(driver, "[role='alert']")

The Complete Helper Module

Here’s the complete accessibility_helpers.py file with all five functions ready to use:

# helpers/accessibility_helpers.py
"""
Accessibility helper functions for Selenium test automation.

These helpers enable accessibility-first testing by providing
reusable checks for common WCAG criteria.

Usage:
    from helpers.accessibility_helpers import (
        assert_all_interactive_elements_named,
        assert_keyboard_accessible,
        assert_color_contrast_aa,
        assert_valid_aria,
        assert_focus_visible
    )

    def test_my_page(driver):
        driver.get("https://example.com")
        assert_all_interactive_elements_named(driver)
        assert_color_contrast_aa(driver)
"""

from selenium.webdriver.remote.webdriver import WebDriver
from typing import List, Dict

# Include all five functions from above...
# (check_accessible_names, verify_keyboard_navigation,
#  check_color_contrast, validate_aria_attributes, check_focus_visible)
# Plus their assertion wrappers

What These Helpers Catch vs. What Requires Manual Testing

Can Automate ✅Requires Manual Testing 👤
Missing accessible namesMeaningful accessible names
Keyboard navigation pathsLogical navigation order
Color contrast ratiosColor as sole indicator
Invalid ARIA attributesAppropriate ARIA usage
Missing focus indicatorsFocus indicator visibility
Basic heading structureMeaningful heading hierarchy
Image alt attributesMeaningful alt text
Form label associationsHelpful error messages

Next Steps

In the next article, I’ll share a comprehensive guide on which accessibility checks to automate first and which require manual testing or user research—helping you prioritize your accessibility automation efforts.

Resources


This article is part of my “Inclusive by Default” series on building accessibility into test automation. Have questions? Reach out on GitHub or LinkedIn.

RC

Ruby Jane Cabagnot

Accessibility Cloud Engineer

Building inclusive digital experiences through automated testing and AI-powered accessibility tools. Passionate about making the web accessible for everyone.

Related Topics:

#accessibility testing #selenium #python #WCAG #test automation #axe-core #inclusive design