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:
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:
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:
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:
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:
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:
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:
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:
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
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
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