Accessibility test toolkit

Test with Vitest

a11ied/vitest gives Vitest a test whose sr fixture is a queued screen reader, so a test writes no await until it needs a value. It needs Vitest 4.0.0 or later.

Install

npm install --save-dev a11ied vitest

Vitest is an optional peer dependency of a11ied, range >=4.0.0, so the package installs and runs without it. a11ied/vitest exports test and it with the sr fixture, the three expect matchers, and everything a11ied/test exports.

The sr fixture

The exported test provides sr, a QueuedScreenReader. The fixture starts it from the srOptions fixture before each test and stops it after the test, whether the test passed or failed. Override the options for a file with test.extend({ srOptions: { url } }), or for a block with test.scoped.

import { test } from 'a11ied/vitest';

const checkout = test.extend({
   srOptions: { url: 'http://localhost:3000/checkout' },
});

srOptions takes the ScreenReaderOptions fields: sr (virtual by default, voiceover, or nvda), a url or inline html, mode, engine, recordingPath, timeoutMs, and idleTimeoutMinutes. The default mode, in-process, runs the screen reader inside the test process, so tests in parallel workers do not compete for the one active a1 sr session.

The command queue

A method call queues a command and returns at once. The queued commands run one at a time in the order they were queued. A test calls the methods with no await.

Await a call when the test needs its value, such as const item = await sr.read();. That await runs every command queued before it, then that call, and gives its value.

Each call returns a chain with every method on it, so commands and checks may follow one another with dots. expect starts a check, and and adds another. The chain checks what expectSpoken, expectSpokenInOrder, and expectCursorOn check, under the names spoken, spokenInOrder, and cursorOn.

sr.goTo({ role: 'button', name: 'Is it unstyled?' })
   .checkpoint('enter')
   .press('Enter')
   .expect.spoken(/expanded$/u, { since: 'enter' })
   .and.cursorOn({ role: 'button', name: 'Is it unstyled?' });

A check waits. It reads the reader again every 100 ms until it passes or timeoutMs runs out, 5000 ms by default, so an announcement or a focus move that lands a moment after the key that caused it still counts, and a test needs no pause before it. Pass timeoutMs: 0 to check once. A check with not never waits: absence now is its answer. A failure after waiting says how long it waited.

Await a call the test expects to fail, such as await expect(sr.expectSpoken('Refund')).rejects.toThrow('Refund');. The rejection goes to that await and the queue runs the next command.

When a command throws and nothing awaited it, every command queued after it is skipped and rejects with the same error, and the test fails. The fixture records each queued command on the test task. After the test function returns, Vitest awaits those and reports each rejection as an error of the test, with the message and a code frame at the line that queued the command. The error keeps its class, ScreenReaderAssertionError for a failed check, its message, and its expected, phrases, and exitCode fields.

A failed check in a test over a native <select> labeled Select country, run with the simulated screen reader, prints:

FAIL  combobox.test.ts > combobox component > announces active option and expanded state
ScreenReaderAssertionError: "option, Canada, 1 of 3" was not announced in the transcript (5 phrases checked).
The reader said:
  "document"
  "main"
  "Select country"
  "combobox, Select country, Australia, has popup listbox, not expanded"
  "option, Australia, not selected, position 1, set size 3"
 > combobox.test.ts:23:10
     21|       sr.expectSpoken('combobox, Select country');
     22|       sr.next();
     23|       sr.expectSpoken('option, Canada, 1 of 3');
       |          ^

Timeouts

Queued commands run inside Vitest's testTimeout, 5000 ms by default, and before afterEach. A test that queues a long sequence needs a longer testTimeout. Pass it as the third argument to one test, or set it in vitest.config.ts for the run. timeoutMs in srOptions bounds each single command.

When the test times out or the run is canceled, Vitest aborts the test's signal and the queue skips every command not yet started. The command already running finishes on its own.

Matchers

Importing a11ied/vitest installs three matchers on expect and adds their types.

toHaveSpoken(match, { since, not })An announcement matches. The failure message is the one expectSpoken throws.
toHaveSpokenInOrder([a, b, c])Each match is found after the previous one, with any announcements between. The failure says which match it did not find and which announcement it looked for after.
toHaveCursorOn({ role, name })The element under the cursor has the role, the name, or both. The failure says which element the cursor is on.

Given a ScreenReader or a QueuedScreenReader, each matcher reads the transcript first, so it returns a promise: await it. Given an array of announcements, transcript entries, or the element read() returned, it answers at once. The matchers check once. The reader methods expectSpoken, expectCursorOn, and expectSpokenInOrder check the same things without an await, and wait for them.

For an announcement the page makes later, pass the transcript to expect.poll. It retries the matcher until Vitest's poll timeout, 1000 ms by default, and takes the timeout and interval options. sr.wait({ for }) does the same from the reader.

await expect.poll(() => sr.transcript()).toHaveSpoken('Draft saved');

Example test file

The page is a combobox labeled Select country with three options, Canada first. Arrow Down from the collapsed combobox expands it and moves to the first option. The dev server runs on port 3000.

// combobox.test.ts
import { describe, expect } from 'vitest';
import { test } from 'a11ied/vitest';

const combobox = test.extend({
   srOptions: { url: 'http://localhost:3000/components/combobox' },
});

describe('combobox component', () => {
   combobox('announces active option and expanded state', ({ sr }) => {
      sr.press('Tab');
      sr.expectSpoken('combobox, Select country, collapsed');

      sr.press('ArrowDown');
      sr.expectSpoken('combobox, Select country, expanded');
      sr.expectSpoken('option, Canada, 1 of 3');
   });

   combobox('reports the option under the cursor', async ({ sr }) => {
      sr.press('Tab');
      sr.press('ArrowDown');

      const item = await sr.read();

      expect(item).toMatchObject({ role: 'option', name: 'Canada' });
   });
});

Vitest browser mode

In Vitest browser mode the test already runs in a real browser, so a11ied/browser starts the simulated screen reader on that page. There is no separate process, no second page load, and no open(): mount the component, then read it. The reader covers the document body, or container.

// vitest.config.ts
import { playwright } from '@vitest/browser-playwright';
import { defineConfig } from 'vitest/config';

export default defineConfig({
   test: {
      browser: {
         enabled: true,
         provider: playwright(),
         instances: [{ browser: 'chromium' }],
      },
   },
});
// pay-button.browser.test.ts
import { test } from 'a11ied/browser';
import { render } from 'vitest-browser-react';
import { PayButton } from './pay-button';

test('announces the price', ({ sr }) => {
   render(<PayButton amount="12.00" />);

   sr.next('button');
   sr.expectCursorOn({ role: 'button', name: 'Pay 12.00' });
});

The exported test provides an sr fixture over the document body, a QueuedScreenReader like the one in a11ied/vitest, and a11ied/browser installs the same three matchers. A test that starts its own reader over one mounted component calls queuedScreenReader({ container }), or screenReader({ container }) for the unqueued ScreenReader. @vitest/browser-playwright needs a Chromium that npx playwright install chromium provides.

Two methods are not available in browser mode. open() throws, because the page is the test's own. perform() throws too, because the command registry that resolves names loads Guidepup's Node-only tables. Every typed method is available.

Other runners

Under Jest, Mocha, Playwright Test, or a script, a11ied/test exports queuedScreenReader and screenReader and depends on no test runner. The queue rules above are the same there. await using, or await sr.stop() in the cleanup hook, runs every queued command, stops the reader, and rethrows a failure nobody awaited. See the testing guide.