Accessibility Testing
Why This Matters
Accessibility testing ensures web applications meet WCAG (Web Content Accessibility Guidelines) standards, making content usable by people with disabilities. Production systems must comply with WCAG AA standards - not just for legal compliance, but to serve all users effectively. Approximately 15% of the world’s population lives with some form of disability, representing a significant user base that needs proper accessibility support.
Playwright provides built-in accessibility testing capabilities through role-based selectors and aria attributes, but production accessibility requires comprehensive WCAG validation including automated rule checking, keyboard navigation testing, and screen reader compatibility verification. Manual testing alone is insufficient - automated accessibility testing integrates into CI/CD pipelines, catching regressions before they reach production. The investment in accessibility testing yields returns through expanded market reach, reduced legal risk, improved SEO, and better user experience for everyone including users with temporary disabilities or situational limitations.
Enterprise applications face accessibility audits, legal requirements (ADA, Section 508, EN 301 549), and procurement requirements that mandate WCAG AA compliance. Implementing production-grade accessibility testing with automated scanning prevents costly post-deployment fixes and ensures continuous compliance as applications evolve. This guide demonstrates the progression from Playwright’s built-in role selectors to production-ready accessibility testing with axe-core integration.
Standard Library Approach: Playwright Role Selectors
Playwright provides role-based selectors that query elements by their ARIA role, making tests more accessible:
// => test-accessibility-basic.spec.ts
import { test, expect } from "@playwright/test";
// => Import Playwright test framework
// => test function defines test cases
// => expect provides assertion functions
test("form has accessible labels", async ({ page }) => {
// => Test verifies form accessibility basics
// => page is Playwright Page object with browser context
// => async function allows await operations
await page.goto("https://example.com/register");
// => Navigate to registration form page
// => await waits for page load completion
// => URL should be your application under test
const nameInput = page.getByRole("textbox", { name: "Full Name" });
// => Query input by ARIA role and accessible name
// => getByRole finds elements by accessibility tree
// => name parameter matches aria-label or label text
await expect(nameInput).toBeVisible();
// => Verify input is visible to users
// => toBeVisible checks CSS visibility and display
// => Fails if element hidden or display:none
await nameInput.fill("John Smith");
// => Fill input with test data
// => Simulates keyboard input
// => Verifies input accepts user input
const submitButton = page.getByRole("button", { name: "Submit" });
// => Query button by role and accessible name
// => Matches button text or aria-label
// => Role-based selector more robust than CSS
await expect(submitButton).toBeEnabled();
// => Verify button is interactive
// => Checks disabled attribute and aria-disabled
// => Ensures form is submittable
await submitButton.click();
// => Trigger form submission
// => Tests button click interaction
// => Should navigate or show confirmation
});Limitations for production:
- No WCAG rule validation - Only tests role presence, not compliance with 78 WCAG AA success criteria
- No color contrast checking - Cannot detect text with insufficient contrast ratios
- No automated scanning - Manual test cases miss violations in untested components
- No violation reporting - Test failures don’t explain WCAG violations with remediation guidance
- Limited ARIA validation - Doesn’t verify correct aria-labelledby, aria-describedby, or state attributes
- No keyboard navigation testing - Role selectors don’t validate tab order or focus management
Production Framework: axe-core Integration
axe-core is the industry-standard accessibility testing engine that validates WCAG compliance. Playwright integrates with axe-core through the @axe-core/playwright package for comprehensive production testing:
# => Install axe-core Playwright integration
npm install --save-dev @axe-core/playwright
# => Adds axe-core engine to dev dependencies
# => Version pinning recommended for reproducible tests
# => Package size: ~1.5MB (development only)// => test-accessibility-production.spec.ts
import { test, expect } from "@playwright/test";
// => Playwright test framework for browser automation
// => Provides test lifecycle and assertions
// => expect function for accessibility checks
import { injectAxe, checkA11y, configureAxe } from "@axe-core/playwright";
// => Import axe-core Playwright utilities
// => injectAxe loads axe-core into browser context
// => checkA11y runs accessibility scans
// => configureAxe sets rules and options
interface A11yViolation {
// => Type definition for accessibility violations
// => Provides structured violation reporting
// => Enables TypeScript type safety
id: string;
// => Unique axe rule identifier (e.g. 'color-contrast')
// => Maps to WCAG success criterion
// => Used for violation tracking
impact: "critical" | "serious" | "moderate" | "minor";
// => Severity classification from axe-core
// => critical: WCAG failures blocking users
// => serious: Major accessibility barriers
// => moderate/minor: Usability improvements
description: string;
// => Human-readable explanation of violation
// => Explains what WCAG requirement failed
// => Includes remediation guidance
nodes: Array<{
// => Array of DOM elements violating rule
// => Each node represents one violation instance
// => Provides location information
html: string;
// => Outer HTML of violating element
// => Shows element markup context
// => Used for debugging
target: string[];
// => CSS selector path to element
// => Unique identifier for element location
// => Enables automated fix tooling
}>;
}
test.describe("Accessibility Compliance", () => {
// => Group related accessibility tests
// => describe creates test suite
// => All tests share setup/teardown
test.beforeEach(async ({ page }) => {
// => Run before each test in suite
// => Sets up axe-core in browser context
// => page fixture provides browser page
await page.goto("https://example.com");
// => Navigate to application under test
// => Should use configurable base URL
// => await ensures page fully loaded
await injectAxe(page);
// => Inject axe-core library into page
// => Loads axe.js from node_modules
// => Must run before any checkA11y calls
// => Only needed once per page load
});
test("homepage meets WCAG AA standards", async ({ page }) => {
// => Comprehensive WCAG scan of homepage
// => Tests all 78 WCAG AA success criteria
// => Scans entire page DOM tree
await configureAxe(page, {
// => Configure axe-core scanning options
// => Sets WCAG conformance level
// => Enables specific rule tags
rules: {
// => Customize rule configuration
// => Enable/disable specific checks
// => Set severity levels
"color-contrast": { enabled: true },
// => Enable color contrast checking
// => Validates 4.5:1 normal text, 3:1 large text
// => Critical for visual accessibility
},
});
try {
// => Error handling for accessibility failures
// => Catch block provides detailed reporting
// => Prevents test crash on violations
await checkA11y(page, undefined, {
// => Run accessibility scan on page
// => undefined means scan entire page
// => Options object configures reporting
detailedReport: true,
// => Generate comprehensive violation report
// => Includes all violation details
// => Provides remediation guidance
detailedReportOptions: {
// => Configure report detail level
// => Controls output verbosity
// => Useful for CI/CD logs
html: true,
// => Include HTML snippets in report
// => Shows violating element markup
// => Helps developers locate issues
},
});
// => If checkA11y succeeds, no violations found
// => Test passes, page is WCAG AA compliant
// => Continue to next test
} catch (error) {
// => Accessibility violations detected
// => error contains violation details
// => Type assertion for structured access
const violations = (error as any).violations as A11yViolation[];
// => Extract violation array from error
// => Type assertion provides TypeScript types
// => violations array contains all WCAG failures
console.error(`Found ${violations.length} accessibility violations:`);
// => Log violation count to console
// => Visible in test output and CI logs
// => Helps assess accessibility debt
violations.forEach((violation, index) => {
// => Iterate through each violation
// => Log detailed information per violation
// => Provides actionable remediation data
console.error(`\n${index + 1}. ${violation.id}`);
// => Log violation number and axe rule ID
// => Format: "1. color-contrast"
// => Rule ID maps to WCAG criterion
console.error(` Impact: ${violation.impact}`);
// => Log severity level
// => critical > serious > moderate > minor
// => Prioritizes fix order
console.error(` Description: ${violation.description}`);
// => Log violation explanation
// => Describes WCAG requirement
// => Includes fix guidance
console.error(` Affected elements (${violation.nodes.length}):`);
// => Log count of violating elements
// => One rule can affect multiple elements
// => Shows scope of issue
violation.nodes.forEach((node, nodeIndex) => {
// => Iterate through violating DOM nodes
// => Log each element's location and markup
// => Helps developers locate violations
console.error(` ${nodeIndex + 1}. ${node.target.join(" > ")}`);
// => Log CSS selector path to element
// => Format: "div > section > button"
// => Used to locate element in codebase
console.error(` HTML: ${node.html.substring(0, 100)}...`);
// => Log first 100 chars of element HTML
// => Shows element context
// => Truncated to keep logs readable
});
});
throw new Error(`Accessibility violations detected. See logs above.`);
// => Re-throw error to fail test
// => Prevents non-compliant code from merging
// => Forces accessibility fixes before deployment
}
});
test("form has proper ARIA labels", async ({ page }) => {
// => Specific test for form accessibility
// => Validates ARIA label associations
// => Tests keyboard navigation
await page.goto("https://example.com/register");
// => Navigate to registration form
// => Should use configurable URL
// => await ensures page ready
await injectAxe(page);
// => Inject axe-core into form page
// => Required for checkA11y call
// => Must run after navigation
await checkA11y(page, "form", {
// => Scan only form element
// => CSS selector limits scope
// => Faster than full page scan
axeOptions: {
// => Configure axe-core rule options
// => Enable specific checks for forms
// => Customize severity levels
rules: {
label: { enabled: true },
// => Verify all inputs have labels
// => Validates for/id associations
// => Critical for screen readers
"aria-required-attr": { enabled: true },
// => Check required ARIA attributes present
// => Validates aria-labelledby, aria-label
// => Ensures proper ARIA usage
},
},
});
// => Test passes if no violations found
// => Form is properly labeled
// => Screen readers can navigate form
});
test("keyboard navigation works correctly", async ({ page }) => {
// => Test keyboard-only interaction
// => Validates focus management
// => Critical for motor disability users
await page.goto("https://example.com/navigation");
// => Navigate to page with interactive elements
// => Should test complex UI components
// => await ensures page loaded
await injectAxe(page);
// => Inject axe-core library
// => Required for accessibility scanning
// => Runs after page navigation
await page.keyboard.press("Tab");
// => Simulate Tab key press
// => Moves focus to first focusable element
// => Tests keyboard navigation path
const focusedElement = await page.evaluate(() => {
// => Execute JavaScript in browser context
// => Gets currently focused element
// => Returns element properties to test
const element = document.activeElement;
// => Get active (focused) element from DOM
// => activeElement is current focus target
// => null if nothing focused
return {
tagName: element?.tagName,
// => Get element tag name (DIV, BUTTON, etc.)
// => Used to verify correct element focused
// => undefined if no element focused
role: element?.getAttribute("role"),
// => Get ARIA role attribute
// => Validates semantic role
// => null if no role specified
ariaLabel: element?.getAttribute("aria-label"),
// => Get aria-label attribute
// => Provides accessible name
// => null if no aria-label
};
});
expect(focusedElement.tagName).toBe("A");
// => Verify first focusable element is link
// => Validates expected tab order
// => Test fails if wrong element focused
await checkA11y(page);
// => Run full accessibility scan
// => Checks focus indicators visible
// => Validates WCAG focus requirements
});
});Accessibility Testing Architecture
flowchart TD
Start[Test Starts] --> Navigate[Navigate to Page]
Navigate --> Inject[Inject axe-core]
Inject --> Configure[Configure Rules]
Configure --> Scan[Run Accessibility Scan]
Scan --> Check{Violations?}
Check -->|No| Pass[Test Passes]
Check -->|Yes| Extract[Extract Violations]
Extract --> Classify[Classify by Impact]
Classify --> Critical{Critical?}
Classify --> Serious{Serious?}
Classify --> Moderate{Moderate?}
Critical -->|Yes| Log1[Log Critical Violations]
Serious -->|Yes| Log2[Log Serious Violations]
Moderate -->|Yes| Log3[Log Moderate Violations]
Log1 --> Report[Generate Report]
Log2 --> Report
Log3 --> Report
Report --> Fail[Test Fails]
Pass --> End[End]
Fail --> End
style Start fill:#0173B2,color:#fff
style Navigate fill:#029E73,color:#fff
style Inject fill:#DE8F05,color:#000
style Scan fill:#CC78BC,color:#fff
style Check fill:#CA9161,color:#fff
style Pass fill:#029E73,color:#fff
style Fail fill:#DE8F05,color:#000
style End fill:#0173B2,color:#fff
Production Patterns and Best Practices
Pattern 1: Automated WCAG Scanning in CI/CD
Integrate accessibility scanning into continuous integration pipelines to catch violations before deployment:
// => test-accessibility-ci.spec.ts
import { test, expect } from "@playwright/test";
import { injectAxe, checkA11y } from "@axe-core/playwright";
import * as fs from "fs";
// => Import Node.js filesystem module
// => Enables writing violation reports to files
// => Used for CI/CD artifact storage
test.describe("CI Accessibility Audit", () => {
// => Test suite for automated CI scanning
// => Runs on every pull request
// => Blocks merges if violations found
const violationReport: any[] = [];
// => Accumulate violations across tests
// => Array stores all detected violations
// => Written to JSON file after tests
test.afterAll(() => {
// => Run after all tests complete
// => Write violation report to disk
// => CI/CD can upload as artifact
if (violationReport.length > 0) {
// => Check if violations detected
// => Only write report if failures found
// => Empty report means compliance
fs.writeFileSync(
// => Write report to filesystem
// => Synchronous write ensures completion
// => File path should be configurable
"accessibility-violations.json",
// => Output filename for CI artifacts
// => JSON format for machine parsing
// => Can be processed by dashboards
JSON.stringify(violationReport, null, 2),
// => Convert violations to formatted JSON
// => null, 2 provides readable indentation
// => Preserves all violation metadata
);
console.log("Accessibility report written to accessibility-violations.json");
// => Log report location
// => Visible in CI logs
// => Helps developers find detailed report
}
});
test("scan critical user flows", async ({ page }) => {
// => Test critical accessibility paths
// => Focuses on core user journeys
// => Login, registration, checkout flows
const flows = [
// => Array of critical user flow URLs
// => Each flow tested independently
// => Failures tracked separately
{ name: "Login", url: "/login" },
// => Login flow entry point
// => Critical for user access
// => Must be fully accessible
{ name: "Registration", url: "/register" },
// => User registration flow
// => New user onboarding path
// => Form accessibility critical
{ name: "Dashboard", url: "/dashboard" },
// => Main application dashboard
// => Primary user interface
// => Complex UI requires thorough testing
];
for (const flow of flows) {
// => Iterate through each critical flow
// => Test one flow at a time
// => Isolates violations by flow
await page.goto(`https://example.com${flow.url}`);
// => Navigate to flow URL
// => Use base URL from config
// => await ensures page loaded
await injectAxe(page);
// => Inject axe-core into page
// => Required for accessibility scanning
// => Must run after each navigation
try {
await checkA11y(page);
// => Run accessibility scan
// => Throws if violations found
// => Success means flow is compliant
console.log(`✓ ${flow.name} flow is accessible`);
// => Log success message
// => Green checkmark indicates pass
// => Visible in CI logs
} catch (error) {
// => Violations detected in flow
// => Extract violation details
// => Add to cumulative report
const violations = (error as any).violations;
// => Extract violations from error
// => Type assertion for access
// => violations is array of violation objects
violationReport.push({
// => Add flow violations to report
// => Object includes flow context
// => Enables flow-specific analysis
flow: flow.name,
// => Flow name (Login, Registration, etc.)
// => Groups violations by user journey
// => Helps prioritize fixes
url: flow.url,
// => URL where violations found
// => Enables reproduction
// => Used for regression testing
violations: violations,
// => Full violation details
// => Includes rule IDs, impact, nodes
// => Provides remediation guidance
});
console.error(`✗ ${flow.name} flow has accessibility violations`);
// => Log failure message
// => Red X indicates failure
// => Visible in CI logs
}
}
if (violationReport.length > 0) {
// => Check if any violations found
// => Fail test if violations exist
// => Prevents non-compliant code merge
throw new Error(`${violationReport.length} flows have accessibility violations`);
// => Throw error with violation count
// => Fails CI/CD pipeline
// => Forces developers to fix issues
}
});
});Pattern 2: Keyboard Navigation Testing
Validate keyboard-only interaction for users who cannot use a mouse:
// => test-keyboard-navigation.spec.ts
import { test, expect } from "@playwright/test";
// => Playwright test framework
// => Provides keyboard simulation
// => expect for focus verification
test.describe("Keyboard Navigation", () => {
// => Test suite for keyboard accessibility
// => Validates focus management
// => Critical for motor disabilities
test("tab order follows visual layout", async ({ page }) => {
// => Test tab order matches visual order
// => WCAG 2.4.3 Focus Order requirement
// => Prevents confusing navigation
await page.goto("https://example.com/form");
// => Navigate to form page
// => Should test complex interactive UIs
// => await ensures page ready
const focusOrder: string[] = [];
// => Track focus order sequence
// => Array stores element IDs in tab order
// => Compared to expected order
for (let i = 0; i < 5; i++) {
// => Tab through first 5 focusable elements
// => 5 iterations covers typical form
// => Adjust count based on page complexity
await page.keyboard.press("Tab");
// => Simulate Tab key press
// => Moves focus to next element
// => Triggers focus event
const focusedId = await page.evaluate(() => {
// => Execute in browser context
// => Get currently focused element ID
// => Returns to Node.js test context
return document.activeElement?.id || "unknown";
// => Return ID of focused element
// => 'unknown' if no ID attribute
// => activeElement is current focus target
});
focusOrder.push(focusedId);
// => Add element ID to focus sequence
// => Builds complete tab order
// => Used for order verification
}
expect(focusOrder).toEqual([
// => Verify focus order matches expected
// => toEqual checks array equality
// => Order matters for accessibility
"name-input",
// => First focus: Name input field
// => Top of visual form layout
// => Matches reading order
"email-input",
// => Second focus: Email input
// => Below name in visual layout
// => Logical progression
"password-input",
// => Third focus: Password input
// => Below email in form
// => Continues logical order
"terms-checkbox",
// => Fourth focus: Terms checkbox
// => Before submit button
// => User must agree to terms
"submit-button",
// => Fifth focus: Submit button
// => Last interactive element
// => Completes form flow
]);
});
test("focus indicators are visible", async ({ page }) => {
// => Test focus indicators meet WCAG 2.4.7
// => Visible focus indicator required
// => Helps keyboard users track position
await page.goto("https://example.com/navigation");
// => Navigate to page with links
// => Test navigation menus
// => await ensures page loaded
await page.keyboard.press("Tab");
// => Move focus to first link
// => Triggers focus styling
// => Should show visible outline
const focusedElement = page.locator(":focus");
// => Locate currently focused element
// => :focus pseudo-class selector
// => Matches element with focus
await expect(focusedElement).toHaveCSS("outline-width", /[1-9]/);
// => Verify outline width is set
// => Regex matches 1-9px (any visible width)
// => WCAG requires visible focus indicator
// => Test fails if outline: none
await expect(focusedElement).toHaveCSS("outline-style", "solid");
// => Verify outline style is solid
// => Dotted/dashed acceptable too
// => Ensures visible boundary
const outlineColor = await focusedElement.evaluate((el) => {
// => Get computed outline color
// => Execute in browser context
// => Returns CSS color value
return window.getComputedStyle(el).outlineColor;
// => Get computed CSS property
// => getComputedStyle returns final values
// => outlineColor includes inherited styles
});
expect(outlineColor).not.toBe("rgba(0, 0, 0, 0)");
// => Verify outline not transparent
// => rgba(0,0,0,0) is transparent black
// => Transparent outline invisible to users
// => Test fails if focus invisible
});
});Pattern 3: Screen Reader ARIA Testing
Validate ARIA attributes for screen reader compatibility:
// => test-aria-attributes.spec.ts
import { test, expect } from "@playwright/test";
import { injectAxe, checkA11y } from "@axe-core/playwright";
// => Import axe-core for ARIA validation
// => Checks ARIA usage against ARIA spec
// => Validates relationships and states
test.describe("ARIA Attributes", () => {
// => Test suite for ARIA compliance
// => Validates semantic HTML + ARIA
// => Critical for screen readers
test("dynamic content announces changes", async ({ page }) => {
// => Test ARIA live regions
// => Validates dynamic updates announced
// => WCAG 4.1.3 Status Messages
await page.goto("https://example.com/notifications");
// => Navigate to page with live updates
// => Should test real-time notifications
// => await ensures page loaded
const liveRegion = page.locator("[aria-live]");
// => Locate live region element
// => aria-live attribute marks dynamic content
// => Screen readers monitor for changes
await expect(liveRegion).toHaveAttribute("aria-live", "polite");
// => Verify live region is polite
// => polite waits for screen reader pause
// => assertive interrupts immediately (use sparingly)
// => Test fails if aria-live missing
await page.getByRole("button", { name: "Notify" }).click();
// => Trigger notification update
// => Simulates dynamic content change
// => Should update live region text
await expect(liveRegion).toContainText("Notification sent");
// => Verify live region updated
// => Screen reader announces new text
// => Text change triggers announcement
await injectAxe(page);
// => Inject axe-core after interaction
// => Validates ARIA after state change
// => Ensures dynamic updates maintain accessibility
await checkA11y(page, undefined, {
// => Scan page accessibility
// => undefined means full page scan
// => Options configure ARIA checks
axeOptions: {
rules: {
"aria-allowed-attr": { enabled: true },
// => Validate ARIA attributes allowed on element
// => Prevents invalid ARIA usage
// => e.g. aria-label on div invalid
"aria-required-attr": { enabled: true },
// => Verify required ARIA attributes present
// => e.g. role="checkbox" requires aria-checked
// => Ensures complete ARIA patterns
"aria-valid-attr-value": { enabled: true },
// => Check ARIA attribute values valid
// => e.g. aria-checked must be true/false/mixed
// => Prevents malformed ARIA
},
},
});
});
test("modal has proper ARIA labels and focus management", async ({ page }) => {
// => Test modal dialog accessibility
// => Validates ARIA dialog pattern
// => WCAG 4.1.2 Name, Role, Value
await page.goto("https://example.com/modal");
// => Navigate to page with modal
// => Should test overlay dialogs
// => await ensures page ready
await page.getByRole("button", { name: "Open Modal" }).click();
// => Trigger modal open
// => Simulates user interaction
// => Should show dialog
const modal = page.getByRole("dialog");
// => Locate modal by dialog role
// => Role-based selector tests ARIA role
// => Fails if role="dialog" missing
await expect(modal).toBeVisible();
// => Verify modal displayed
// => Checks CSS visibility
// => Modal must be visible to users
await expect(modal).toHaveAttribute("aria-modal", "true");
// => Verify aria-modal attribute
// => Tells screen readers dialog is modal
// => Prevents navigation to background content
const modalLabel = await modal.getAttribute("aria-labelledby");
// => Get aria-labelledby ID reference
// => Points to element containing modal title
// => Provides accessible name for dialog
expect(modalLabel).toBeTruthy();
// => Verify aria-labelledby exists
// => Modal must have accessible name
// => Test fails if missing label reference
const labelElement = page.locator(`#${modalLabel}`);
// => Locate label element by ID
// => ID from aria-labelledby attribute
// => Should be modal heading
await expect(labelElement).toBeVisible();
// => Verify label element visible
// => Screen readers read visible text
// => Hidden labels confuse users
const focusedElement = page.locator(":focus");
// => Get currently focused element
// => Focus should be trapped in modal
// => WCAG 2.4.3 Focus Order
await expect(focusedElement).toBeVisible();
// => Verify focused element visible
// => Focus indicator must be visible
// => Users need focus feedback
const focusedRole = await focusedElement.getAttribute("role");
// => Get ARIA role of focused element
// => Should be button or interactive control
// => Validates focus management
expect(["button", "link"]).toContain(focusedRole);
// => Verify focused element is interactive
// => button or link are expected
// => Focus should not be on static text
// => Test fails if focus on wrong element type
});
});Trade-offs and When to Use
Standard Playwright Role Selectors:
- Use when: Writing basic accessibility tests, testing role presence, validating interactive element types
- Benefits: Built into Playwright (no dependencies), fast execution, simple API, good for smoke tests
- Costs: No WCAG validation, manual test coverage, no violation reporting, misses color contrast issues
Production Framework (axe-core):
- Use when: Enforcing WCAG compliance, automating accessibility audits, generating violation reports, testing in CI/CD
- Benefits: Validates 78 WCAG AA criteria, detailed violation reports with remediation guidance, industry-standard tool (used by Google, Microsoft), integrates with CI pipelines
- Costs: Additional dependency (~1.5MB), slower test execution (comprehensive scans), requires axe-core configuration knowledge
Production recommendation: Use axe-core integration for all production applications. The small dependency cost and slightly slower execution are negligible compared to the comprehensive WCAG validation and detailed violation reporting. axe-core catches issues that manual testing misses, prevents legal compliance violations, and provides actionable remediation guidance. Start with full-page scans in CI/CD, then add component-specific scans for critical user flows. Combine with manual keyboard navigation and screen reader testing for complete accessibility coverage.
Security Considerations
- Accessibility and security overlap - Accessible error messages must not leak sensitive information (e.g. “User not found” vs “Invalid credentials”)
- ARIA labels can expose information - Avoid putting sensitive data in aria-label attributes that screen readers announce
- Focus management prevents phishing - Proper focus indicators help users verify legitimate UI elements vs injected malicious content
- Color contrast aids security awareness - High contrast helps users spot security indicators like HTTPS locks and warning messages
- Keyboard navigation security - Tab order must not expose hidden admin controls or bypass security checks
- Live region injection attacks - Validate content before inserting into aria-live regions to prevent XSS announcements
- CAPTCHA accessibility - Audio CAPTCHAs must not be easier to solve programmatically than visual ones
- Screen reader output logging - ARIA labels and announcements may be logged by screen reader software, avoid sensitive data
Common Pitfalls
Testing only with axe-core - Automated tools catch ~30-40% of accessibility issues. Must combine with manual keyboard navigation testing and screen reader verification to achieve full WCAG compliance.
Ignoring color contrast - Text with insufficient contrast ratio (below 4.5:1 for normal text, 3:1 for large text) fails WCAG AA. axe-core color-contrast rule catches these but must be explicitly enabled.
Missing focus indicators - Setting
outline: nonewithout visible alternative fails WCAG 2.4.7. Always test keyboard navigation with visible focus indicators.Invalid ARIA usage - Adding ARIA roles without required attributes (e.g.
role="checkbox"withoutaria-checked) creates broken accessibility. Use axe-corearia-required-attrrule to catch these.Testing only in Chrome - Screen readers behave differently across browsers. Test in Chrome with NVDA, Firefox with NVDA, Safari with VoiceOver for comprehensive coverage.
Assuming alt text is sufficient - Complex images need detailed descriptions in aria-describedby or adjacent text. Alt text should be concise (< 150 chars).
Not testing dynamic content - ARIA live regions must announce updates properly. Test notification systems, form validation messages, and loading states with screen readers.
Hardcoding accessibility tests - Use data-testid attributes for test selectors instead of hardcoding text content that may change with translations or content updates.
Testing only happy paths - Test error states, loading states, empty states with accessibility tools. Error messages must be associated with form fields via aria-describedby.
Ignoring mobile accessibility - Touch targets must be 44x44px minimum (WCAG 2.5.5). Test with touch screen devices and mobile screen readers (TalkBack on Android, VoiceOver on iOS).