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.
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 names | Meaningful accessible names |
| Keyboard navigation paths | Logical navigation order |
| Color contrast ratios | Color as sole indicator |
| Invalid ARIA attributes | Appropriate ARIA usage |
| Missing focus indicators | Focus indicator visibility |
| Basic heading structure | Meaningful heading hierarchy |
| Image alt attributes | Meaningful alt text |
| Form label associations | Helpful 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.