Screen Reader Testing
Practical guide to testing web applications with screen readers for comprehensive accessibility validation.
When to Use This Skill
-
Validating screen reader compatibility
-
Testing ARIA implementations
-
Debugging assistive technology issues
-
Verifying form accessibility
-
Testing dynamic content announcements
-
Ensuring navigation accessibility
Core Concepts
- Major Screen Readers
Screen Reader Platform Browser Usage
VoiceOver macOS/iOS Safari ~15%
NVDA Windows Firefox/Chrome ~31%
JAWS Windows Chrome/IE ~40%
TalkBack Android Chrome ~10%
Narrator Windows Edge ~4%
- Testing Priority
Minimum Coverage:
- NVDA + Firefox (Windows)
- VoiceOver + Safari (macOS)
- VoiceOver + Safari (iOS)
Comprehensive Coverage:
- JAWS + Chrome (Windows)
- TalkBack + Chrome (Android)
- Narrator + Edge (Windows)
- Screen Reader Modes
Mode Purpose When Used
Browse/Virtual Read content Default reading
Focus/Forms Interact with controls Filling forms
Application Custom widgets ARIA applications
VoiceOver (macOS)
Setup
Enable: System Preferences → Accessibility → VoiceOver Toggle: Cmd + F5 Quick Toggle: Triple-press Touch ID
Essential Commands
Navigation: VO = Ctrl + Option (VoiceOver modifier)
VO + Right Arrow Next element VO + Left Arrow Previous element VO + Shift + Down Enter group VO + Shift + Up Exit group
Reading: VO + A Read all from cursor Ctrl Stop speaking VO + B Read current paragraph
Interaction: VO + Space Activate element VO + Shift + M Open menu Tab Next focusable element Shift + Tab Previous focusable element
Rotor (VO + U): Navigate by: Headings, Links, Forms, Landmarks Left/Right Arrow Change rotor category Up/Down Arrow Navigate within category Enter Go to item
Web Specific: VO + Cmd + H Next heading VO + Cmd + J Next form control VO + Cmd + L Next link VO + Cmd + T Next table
Testing Checklist
VoiceOver Testing Checklist
Page Load
- Page title announced
- Main landmark found
- Skip link works
Navigation
- All headings discoverable via rotor
- Heading levels logical (H1 → H2 → H3)
- Landmarks properly labeled
- Skip links functional
Links & Buttons
- Link purpose clear
- Button actions described
- New window/tab announced
Forms
- All labels read with inputs
- Required fields announced
- Error messages read
- Instructions available
- Focus moves to errors
Dynamic Content
- Alerts announced immediately
- Loading states communicated
- Content updates announced
- Modals trap focus correctly
Tables
- Headers associated with cells
- Table navigation works
- Complex tables have captions
Common Issues & Fixes
<!-- Issue: Button not announcing purpose --> <button><svg>...</svg></button>
<!-- Fix --> <button aria-label="Close dialog"><svg aria-hidden="true">...</svg></button>
<!-- Issue: Dynamic content not announced --> <div id="results">New results loaded</div>
<!-- Fix --> <div id="results" role="status" aria-live="polite">New results loaded</div>
<!-- Issue: Form error not read --> <input type="email" /> <span class="error">Invalid email</span>
<!-- Fix --> <input type="email" aria-invalid="true" aria-describedby="email-error" /> <span id="email-error" role="alert">Invalid email</span>
NVDA (Windows)
Setup
Download: nvaccess.org Start: Ctrl + Alt + N Stop: Insert + Q
Essential Commands
Navigation: Insert = NVDA modifier
Down Arrow Next line Up Arrow Previous line Tab Next focusable Shift + Tab Previous focusable
Reading: NVDA + Down Arrow Say all Ctrl Stop speech NVDA + Up Arrow Current line
Headings: H Next heading Shift + H Previous heading 1-6 Heading level 1-6
Forms: F Next form field B Next button E Next edit field X Next checkbox C Next combo box
Links: K Next link U Next unvisited link V Next visited link
Landmarks: D Next landmark Shift + D Previous landmark
Tables: T Next table Ctrl + Alt + Arrows Navigate cells
Elements List (NVDA + F7): Shows all links, headings, form fields, landmarks
Browse vs Focus Mode
NVDA automatically switches modes:
- Browse Mode: Arrow keys navigate content
- Focus Mode: Arrow keys control interactive elements
Manual switch: NVDA + Space
Watch for:
- "Browse mode" announcement when navigating
- "Focus mode" when entering form fields
- Application role forces forms mode
Testing Script
NVDA Test Script
Initial Load
- Navigate to page
- Let page finish loading
- Press Insert + Down to read all
- Note: Page title, main content identified?
Landmark Navigation
- Press D repeatedly
- Check: All main areas reachable?
- Check: Landmarks properly labeled?
Heading Navigation
- Press Insert + F7 → Headings
- Check: Logical heading structure?
- Press H to navigate headings
- Check: All sections discoverable?
Form Testing
- Press F to find first form field
- Check: Label read?
- Fill in invalid data
- Submit form
- Check: Errors announced?
- Check: Focus moved to error?
Interactive Elements
- Tab through all interactive elements
- Check: Each announces role and state
- Activate buttons with Enter/Space
- Check: Result announced?
Dynamic Content
- Trigger content update
- Check: Change announced?
- Open modal
- Check: Focus trapped?
- Close modal
- Check: Focus returns?
JAWS (Windows)
Essential Commands
Start: Desktop shortcut or Ctrl + Alt + J Virtual Cursor: Auto-enabled in browsers
Navigation: Arrow keys Navigate content Tab Next focusable Insert + Down Read all Ctrl Stop speech
Quick Keys: H Next heading T Next table F Next form field B Next button G Next graphic L Next list ; Next landmark
Forms Mode: Enter Enter forms mode Numpad + Exit forms mode F5 List form fields
Lists: Insert + F7 Link list Insert + F6 Heading list Insert + F5 Form field list
Tables: Ctrl + Alt + Arrows Table navigation
TalkBack (Android)
Setup
Enable: Settings → Accessibility → TalkBack Toggle: Hold both volume buttons 3 seconds
Gestures
Explore: Drag finger across screen Next: Swipe right Previous: Swipe left Activate: Double tap Scroll: Two finger swipe
Reading Controls (swipe up then right):
- Headings
- Links
- Controls
- Characters
- Words
- Lines
- Paragraphs
Common Test Scenarios
- Modal Dialog
<!-- Accessible modal structure --> <div role="dialog" aria-modal="true" aria-labelledby="dialog-title" aria-describedby="dialog-desc"
<h2 id="dialog-title">Confirm Delete</h2> <p id="dialog-desc">This action cannot be undone.</p> <button>Cancel</button> <button>Delete</button> </div>
// Focus management function openModal(modal) { // Store last focused element lastFocus = document.activeElement;
// Move focus to modal modal.querySelector("h2").focus();
// Trap focus modal.addEventListener("keydown", trapFocus); }
function closeModal(modal) { // Return focus lastFocus.focus(); }
function trapFocus(e) { if (e.key === "Tab") { const focusable = modal.querySelectorAll( 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])', ); const first = focusable[0]; const last = focusable[focusable.length - 1];
if (e.shiftKey && document.activeElement === first) {
last.focus();
e.preventDefault();
} else if (!e.shiftKey && document.activeElement === last) {
first.focus();
e.preventDefault();
}
}
if (e.key === "Escape") { closeModal(modal); } }
- Live Regions
<!-- Status messages (polite) --> <div role="status" aria-live="polite" aria-atomic="true"> <!-- Content updates will be announced after current speech --> </div>
<!-- Alerts (assertive) --> <div role="alert" aria-live="assertive"> <!-- Content updates interrupt current speech --> </div>
<!-- Progress updates --> <div role="progressbar" aria-valuenow="75" aria-valuemin="0" aria-valuemax="100" aria-label="Upload progress"
</div>
<!-- Log (additions only) --> <div role="log" aria-live="polite" aria-relevant="additions"> <!-- New messages announced, removals not --> </div>
- Tab Interface
<div role="tablist" aria-label="Product information"> <button role="tab" id="tab-1" aria-selected="true" aria-controls="panel-1"> Description </button> <button role="tab" id="tab-2" aria-selected="false" aria-controls="panel-2" tabindex="-1"
Reviews
</button> </div>
<div role="tabpanel" id="panel-1" aria-labelledby="tab-1"> Product description content... </div>
<div role="tabpanel" id="panel-2" aria-labelledby="tab-2" hidden> Reviews content... </div>
// Tab keyboard navigation tablist.addEventListener("keydown", (e) => { const tabs = [...tablist.querySelectorAll('[role="tab"]')]; const index = tabs.indexOf(document.activeElement);
let newIndex; switch (e.key) { case "ArrowRight": newIndex = (index + 1) % tabs.length; break; case "ArrowLeft": newIndex = (index - 1 + tabs.length) % tabs.length; break; case "Home": newIndex = 0; break; case "End": newIndex = tabs.length - 1; break; default: return; }
tabs[newIndex].focus(); activateTab(tabs[newIndex]); e.preventDefault(); });
Debugging Tips
// Log what screen reader sees function logAccessibleName(element) { const computed = window.getComputedStyle(element); console.log({ role: element.getAttribute("role") || element.tagName, name: element.getAttribute("aria-label") || element.getAttribute("aria-labelledby") || element.textContent, state: { expanded: element.getAttribute("aria-expanded"), selected: element.getAttribute("aria-selected"), checked: element.getAttribute("aria-checked"), disabled: element.disabled, }, visible: computed.display !== "none" && computed.visibility !== "hidden", }); }
Best Practices
Do's
-
Test with actual screen readers - Not just simulators
-
Use semantic HTML first - ARIA is supplemental
-
Test in browse and focus modes - Different experiences
-
Verify focus management - Especially for SPAs
-
Test keyboard only first - Foundation for SR testing
Don'ts
-
Don't assume one SR is enough - Test multiple
-
Don't ignore mobile - Growing user base
-
Don't test only happy path - Test error states
-
Don't skip dynamic content - Most common issues
-
Don't rely on visual testing - Different experience