Screen Reader Testing with NVDA and VoiceOver: A Developer's Guide

Screen Reader Testing with NVDA and VoiceOver: A Developer's Guide

Automated accessibility tools catch around 30-40% of WCAG violations. The rest require manual testing — and screen reader testing is the most important manual technique. Reading how your application sounds to a screen reader user reveals problems that axe-core and Lighthouse never report.

This guide focuses on practical screen reader testing for developers: the tools, the keyboard commands, and what to test for in each type of UI component.

The Two Screen Readers You Need

NVDA on Windows — free, open source, the most widely used screen reader globally (WebAIM survey data). Pairs with Firefox for best compatibility, though Chrome works well too.

Download: nvaccess.org/download

VoiceOver on macOS — built into macOS, no installation needed. Pairs with Safari.

Activate: System Settings → Accessibility → VoiceOver, or Cmd + F5.

You don't need to test with every screen reader combination. NVDA+Firefox and VoiceOver+Safari cover the vast majority of real-world screen reader usage.

Essential NVDA Commands

Before testing, understand NVDA's navigation model. NVDA uses a virtual cursor — it creates a virtual representation of the page and lets users navigate by type of element.

NVDA key = Insert (by default, or Caps Lock)

Basic navigation:
- Tab / Shift+Tab: move between interactive elements (links, buttons, form fields)
- Arrow keys: read line by line
- H / Shift+H: next/previous heading
- F / Shift+F: next/previous form field
- B / Shift+B: next/previous button
- L / Shift+L: next/previous list
- T / Shift+T: next/previous table
- NVDA+F7: open Elements List (headings, links, landmarks, buttons)

Reading:
- NVDA+Down: read from current position
- NVDA+Up: read current line
- NVDA+A: read entire document
- NVDA+Home: jump to start

Browse vs Forms mode:
- NVDA enters Forms Mode automatically on interactive elements
- NVDA+Space: toggle between Browse Mode and Forms Mode manually

Essential VoiceOver Commands

VoiceOver uses VO as a modifier (Control + Option by default).

VO = Control + Option

Basic navigation:
- Tab / Shift+Tab: move between interactive elements
- VO+Right / VO+Left: move to next/previous item
- VO+U: open Rotor (headings, links, landmarks, form controls)
- VO+Space: activate current item
- VO+Shift+Down: interact with an element (enter a group)
- VO+Shift+Up: stop interacting with a group

Reading:
- VO+A: read from current position
- VO+P: read paragraph
- VO+Home: go to top of page

Headings:
- In Rotor, select Headings, then use arrow keys to navigate

What to Test: Components and Scenarios

1. Page Structure

Screen reader users navigate by landmarks and headings before exploring content.

Check with NVDA Elements List (NVDA+F7) or VoiceOver Rotor (VO+U):

  • Are there meaningful headings? Is the hierarchy logical (H1 → H2 → H3)?
  • Are there landmarks? (<header>, <main>, <nav>, <footer>, <aside>)
  • Does every page have exactly one <main> landmark?
  • Is there a skip link ("Skip to main content") as the first focusable element?

Common failures:

<!-- Fail: Using divs for headings, breaking hierarchy -->
<div class="title">Welcome</div>
<div class="section-title">Features</div>

<!-- Pass: Proper heading hierarchy -->
<h1>Welcome</h1>
<h2>Features</h2>

2. Forms

Forms are where most screen reader failures occur.

Tab through every field and verify:

  • Does the screen reader announce the field's label before the input type?
    • NVDA should say: "First name, edit"
    • Not: "edit" (missing label) or "text, edit" (wrong label)
  • Are required fields announced as required?
    • "Email address, required, edit"
  • Are error messages read when validation fails?
    • After submitting with an empty required field, does focus move to the error and is it read?

Test pattern:

<!-- Pass: explicit label association -->
<label for="email">Email address <span aria-label="required">*</span></label>
<input type="email" id="email" required aria-describedby="email-error">
<span id="email-error" role="alert">Please enter a valid email address.</span>

<!-- Fail: placeholder-only labeling -->
<input type="email" placeholder="Email address">

Error announcements: Use role="alert" for dynamically injected error messages — screen readers read these automatically when they appear. Without it, users must navigate back to find the error.

Each button should announce: what it does (not just its icon)

<!-- Fail: icon-only button -->
<button><svg>...</svg></button>
<!-- Screen reader says: nothing, or the SVG title if present -->

<!-- Pass: accessible name via aria-label -->
<button aria-label="Close dialog"><svg aria-hidden="true">...</svg></button>

<!-- Pass: visually hidden text -->
<button>
  <svg aria-hidden="true">...</svg>
  <span class="sr-only">Close dialog</span>
</button>

Links should describe the destination, not just "click here":

<!-- Fail: -->
<a href="/pricing">Click here</a>

<!-- Pass: -->
<a href="/pricing">View pricing plans</a>

4. Modal Dialogs

Dialogs are frequently broken for screen reader users. Test:

  1. Open the modal
  2. Does focus move inside the modal?
  3. Navigate with Tab — does focus stay trapped inside the modal?
  4. Is the modal's heading read when it opens?
  5. Can you close with Escape?
  6. When closed, does focus return to the element that triggered the modal?
<!-- Minimal accessible dialog -->
<div role="dialog" 
     aria-modal="true" 
     aria-labelledby="dialog-title"
     tabindex="-1">  <!-- Makes it focusable to receive focus on open -->
  <h2 id="dialog-title">Confirm deletion</h2>
  <p>Are you sure you want to delete this item?</p>
  <button>Delete</button>
  <button>Cancel</button>
</div>

JavaScript must: set focus to the dialog when opened, trap Tab within it, return focus on close.

5. Dynamic Content

When content updates without a page reload, screen readers don't automatically read it.

Use live regions for important updates:

<!-- For polite announcements (status messages) -->
<div aria-live="polite" id="status"></div>

<!-- For urgent announcements (errors) -->
<div aria-live="assertive" role="alert" id="error"></div>

Update content via JavaScript:

document.getElementById('status').textContent = 'File uploaded successfully';

Test: Add a product to a shopping cart. Does the screen reader announce the cart update? Or does the user have to navigate to the cart to discover it?

6. Tables

Complex tables need explicit structure:

<table>
  <caption>Q1 Sales by Region</caption>
  <thead>
    <tr>
      <th scope="col">Region</th>
      <th scope="col">January</th>
      <th scope="col">February</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <th scope="row">North</th>
      <td>$12,500</td>
      <td>$14,200</td>
    </tr>
  </tbody>
</table>

When navigating cell by cell, NVDA should announce both the row header and column header for each cell: "North, Region. $12,500, January."

Without scope attributes, complex tables become impossible to navigate.

Running a Screen Reader Test Session

A practical 30-minute testing protocol for a typical web application:

  1. Orientation (5 min): Tab to the first element. Is there a skip link? Navigate headings (H key in NVDA). What's the H1? Does the landmark structure make sense?
  2. Navigation (5 min): Use the nav landmark to explore the menu. Tab through all navigation links. Are they clearly labeled?
  3. Key user flow (15 min): Perform your application's primary flow (signup, checkout, search) using only keyboard and screen reader. Tab through every field and interactive element. Note anything that's not announced, confusingly announced, or requires mouse interaction.
  4. Dynamic interactions (5 min): Trigger modal dialogs, dropdown menus, accordions, and loading states. Does focus management work? Are updates announced?

Document every issue found: the element, the expected announcement, the actual announcement, and the WCAG criterion violated.

Incorporating Into Your Workflow

Screen reader testing is most efficient when done at component level before integration, not as a final audit. Test a new modal component when you build it, not after 50 pages use it.

Pair screen reader testing with automated checks:

  1. axe-core in your test suite catches structural issues
  2. Screen reader testing catches announcement and focus management issues

For continuous monitoring between manual test cycles, HelpMeTest runs automated accessibility scenarios against your live application. Write a test that walks your signup flow and verifies key form labels and error announcements exist — catch regressions before users report them.

The free plan supports up to 10 automated tests running continuously. A single screen-reader-simulating test that walks your signup form catches the most common category of regressions: label disappearing from an input after a refactor.

Start now free