Matchers
An ergonomic expect layer over the assertions and snapshots — the same checks, but read as native matchers with .not negation and clean failure messages. This is the jest-axe-style surface of the package.
Shipped from a separate, opt-in entry so the main package stays side-effect-free:
import { registerA11yMatchers } from "@real-a11y-dev/testing/matchers";Not the same as jest-axe. jest-axe runs a WCAG rule engine and reports violations. These matchers assert structure and capture snapshots of the semantic tree — including focus order and modal scoping, which a rule engine doesn't model. The two compose well in one suite. See Accessibility Snapshots for the distinction.
Setup
Registration is opt-in (the jest-axe pattern): call registerA11yMatchers(expect) once from a setup file. Nothing runs on import.
Vitest
// vitest.setup.ts
import { expect } from "vitest";
import { registerA11yMatchers } from "@real-a11y-dev/testing/matchers";
import "@real-a11y-dev/testing/matchers/vitest"; // types-only augmentation
registerA11yMatchers(expect);// vitest.config.ts
export default defineConfig({
test: {
environment: "jsdom",
setupFiles: ["./vitest.setup.ts"],
},
});Jest
// jest.setup.ts
import { registerA11yMatchers } from "@real-a11y-dev/testing/matchers";
import "@real-a11y-dev/testing/matchers/jest"; // types-only augmentation
// `expect` is the Jest global. If you import it from "@jest/globals" instead,
// import "…/matchers/jest-globals" — see below.
registerA11yMatchers(expect);// jest.config.cjs
module.exports = {
preset: "ts-jest",
testEnvironment: "jsdom",
setupFilesAfterEnv: ["<rootDir>/jest.setup.ts"],
};TypeScript module resolution (Jest + ts-jest)
The /matchers subpath is exposed through the package exports field, so TypeScript must read it. Use "moduleResolution": "Node16" (or "NodeNext" / "Bundler") — the legacy "node" resolver ignores exports and the import won't resolve. ts-jest also needs "isolatedModules": true under Node16.
Assertion matchers
Each wraps the matching assert* function. They take a DOM root (e.g. container, document.body).
| Matcher | Asserts |
|---|---|
toHaveNoUnlabeledInteractive() | Every interactive node has a non-empty accessible name. |
toHaveValidHeadingOrder() | Exactly one <h1> and no skipped heading levels. |
toHaveLabeledDialogs() | Every dialog / alertdialog has an accessible name. |
toHaveValidLandmarks() | Exactly one main; at most one banner / contentinfo. |
expect(container).toHaveNoUnlabeledInteractive();
expect(container).toHaveValidHeadingOrder();
expect(container).toHaveLabeledDialogs();
expect(container).toHaveValidLandmarks();
// Negation comes for free
expect(brokenContainer).not.toHaveValidLandmarks();On failure the matcher surfaces the underlying assertion's message:
expect(element).toHaveNoUnlabeledInteractive()
Found 1 accessibility issue:
- Unlabeled interactive element: button <button>toBeValidA11yTree()
Asserts the extracted accessibility tree has no ARIA errors — invalid roles, roles the browser discards, missing required accessible names, and relationship violations (interactive nesting, presentational-children misuse). Backed by @real-a11y-dev/validate. Advisory warnings don't fail it — only errors do.
expect(container).toBeValidA11yTree();
// Negation asserts the tree *does* have an ARIA error
expect(brokenContainer).not.toBeValidA11yTree();It judges what you authored, not what the platform gives you. A role that comes from the element rather than a role= attribute is never reported as an invalid role, and an ARIA state the browser supplies never counts as a missing required attribute — so native controls pass without you writing the ARIA the spec nominally requires:
// Passes. `combobox` is the element's own role, and the browser supplies the
// aria-expanded / aria-controls that an authored combobox would owe.
<select aria-label="Status"><option>One</option></select>
// Passes. The datalist makes the input a combobox, and its popup is the
// browser's own, so there is no aria-expanded / aria-controls for you to write.
<input aria-label="Fruit" list="fruits"><datalist id="fruits">…</datalist>
// Passes. Browser-supplied checkedness, so no aria-checked needed.
<input type="checkbox" aria-label="Agree">
// Passes. The role is authored, but the browser supplies the expanded
// state of a button that invokes a popover: whether the popover shows.
<button role="combobox" popovertarget="sizes" aria-controls="sizes" aria-label="Size"></button>
<div id="sizes" popover role="listbox" aria-label="Sizes"></div>
// Fails — missing required aria-controls, aria-expanded.
// The role is authored, so the attributes are yours to supply.
<div role="combobox">One</div>Note what is not exempted: the <select> above still needs an accessible name. Dropping aria-label fails with role "combobox" requires an accessible name, because a label is an authoring obligation no user agent can invent.
The distinction is per-attribute rather than per-element, since an element can supply one state and still owe another — <input type="checkbox" role="switch"> has an authored role and browser-supplied checkedness, and passes.
An authored role that repeats the element's own counts as the element's own. A <select>'s role depends on how many rows it shows: it is a listbox when its size is greater than 1, or when it is multiple with no size. Otherwise it is a combobox, and that includes <select multiple size="1">, which Chromium renders as a drop-down. So role="listbox" on <select size="3"> is redundant. role="combobox" on that select changes its role, and its options are then reported as controls nested inside a combobox.
The same rule is why <video controls> no longer fails: its extracted role is engine vocabulary rather than an ARIA role, and only an authored role can be invalid ARIA.
A role the browser discards is still yours. The tree shows the role Chromium applies, so an unrecognised token or an item outside its container falls back to the element's own role — often a generic that folds out of the view. The matcher reports what you wrote all the same:
// Fails — generic "Close" — "foo" is not a valid ARIA role
<div role="foo">Close</div>
// Fails — generic "Item" — role "listitem" is discarded outside its required context (directory / list)
<div role="listitem">Item</div>
// Fails — the <section> ends the search for a listbox, so this is no option.
<div role="listbox" aria-label="Fruit"><section><div role="option" aria-selected="false">Apple</div></section></div>A discarded role is an error, not an advisory warning: assistive tech gets a different role from the one you wrote. See Roles for which containers count.
Unlike the four matchers above, this one doesn't wrap an assert* function — it runs the semantic tree through @real-a11y-dev/validate and fails only on severity: "error" issues.
toHaveTabSequence(expected)
Asserts the computed Tab order equals an array of role "name" tokens, in the order a user would encounter pressing Tab (positive tabindex first).
expect(container).toHaveTabSequence([
'link "Home"',
'link "About"',
'textbox "Search"',
'button "Go"',
]);This is the assertion form of tabSequenceSnapshot. It's especially useful for focus-trap checks — when a modal is open, the sequence collapses to just the dialog's controls:
await flow(container).findByRole("button", { name: "Delete account" }).click();
expect(container).toHaveTabSequence(['button "Cancel"', 'button "Delete"']);Names are matched with typographic punctuation normalized, so a plain token like button "Don't save" matches a label the page renders with a curly apostrophe (Don’t). Curly quotes, the ellipsis character, en/em dashes, and non-breaking spaces all fold to their ASCII forms for the comparison — you never have to paste smart quotes to make a token match. (Roles and everything else compare exactly; only accessible-name typography is folded.)
toMatchA11yContract(contract, options?)
Asserts the tree satisfies an authored contract — a partial a11y tree, written in the same role "name" (level N) grammar the snapshots use. Think of it as the toMatchObject of accessibility trees: unlike a full snapshot, the contract lists only the nodes you care about, and extra nodes in the implementation are allowed.
// The tree must CONTAIN this structure, in this order, nested this way —
// but a skip link, a cookie banner, or extra wrappers don't break it.
expect(container).toMatchA11yContract(`
main
heading "Sign in" (level 1)
form "Sign in"
textbox "Email address"
textbox "Password"
button "Sign in"
link "Forgot password?"
`);Matching is containment with ancestor semantics:
- every contract node must appear with the same role (and name /
(level N)/[focused]when the contract specifies them — an omitted name matches any name); - each node must sit somewhere under its contract parent's match — intermediate wrappers in the implementation are fine, so
textbox "Email address"matches even when the real markup nests it in agroup "Credentials"; - contract nodes must appear in document order;
- extra target nodes are always allowed.
That resilience is the point: a contract survives the noise a real page accumulates, so it stays green through cosmetic churn and fails only on a structural regression — a <button> shipped as a <div onclick>, a heading demoted, a field that lost its label. When it fails, the message pinpoints the first missing node and why:
a11y contract not satisfied: matched 5/7 nodes.
✓ main (line 5)
✓ heading "Sign in" (level 1) (line 6)
✓ form (line 7)
✓ textbox "Email address" (line 9)
✓ textbox "Password" (line 10)
✖ button "Sign in" ← NOT FOUND
· link "Forgot password?"
✖ button "Sign in": not found under form (matched line 7), after textbox "Password" (line 10).Received can be a DOM Element (extracted on the spot) or an already-serialized tree string (a committed treeSnapshot artifact). Names fold typographic punctuation like toHaveTabSequence does, so a contract typed with plain quotes matches curly-quote labels. A contract may carry # comments and a --- frontmatter block (e.g. a source URL) — both are ignored for matching.
{ strict: true } switches to exact tree equality — the contract behaves like a committed snapshot baseline (and, unlike containment, compares names byte-exact). Use it only where you truly render a component in isolation and nothing else should be present.
// containment: everything listed must be present (default)
expect(container).toMatchA11yContract(contract);
// strict: the tree must equal the contract exactly, nothing more
expect(inIsolation).toMatchA11yContract(contract, { strict: true });Because a serialized string is accepted, the same contract can be checked against a committed snapshot artifact — not only a live test render.
boxedTreeSnapshot(root, options?) — snapshot serializer
Wrap a DOM root (or a pre-extracted tree) so toMatchSnapshot() / toMatchInlineSnapshot() render the semantic tree instead of a DOM dump — fully native to each framework's snapshot tooling (-u/--update, obsolete detection all work).
import { boxedTreeSnapshot } from "@real-a11y-dev/testing/matchers";
expect(boxedTreeSnapshot(container)).toMatchSnapshot();main
heading "Sign in" (level 1)
form "Sign-in form"
textbox "Email"
button "Sign in"It accepts the same options as treeSnapshot — including redact to mask dynamic text (timestamps, IDs, prices) so the snapshot stays stable in CI:
expect(
boxedTreeSnapshot(container, { redact: [/\d{4}-\d{2}-\d{2}/g] }),
).toMatchSnapshot();See redact for the full pattern reference and the g-flag gotcha.
registerA11yMatchers registers the serializer for you. To register it on its own (e.g. via a framework's snapshotSerializers config), it's exported as a11ySnapshotSerializer.
boxedTreeSnapshot vs treeSnapshot
Both render the same tree — under the hood they call the same serializer with the same options (mode, redact, includeGeneric, values). The only difference is what they hand back, and therefore how the framework treats it:
treeSnapshot()returns a plain string. The value your test holds is the tree, so you can assert on it directly —expect(s).toContain('button "Save"'),expect(s1).toBe(s2)— log it, or write it to a file. It needs no setup.boxedTreeSnapshot()returns an opaque boxed value that the registered serializer renders at snapshot time. It does nothing on its own, but it keepstoMatchSnapshot()/toMatchInlineSnapshot()fully native — and crucially, inline snapshots stay readable: the tree is rendered as-is instead of escaped into a quoted string literal.
Reach for treeSnapshot when… | Reach for boxedTreeSnapshot when… |
|---|---|
| You want the string itself — substring assertions, comparing two trees, writing a CI artifact | You're committing the tree with toMatchSnapshot() / toMatchInlineSnapshot() |
| You don't want to register a serializer | You want clean, unquoted output — especially for inline snapshots |
When in doubt: treeSnapshot for "I need the string," boxedTreeSnapshot for "I'm storing a snapshot of the tree."
Modal scoping in snapshots
Because the snapshot reflects the extracted a11y tree, an open dialog scopes the snapshot — content behind a modal is inert to assistive tech, so it drops out and the snapshot captures only the dialog. That makes the snapshot a precise regression artifact for the modal state, not just the page.
Rules, contracts, and snapshots — which to reach for
Three of these matchers assert about the same tree but answer different questions, so it's worth being precise about when each earns its place.
| Dimension | Rule-basedtoBeValidA11yTree, toHaveNoUnlabeledInteractive, … | ContracttoMatchA11yContract | SnapshotboxedTreeSnapshot + toMatchSnapshot |
|---|---|---|---|
| Asks | "Is this markup legal?" | "Is this the markup I meant?" | "Did anything change?" |
| Source of truth | ARIA rules | A spec you author | The last recording |
| Authoring cost | none | you write the contract | none (-u records it) |
| Scope | whole tree, blanket | only the nodes you list | whole tree, total |
| Churn on cosmetic change | no | no | yes |
Rules and contracts catch disjoint bugs
The most important thing to internalize: toBeValidA11yTree() and toMatchA11yContract() are orthogonal. Each passes where the other fails.
Valid ARIA, but violates your intent — a rule can't help you:
<a href="/signin">Sign in</a> <!-- you meant a button -->toBeValidA11yTree() passes: a named link is perfectly legal ARIA, and nothing in the spec says this is wrong. toMatchA11yContract('button "Sign in"') fails: you specified a button. Validity can't know your intent.
Matches your intent, but invalid ARIA — a contract can't help you:
<main>
<button>Save</button>
<button aria-label=""></button> <!-- unnamed -->
</main>toMatchA11yContract('main\n button "Save"') passes: containment only checks the nodes you listed, and the extra button is an allowed extra. toBeValidA11yTree() fails: button requires an accessible name. A contract can't catch what you never thought to specify.
In practice
- Rules everywhere.
toBeValidA11yTree()is a blanket floor with zero authoring cost — apply it broadly. - A contract for the flows where structure is the requirement — a login form, a checkout step, a modal's focus scope. The rule catches "this is broken ARIA"; the contract catches "this is valid ARIA that's no longer the thing we designed."
- A snapshot when you want total coverage of a stable surface and accept the churn. Contracts are the deliberate middle ground: more intentional than a snapshot (a failure always means something you chose to care about), more expressive than a rule (it knows your design, which no rule engine can infer).
Vitest vs Jest type augmentation
The matcher signatures are identical, and so is the wiring — one runtime call, one types-only import, differing only in which runner you name:
| Aspect | Vitest | Jest |
|---|---|---|
| Runtime registration | registerA11yMatchers(expect) | registerA11yMatchers(expect) |
| Type augmentation | import "@real-a11y-dev/testing/matchers/vitest" | import "@real-a11y-dev/testing/matchers/jest" |
Jest has two expects, and they have separate type surfaces. If you import { expect } from "@jest/globals" rather than using the global that @types/jest declares, import @real-a11y-dev/testing/matchers/jest-globals instead — ./matchers/jest augments jest.Matchers and does not reach it. @testing-library/jest-dom splits its own augmentation the same way.
Each is a separate entry point because a type augmentation you cannot opt out of is a type augmentation that lands in projects that did not want it. Jest's used to ship unconditionally from ./matchers — a global namespace jest merge needs no module resolution, so it looked free. It was not: Vitest's Assertion extends JestAssertion, which extends jest.Matchers, so a Vitest user importing ./matchers received every matcher name twice, from two augmentations that disagreed about what the matchers return. Under skipLibCheck: false on TypeScript 7 that is one TS2320 per matcher, reported against a file inside node_modules; on TypeScript 5.x, or under the default skipLibCheck: true, it is silent — and silent is not healthy, it is the Jest declaration quietly winning the merge.
Importing more than one entry is redundant but harmless. That is true because the augmentations now agree that every matcher returns void, which is what Vitest and Jest both say about their own; making them agree is the fix, and keeping them separate is what stops a consumer carrying the other runner's types at all.
None of these entries ships runtime code — importing one is a types-only statement, and registerA11yMatchers(expect) remains what actually installs the matchers.
See it running
examples/testing-vitest—matchers-basic.test.ts(simple) andmatchers-account-page.test.ts(a full a11y gate +flow()-driven modal)examples/testing-jest— the minimal Jest + ts-jest setup
See also
- Assertions and Snapshots — the functions these matchers wrap
- Accessibility Snapshots — the concept and why it complements rule-based audits