@real-a11y-dev/testing
TL;DR — Accessibility-tree snapshots, structural assertions, custom
expectmatchers, and a fluentflow()interaction chain. Works in jsdom (Vitest / Jest) out of the box; add@real-a11y-dev/testing/playwrightfor real-browser E2E. Reach for this in your test suite — unit and e2e alike.
Headless accessibility audit helpers for Vitest, Jest, and Playwright. No browser required for the core helpers — they work in jsdom.
Install
This package brings no test runner and no DOM of its own — it audits a DOM you already have. So install it next to a runner and a DOM implementation:
npm install -D @real-a11y-dev/testing vitest jsdomnpm install -D @real-a11y-dev/testing jest jest-environment-jsdomNothing else is required. @testing-library/react appears throughout these docs because it is the common way to get a container, but it is optional — any Element works as an audit root, including one you built by hand.
Your first passing test
Two files, no framework. Copy both and it runs.
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
// Required. Without it the helpers get no `document` and every test fails
// with `document is not defined`.
environment: "jsdom",
},
});import { expect, test } from "vitest";
import { treeSnapshot, assertNoUnlabeledInteractive } from "@real-a11y-dev/testing";
test("the sign-in form is labeled", () => {
document.body.innerHTML = `
<main>
<h1>Sign in</h1>
<label>Email <input name="email" /></label>
<button>Continue</button>
</main>
`;
const root = document.querySelector("main")!;
assertNoUnlabeledInteractive(root);
expect(treeSnapshot(root)).toMatchSnapshot();
});npx vitest runThe committed snapshot is the accessibility tree, not a DOM dump:
main
heading "Sign in" (level 1)
textbox "Email"
button "Continue"Delete the <label> wrapper and assertNoUnlabeledInteractive throws naming the offender; rename the button and the snapshot diff is one line.
The same, in Jest
jest.config.js needs the environment for the same reason:
module.exports = { testEnvironment: "jsdom" };The assertions are identical. The one difference that will stop you: Jest does not parse TypeScript on its own. A .ts test fails with a Babel parse error until you add ts-jest or babel-jest, so the transform-free quick-start is a .js file:
// a11y.test.js
const { treeSnapshot, assertNoUnlabeledInteractive } = require("@real-a11y-dev/testing");
test("the sign-in form is labeled", () => {
document.body.innerHTML = `<main><h1>Sign in</h1><label>Email <input /></label><button>Continue</button></main>`;
const root = document.querySelector("main");
assertNoUnlabeledInteractive(root);
expect(treeSnapshot(root)).toMatchSnapshot();
});Same tree, same snapshot. Keeping your tests in TypeScript is fine — add ts-jest and its preset the way you would for any Jest project; nothing about this package changes.
Adding the expect matchers
The matchers need one setup file, registered with your runner (setupFiles in Vitest, setupFilesAfterEnv in Jest):
import { expect } from "vitest";
import { registerA11yMatchers } from "@real-a11y-dev/testing/matchers";
import "@real-a11y-dev/testing/matchers/vitest"; // types
registerA11yMatchers(expect);The second import is types-only, and there is one per runner: ./matchers/vitest, ./matchers/jest for Jest's global expect, and ./matchers/jest-globals if you import { expect } from "@jest/globals" — which is a different type surface.
Auditing a real browser page instead of jsdom? That path skips all of the above — see the Playwright adapter. Peer-version specifics for React, Testing Library and Playwright live in Peer dependencies.
What's in the box
| Area | What it does | Page |
|---|---|---|
| Snapshots | Deterministic strings of the a11y tree, heading outline, and tab order — diff-friendly, safe to commit. | Snapshots → |
| Assertions | assert* functions that throw descriptive errors on broken structure. | Assertions → |
| Matchers | The same checks as ergonomic expect matchers, plus the boxedTreeSnapshot() serializer. Vitest + Jest. | Matchers → |
| Flow API | Fluent interaction chains that assert about the tree after each step. | Flow API → |
| Playwright adapter | Run every helper against a real browser page via attach(page). | Playwright → |
New to the idea of snapshotting the accessibility tree? Start with the concept: Accessibility Snapshots.
Which do I reach for?
- Catch regressions in CI → Snapshots (
treeSnapshot,outlineSnapshot,tabSequenceSnapshot, plusnumberTabStopsfor a human-read listing) committed withtoMatchSnapshot(). For headless page-set audits of a deployed site — no test suite — reach for@real-a11y-dev/cli'ssnapshot/diffinstead. - Assert a specific invariant ("one
<h1>", "no unlabeled buttons") → Assertions or, forexpectstyle, Matchers.assertRulesruns an arbitrary subset of the rule set, andformatFindingsrenders findings for your own reporting. - Test an interaction (open a menu, submit a form, dismiss a modal) → Flow API.
- Assert what an interaction changed (options appeared,
aria-expandedflipped, focus moved) →capture+a11yDifforflow().expectChanges. - Audit a real, rendered page (not jsdom) → Playwright adapter.
See it running
- Vitest + jsdom —
examples/testing-vitest/: snapshot tests, the custom matchers,flow()interactions, tab-sequence structure assertions. - Jest + ts-jest —
examples/testing-jest/: the minimal Jest setup for the matchers. - Playwright E2E —
examples/playwright/: a "good fixture" where every assertion passes and a "broken fixture" where each throws — the pattern to keep in CI. - CI tree-diff bot — the CI Diff Bot recipe runs
@real-a11y-dev/cli'sreal-a11y snapshot(each audited page → one diffable JSON artifact) andreal-a11y diff(new / changed / fixed findings) in a PR workflow.