DEV Community

Cover image for You know you can automate screen reader tests right?
Craig Morten
Craig Morten

Posted on

You know you can automate screen reader tests right?

There is a common misconception that accessibility testing can only fall into two groups:

  1. Some limited amount of static analysis performed by something like Axe
  2. Manual testing for everything else, either by yourself, your tester, your AI agent(?), your resident accessibility expert, or hopefully real assistive technology users

If you've gone a step further, you might even have some coverage in your functional test suite of choice for keyboard interactions (most cool!), but have you ever considered that you could also be testing with some popular screen reader technologies as well?

Instead of querying the DOM to find a button, or checking its accessible name from the browser's internal accessibility tree, you could be navigating using a real screen reader and assert against what it actually announces.

In this post we'll walk you through how to set it up with Guidepup running against the BBC Homepage - spoiler alert, it's two extra CLI commands and then whatever tests you fancy writing!

Getting started

For this walkthrough we're going to use Playwright, but you can bring your own testing library of choice!

The first step to screen reader automation is to configure your machine, which can be done in one command:

# Add the --ci flag if running in CI!
npx @guidepup/setup
Enter fullscreen mode Exit fullscreen mode

Then in your project directory install your test dependencies as you would usually, but with a few extra Guidepup packages that will be handling the screen reader automation:

# If you're not using Playwright then drop the last package!
npm i @playwright/test @guidepup/guidepup @guidepup/playwright
Enter fullscreen mode Exit fullscreen mode

Then as you would normally, install your Playwright browser(s) of choice - for this walkthrough we'll stick to WebKit:

npx playwright install webkit
Enter fullscreen mode Exit fullscreen mode

And for the last "extra" step vs a standard Playwright project setup, we run the following command to install our screen reader assets - basically the equivalent to the Playwright command we just ran, but for screen readers instead of browsers!

npx @guidepup/setup install
Enter fullscreen mode Exit fullscreen mode

Setup done! šŸŽ‰

Playwright config

We need this anyway, the only difference is we wrap our config in a withGuidepup() utility:

// playwright.config.ts
import { devices } from "@playwright/test";
import { withGuidepup } from "@guidepup/playwright";

const config = withGuidepup({
  projects: [
    {
      name: "webkit",
      use: { ...devices["Desktop Safari"] },
    },
  ],
});

export default config;
Enter fullscreen mode Exit fullscreen mode

If you're introducing this to an existing test suite you can also create this as a separate playwright.guidepup-config.ts or similar.

This wrapper makes sure that the Playwright test runs are always headed as screen readers cannot operate against headless browsers.

Playwright test

Last step! Let's dive straight in with the pertinent code:

// bbc.test.ts

// We import our test instance from Guidepup's Playwright package -
// it's the same as the Playwright test version but with a
// `screenReader` instance available, a bit like the `page`
// instance
import { screenReaderTest as test } from "@guidepup/playwright";
import { expect } from "@playwright/test";

const MAX_NAVIGATION = 100;

const log = (...messages: string[]) =>
  console.log(`[${new Date().toUTCString()}]`, ...messages);

test.describe("BBC screen reader tests", () => {
  test.beforeEach(
    async ({ context, page, screenReader }) => {
      // Skip the pesky cookie banner.
      await context.addCookies([
        {
          name: "ckns_explicit",
          value: "1",
          domain: "www.bbc.co.uk",
          path: "/",
        },
      ]);

      // Prevent the BBC redirecting us to their international site.
      await page.route("https://www.bbc.co.uk/userinfo**", (route) =>
        route.abort(),
      );

      // Navigate to our target page.
      await page.goto("https://www.bbc.co.uk/", {
        waitUntil: "load",
      });

      // Wait for the page to load.
      await page.locator("header").waitFor();

      // Navigate the screen reader to the web content!!
      await screenReader.navigateToWebContent();

      // That's us now at the beginning of the web page
      // with our screen reader :o
    },
  );

  test("I can navigate to BBC Sounds using a screen reader", async ({
    screenReader,
  }) => {
    let lastSpokenPhrase = "";
    let navigationCounter = 0;

    // Let's navigate to their "More menu" button on the
    // site header.
    while (!/More menu/i.test(lastSpokenPhrase)) {
      if (navigationCounter >= MAX_NAVIGATION) {
        throw new Error("Failed to find 'More menu' button");
      }

      // Navigate to the next item with the screen reader.
      // For VoiceOver this does "VO+Right Arrow", for NVDA this
      // does "Right Arrow", for Orca this does
      // "Orca+Ctrl+Right Arrow".
      await screenReader.next();
      lastSpokenPhrase = await screenReader.lastSpokenPhrase();
      log(`Last spoken phrase: ${lastSpokenPhrase}`);

      navigationCounter++;
    }

    // Activate the control under the screen reader cursor, usually
    // similar to keying "Enter" with a keyboard.
    // This is so we can expand the menu to find BBC Sounds!
    await screenReader.act();
    lastSpokenPhrase = await screenReader.lastSpokenPhrase();
    log(`Last spoken phrase: ${lastSpokenPhrase}`);

    navigationCounter = 0;

    // And finally, continue our search for the desired link.
    while (!/Sounds/i.test(lastSpokenPhrase)) {
      if (navigationCounter >= MAX_NAVIGATION) {
        throw new Error("Failed to find 'Sounds' link");
      }

      await screenReader.next();
      lastSpokenPhrase = await screenReader.lastSpokenPhrase();
      log(`Last spoken phrase: ${lastSpokenPhrase}`);

      navigationCounter++;
    }

    // We're here, visit the link!
    await screenReader.act();
    lastSpokenPhrase = await screenReader.lastSpokenPhrase();
    log(`Last spoken phrase: ${lastSpokenPhrase}`);

    // Let's assert that we actually got to BBC Sounds!
    await expect(page).toHaveURL(/bbc\.co\.uk\/sounds/);

    // And finally log out the entire journey - in your tests this
    // could be assertions or even a snapshot.
    log(
      `Spoken phrase log: ${JSON.stringify(
        await screenReader.spokenPhraseLog(),
      )}`,
    );
  });
});
Enter fullscreen mode Exit fullscreen mode

Not working for you? I don't own the website so it could have changed! Please also be considerate when testing against sites you don't own!

Follow along ok? šŸ˜…

The key bits are:

  1. Import your test instance from @guidepup/playwright not @playwright/test to get access to the generic screenReader instance, or voiceOver, nvda, or orca instances if targeting a specific screen reader.
  2. Once your page is loaded, use the screenReader.navigateToWebContent() command to land on the web content.
  3. Make use of screenReader commands to navigate through the page and assert on what was spoken through the screen reader.

This test is basic - we're mostly using next, act and lastSpokenPhrase, but Guidepup also supports the full command set for VoiceOver, NVDA, and Orca, so you can go wild with more realistic journeys, e.g. navigating by headings, landmarks, and links.

For your own tests you can probably drop some of the log() logic, it's there so I can easily dump the output for this post šŸ˜„ - talking of which here's the log output from a macos-latest GitHub actions run (which automatically uses VoiceOver):

> playwright test bbc.test.ts

Running 1 tests using 1 worker
ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¬ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”
│ (index)             │ Values             │
ā”œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¼ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¤
│ osName              │ 'darwin'           │
│ osVersion           │ '25.6.0'           │
│ browserName         │ 'webkit'           │
│ browserVersion      │ '27.2'             │
│ screenReaderName    │ 'VoiceOver'        │
│ screenReaderVersion │ '0.0.1-VoiceOver4' │
│ retry               │ 0                  │
ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”“ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜
[Sat, 24 Oct 2026 16:32:37 GMT] Last spoken phrase: BBC navigation
[Sat, 24 Oct 2026 16:32:38 GMT] Last spoken phrase: link BBC Homepage
[Sat, 24 Oct 2026 16:32:38 GMT] Last spoken phrase: list 2 items
[Sat, 24 Oct 2026 16:32:38 GMT] Last spoken phrase: link Skip to content 1 of 2
[Sat, 24 Oct 2026 16:32:39 GMT] Last spoken phrase: link Accessibility Help 2 of 2
[Sat, 24 Oct 2026 16:32:39 GMT] Last spoken phrase: end of list
[Sat, 24 Oct 2026 16:32:39 GMT] Last spoken phrase: list 1 item
[Sat, 24 Oct 2026 16:32:40 GMT] Last spoken phrase: link Sign in
[Sat, 24 Oct 2026 16:32:40 GMT] Last spoken phrase: end of list
[Sat, 24 Oct 2026 16:32:40 GMT] Last spoken phrase: list 7 items
[Sat, 24 Oct 2026 16:32:40 GMT] Last spoken phrase: link Home 1 of 7
[Sat, 24 Oct 2026 16:32:41 GMT] Last spoken phrase: link News 2 of 7
[Sat, 24 Oct 2026 16:32:41 GMT] Last spoken phrase: link Sport 3 of 7
[Sat, 24 Oct 2026 16:32:41 GMT] Last spoken phrase: link Earth 4 of 7
[Sat, 24 Oct 2026 16:32:42 GMT] Last spoken phrase: link Reel 5 of 7
[Sat, 24 Oct 2026 16:32:42 GMT] Last spoken phrase: link Worklife 6 of 7
[Sat, 24 Oct 2026 16:32:43 GMT] Last spoken phrase: link Travel 7 of 7
[Sat, 24 Oct 2026 16:32:43 GMT] Last spoken phrase: end of list
[Sat, 24 Oct 2026 16:32:43 GMT] Last spoken phrase: More menu menu pop up button
[Sat, 24 Oct 2026 16:32:44 GMT] Last spoken phrase: 
[Sat, 24 Oct 2026 16:32:44 GMT] Last spoken phrase: link Future 2 of 6
[Sat, 24 Oct 2026 16:32:44 GMT] Last spoken phrase: link Music 3 of 6
[Sat, 24 Oct 2026 16:32:45 GMT] Last spoken phrase: link TV 4 of 6
[Sat, 24 Oct 2026 16:32:45 GMT] Last spoken phrase: link Weather 5 of 6
[Sat, 24 Oct 2026 16:32:45 GMT] Last spoken phrase: link Sounds 6 of 6
[Sat, 24 Oct 2026 16:32:47 GMT] Last spoken phrase: 
[Sat, 24 Oct 2026 16:32:47 GMT] Spoken phrase log: ["banner","BBC navigation","link BBC Homepage","list 2 items","link Skip to content 1 of 2","link Accessibility Help 2 of 2","end of list","list 1 item","link Sign in","end of list","list 7 items","link Home 1 of 7","link News 2 of 7","link Sport 3 of 7","link Earth 4 of 7","link Reel 5 of 7","link Worklife 6 of 7","link Travel 7 of 7","end of list","More menu menu pop up button","","link Future 2 of 6","link Music 3 of 6","link TV 4 of 6","link Weather 5 of 6","link Sounds 6 of 6",""]
Enter fullscreen mode Exit fullscreen mode

What's next

  • Start with your static analysis tests such as Axe for a base level
  • Have a think about what critical user journeys you have
  • Test them out manually with some key OS + browser + screen reader combos
  • Fix any issues
  • Then convert your manual test to Guidepup based tests to cover you from future regressions!

Same as mouse and keyboard regression tests, you can't test everything, but a small set of golden path tests for your core features can go a long way to ensuring your site can work for screen reader users!

The real benefit here is catching accessibility regressions before they reach your users. Once you've manually tested a critical user journey and automated it with Guidepup, you can run it as part of your existing test suite every time you make a change. If a button's announcement changes, a link becomes harder to find, or a journey stops working as expected, your tests can flag it before it ships.

Think you want to go further? With the dawn of Agentic AI I am curious what level of exploratory and/or chaos testing you can achieve to battle test a site? Maybe give it a go and report back? šŸ˜‰


Want the code? Check out the BBC Homepage screen reader demo in GitHub.

Top comments (2)

Collapse
 
marcusykim profile image
Marcus Kim •

With the /More menu/ loop, I'd add the reverse journey before navigating away: expand it, move into it, press Escape and check that the screen reader cursor returns to the trigger and announces a collapsed menu. Reaching Sounds proves forward navigation, but a menu can still strand a keyboard user when dismissed. I'd keep that semantic expectation per screen-reader/browser pair rather than requiring identical spokenPhraseLog text across VoiceOver, NVDA and Orca.

Collapse
 
craigmorten profile image
Craig Morten •

Sounds great - this is just a toy example, but where I hope teams would go is exploring the real problem hotspots: focus traps, modal behaviours, and live regions to name a few - where screen reader testing is the only way I know of currently able to assert on live regions behaviours properly!