--- name: accessibility-testing-specialist description: Use when testing WCAG compliance, screen reader compatibility, keyboard navigation, ARIA attributes, or ensuring application is accessible to users with disabilities - focuses on a11y testing with axe and Playwright tags: domain: testing-quality tools: [axe, playwright, testing-library, pa11y, lighthouse] symptoms: [accessibility violation, screen reader not working, keyboard nav broken, missing aria label] keywords: [accessibility, a11y, wcag, aria, screen reader, keyboard navigation, contrast] priority: high prerequisites: [e2e-framework] --- # Accessibility Testing Specialist ## When to Use - Testing WCAG compliance (AA or AAA) - Verifying screen reader compatibility - Testing keyboard navigation - Validating ARIA attributes - Testing color contrast ratios - Ensuring forms are accessible - Testing focus management - Validating semantic HTML ## Process ### 1. Set Up Accessibility Testing ☐ Install axe-core: `npm install --save-dev @axe-core/playwright` ☐ Or use Lighthouse CI ☐ Configure accessibility rules ☐ Set up automated scanning in tests **Axe Playwright setup:** ```typescript import { test, expect } from '@playwright/test'; import AxeBuilder from '@axe-core/playwright'; test.describe('Accessibility tests', () => { test('should not have accessibility violations', async ({ page }) => { await page.goto('/'); const accessibilityScanResults = await new AxeBuilder({ page }) .analyze(); expect(accessibilityScanResults.violations).toEqual([]); }); }); ``` ### 2. Test Keyboard Navigation ☐ Test Tab key moves focus correctly ☐ Test Shift+Tab for reverse navigation ☐ Test Enter/Space activate buttons ☐ Test Escape closes modals ☐ Verify focus indicators visible **Keyboard navigation tests:** ```typescript test('keyboard navigation through form', async ({ page }) => { await page.goto('/signup'); // Tab to first field await page.keyboard.press('Tab'); await expect(page.getByLabel(/email/i)).toBeFocused(); // Tab to next field await page.keyboard.press('Tab'); await expect(page.getByLabel(/password/i)).toBeFocused(); // Tab to submit button await page.keyboard.press('Tab'); await expect(page.getByRole('button', { name: /sign up/i })).toBeFocused(); // Activate with Enter await page.keyboard.press('Enter'); // Form should submit }); test('Escape closes modal', async ({ page }) => { await page.goto('/'); await page.getByRole('button', { name: /open dialog/i }).click(); const dialog = page.getByRole('dialog'); await expect(dialog).toBeVisible(); await page.keyboard.press('Escape'); await expect(dialog).not.toBeVisible(); }); test('focus trap in modal', async ({ page }) => { await page.goto('/'); await page.getByRole('button', { name: /open dialog/i }).click(); const firstFocusable = page.getByRole('button', { name: /close/i }).first(); const lastFocusable = page.getByRole('button', { name: /submit/i }).last(); // Focus should be on first element await expect(firstFocusable).toBeFocused(); // Tab through to last element await page.keyboard.press('Tab'); await page.keyboard.press('Tab'); // Tab again should loop back to first await page.keyboard.press('Tab'); await expect(firstFocusable).toBeFocused(); }); ``` ### 3. Test Screen Reader Compatibility ☐ Verify proper heading hierarchy (h1, h2, h3) ☐ Test ARIA labels on interactive elements ☐ Test landmark regions (nav, main, aside) ☐ Verify alt text on images ☐ Test live regions for dynamic content **Screen reader tests:** ```typescript test('proper heading hierarchy', async ({ page }) => { await page.goto('/'); const h1Count = await page.locator('h1').count(); expect(h1Count).toBe(1); // Only one h1 per page const h1 = page.locator('h1').first(); await expect(h1).toHaveText(/welcome/i); // Verify headings are sequential const headings = page.locator('h1, h2, h3, h4, h5, h6'); const levels = await headings.evaluateAll((elements) => elements.map((el) => parseInt(el.tagName.charAt(1))) ); // Check no skipped levels (h1 -> h3 without h2) for (let i = 1; i < levels.length; i++) { expect(levels[i] - levels[i - 1]).toBeLessThanOrEqual(1); } }); test('images have alt text', async ({ page }) => { await page.goto('/'); const images = page.locator('img'); const count = await images.count(); for (let i = 0; i < count; i++) { const img = images.nth(i); const alt = await img.getAttribute('alt'); // Decorative images can have alt="" // All other images must have meaningful alt expect(alt).toBeDefined(); // If not decorative, alt should not be empty const role = await img.getAttribute('role'); if (role !== 'presentation' && role !== 'none') { expect(alt).not.toBe(''); } } }); test('buttons have accessible names', async ({ page }) => { await page.goto('/'); const buttons = page.locator('button'); const count = await buttons.count(); for (let i = 0; i < count; i++) { const button = buttons.nth(i); // Button must have text or aria-label const text = await button.textContent(); const ariaLabel = await button.getAttribute('aria-label'); const ariaLabelledBy = await button.getAttribute('aria-labelledby'); expect( text?.trim() || ariaLabel || ariaLabelledBy ).toBeTruthy(); } }); ``` ### 4. Test ARIA Attributes ☐ Verify ARIA roles are appropriate ☐ Test ARIA state attributes (aria-expanded, aria-checked) ☐ Verify ARIA labels and descriptions ☐ Test ARIA live regions **ARIA attribute tests:** ```typescript test('dropdown has correct ARIA attributes', async ({ page }) => { await page.goto('/components/dropdown'); const button = page.getByRole('button', { name: /options/i }); // Button should have aria-haspopup await expect(button).toHaveAttribute('aria-haspopup', 'true'); await expect(button).toHaveAttribute('aria-expanded', 'false'); // Open dropdown await button.click(); // aria-expanded should update await expect(button).toHaveAttribute('aria-expanded', 'true'); // Menu should be visible const menu = page.getByRole('menu'); await expect(menu).toBeVisible(); }); test('form inputs have labels', async ({ page }) => { await page.goto('/signup'); const emailInput = page.getByRole('textbox', { name: /email/i }); const passwordInput = page.getByRole('textbox', { name: /password/i }); // Inputs should be accessible by label await expect(emailInput).toBeVisible(); await expect(passwordInput).toBeVisible(); // Or check label association const emailLabel = await emailInput.evaluate((el) => { return el.labels?.[0]?.textContent; }); expect(emailLabel).toMatch(/email/i); }); test('live region announces updates', async ({ page }) => { await page.goto('/notifications'); const liveRegion = page.locator('[aria-live]'); await expect(liveRegion).toHaveAttribute('aria-live', 'polite'); // Trigger notification await page.getByRole('button', { name: /send notification/i }).click(); // Live region should update await expect(liveRegion).toContainText('Notification sent'); }); ``` ### 5. Test Color Contrast ☐ Verify text meets WCAG AA (4.5:1 normal, 3:1 large) ☐ Test color contrast for interactive elements ☐ Test focus indicators have sufficient contrast ☐ Run automated contrast checks **Color contrast testing:** ```typescript test('text has sufficient contrast', async ({ page }) => { await page.goto('/'); const accessibilityScanResults = await new AxeBuilder({ page }) .withTags(['wcag2aa', 'wcag21aa']) .analyze(); const contrastViolations = accessibilityScanResults.violations.filter( (v) => v.id === 'color-contrast' ); expect(contrastViolations).toEqual([]); }); ``` ### 6. Test Form Accessibility ☐ Verify all inputs have labels ☐ Test error messages are announced ☐ Test required fields indicated ☐ Verify autocomplete attributes **Form accessibility tests:** ```typescript test('form errors are accessible', async ({ page }) => { await page.goto('/signup'); // Submit empty form await page.getByRole('button', { name: /sign up/i }).click(); // Error message should be linked to input const emailInput = page.getByLabel(/email/i); const errorId = await emailInput.getAttribute('aria-describedby'); expect(errorId).toBeTruthy(); const errorMessage = page.locator(`#${errorId}`); await expect(errorMessage).toContainText(/required/i); // Error should have role="alert" or aria-live const role = await errorMessage.getAttribute('role'); const ariaLive = await errorMessage.getAttribute('aria-live'); expect(role === 'alert' || ariaLive === 'assertive').toBe(true); }); test('required fields indicated', async ({ page }) => { await page.goto('/signup'); const emailInput = page.getByLabel(/email/i); const required = await emailInput.getAttribute('required'); const ariaRequired = await emailInput.getAttribute('aria-required'); expect(required !== null || ariaRequired === 'true').toBe(true); // Visual indicator should exist (*, "required" text, etc.) const label = page.getByText(/email/i).first(); const labelText = await label.textContent(); expect(labelText).toMatch(/\*|required/i); }); ``` ### 7. Test Focus Management ☐ Verify focus moves logically through page ☐ Test focus returns after modal close ☐ Verify skip links work ☐ Test focus indicators visible **Focus management tests:** ```typescript test('focus returns after modal closes', async ({ page }) => { await page.goto('/'); const openButton = page.getByRole('button', { name: /open dialog/i }); await openButton.click(); const dialog = page.getByRole('dialog'); await expect(dialog).toBeVisible(); const closeButton = dialog.getByRole('button', { name: /close/i }); await closeButton.click(); // Focus should return to open button await expect(openButton).toBeFocused(); }); test('skip link allows bypassing navigation', async ({ page }) => { await page.goto('/'); // Tab to skip link (usually first focusable element) await page.keyboard.press('Tab'); const skipLink = page.getByRole('link', { name: /skip to main/i }); await expect(skipLink).toBeFocused(); await page.keyboard.press('Enter'); // Focus should move to main content const mainContent = page.locator('main'); await expect(mainContent).toBeFocused(); }); ``` ### 8. Run Comprehensive A11y Scans ☐ Use AxeBuilder to scan all pages ☐ Test against WCAG 2.1 Level AA ☐ Generate accessibility report ☐ Fix all violations before deploying **Comprehensive scanning:** ```typescript test('homepage accessibility scan', async ({ page }) => { await page.goto('/'); const accessibilityScanResults = await new AxeBuilder({ page }) .withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa']) .analyze(); // Log violations for debugging if (accessibilityScanResults.violations.length > 0) { console.log('Accessibility violations:'); accessibilityScanResults.violations.forEach((violation) => { console.log(`- ${violation.id}: ${violation.description}`); console.log(` Impact: ${violation.impact}`); console.log(` Nodes: ${violation.nodes.length}`); }); } expect(accessibilityScanResults.violations).toEqual([]); }); // Test multiple pages const pages = ['/', '/about', '/contact', '/dashboard']; for (const path of pages) { test(`${path} accessibility`, async ({ page }) => { await page.goto(path); const results = await new AxeBuilder({ page }).analyze(); expect(results.violations).toEqual([]); }); } ``` ## Common Accessibility Patterns ### Accessible Modal ```typescript test('modal is accessible', async ({ page }) => { await page.goto('/'); await page.getByRole('button', { name: /open/i }).click(); const dialog = page.getByRole('dialog'); // Should have aria-modal await expect(dialog).toHaveAttribute('aria-modal', 'true'); // Should have accessible name const title = await dialog.getAttribute('aria-labelledby'); expect(title).toBeTruthy(); // Should trap focus await page.keyboard.press('Tab'); const focused = await page.evaluate(() => document.activeElement?.tagName); expect(dialog.locator('*').filter({ has: page.locator(':focus') })).toBeTruthy(); }); ``` ### Accessible Data Table ```typescript test('table is accessible', async ({ page }) => { await page.goto('/data'); const table = page.getByRole('table'); // Should have caption or aria-label const caption = table.locator('caption'); const ariaLabel = await table.getAttribute('aria-label'); expect((await caption.count()) > 0 || ariaLabel).toBeTruthy(); // Headers should use th, not td const headers = await table.locator('thead th').count(); expect(headers).toBeGreaterThan(0); // Should have proper scope const firstHeader = table.locator('thead th').first(); const scope = await firstHeader.getAttribute('scope'); expect(['col', 'row']).toContain(scope); }); ``` ## Red Flags **Never:** - Use div/span for buttons (use semantic HTML) - Remove focus outlines without replacement - Use color alone to convey information - Skip keyboard navigation testing - Use placeholder as label replacement - Create inaccessible custom controls (use native when possible) - Ignore ARIA attribute rules (must follow specification) **Always:** - Use semantic HTML (button, nav, main, article, etc.) - Provide text alternatives (alt, aria-label) - Ensure keyboard navigation works - Test with screen reader (VoiceOver, NVDA, JAWS) - Maintain focus order and visibility - Use ARIA only when HTML semantics insufficient - Test with keyboard only (no mouse) - Verify color contrast meets WCAG AA **Accessibility Testing Tools:** - @axe-core/playwright - Automated accessibility testing - Lighthouse - Comprehensive audits - WAVE - Browser extension for manual testing - Screen readers - VoiceOver (Mac), NVDA (Windows), JAWS ## Accessibility Checklist Before production: - ☐ All pages pass axe-core scan - ☐ Keyboard navigation tested throughout - ☐ Screen reader tested (at least one platform) - ☐ Color contrast meets WCAG AA (4.5:1) - ☐ All images have alt text - ☐ Forms have proper labels and error handling - ☐ Focus indicators visible - ☐ Heading hierarchy logical - ☐ ARIA attributes used correctly - ☐ No accessibility violations in CI