Usage with test-matchers

For unit and component tests — assert accessibility inline in a Jest or Vitest suite, against whatever DOM the test already has, rather than driving a real or headless browser. If you're testing full rendered pages instead, see the browser-driven bindings.

@surea11y/test-matchers gives you toHaveNoA11yViolations(), plus toHaveAnnounced() for live regions. Both plug into either framework's expect.extend() unmodified, with no framework-specific code of their own. It scans whatever jsdom-based environment your test already runs under; it doesn't depend on jsdom itself or set one up for you. surea11y's engine is synchronous, so this matcher is too — no await, no forgetting one and silently passing on a resolved promise.

$ npm install --save-dev @surea11y/core @surea11y/test-matchers

@surea11y/core is the engine the matcher runs; install it with the matcher, as above. To check built HTML from the terminal or a CI step instead, outside a test runner, see the CLI.

Setup

Your test environment must already be jsdom-based — this matcher scans whatever document/window it provides. Register the matcher once, in whichever setup file your test runner already loads:

Jest

// jest.setup.js
expect.extend({
toHaveNoA11yViolations: require('@surea11y/test-matchers').toHaveNoA11yViolations
});
 
// jest.config.js
module.exports = {
testEnvironment: 'jsdom', // or the explicit jest-environment-jsdom package, depending on your Jest version
setupFilesAfterEnv: ['./jest.setup.js']
};

Vitest

// vitest.setup.js
import { expect } from 'vitest';
import { toHaveNoA11yViolations } from '@surea11y/test-matchers';
 
expect.extend({ toHaveNoA11yViolations });
 
// vitest.config.js
export default {
test: {
environment: 'jsdom', // Vitest doesn't bundle jsdom -- install it as a devDependency too
setupFiles: ['./vitest.setup.js']
}
};

The bundled .d.ts augments both Jest's global jest.Matchers interface and Vitest's @vitest/expect interfaces, with no @types/jest or vitest import required to ship it — whichever framework is actually installed, only the matching augmentation takes effect.

Usage

Works against a plain DOM node:

test('no accessibility violations', () => {
document.body.innerHTML = '<img src="logo.png">';
 
expect(document.body).toHaveNoA11yViolations(); // fails: missing alt
});

...or any testing-library container — React Testing Library's, Vue Test Utils' wrapper.element, Angular Testing Library's, Svelte Testing Library's — since all of them expose a rendered DOM element the same way:

const { render } = require('@testing-library/react');
 
test('MyComponent has no accessibility violations', () => {
const { container } = render(<MyComponent />);
 
expect(container).toHaveNoA11yViolations();
});

This is framework-agnostic on the test-runner side too — the snippets above are identical whether they run under Jest or Vitest, once the matcher is registered.

Asserting .not

test('a page with a violation fails the assertion', () => {
document.body.innerHTML = '<main><img src="logo.png"></main>';
 
expect(document.body).not.toHaveNoA11yViolations();
});

Reusing one scan across multiple assertions

If you're already computing a surea11y scan result yourself — to assert on specific rules across several it() blocks without re-scanning each time — pass the result object directly instead of a DOM node:

const { runDomRulesInPage } = require('@surea11y/core');
 
const result = runDomRulesInPage(null, '#main', {}, null);
expect(result).toHaveNoA11yViolations();

Filtering and configuring scans

The second argument accepts anything @surea11y/core accepts — see Engine options for the full surface. A few common cases:

// Only run rules relevant to WCAG 2.0 A
expect(container).toHaveNoA11yViolations({ tags: { include: 'wcag2a' } });
 
// Skip a rule you've decided not to enforce yet
expect(container).toHaveNoA11yViolations({ rules: { exclude: 'target-size-minimum' } });
 
// Ignore a third-party widget you don't control
expect(container).toHaveNoA11yViolations({ excludeSelectors: ['.intercom-launcher'] });
 
// EN 301 549 V4.1.1 (WCAG 2.2 A and AA); 'en301549-v3.2.1' for V3.2.1 (WCAG 2.1 A and AA)
expect(container).toHaveNoA11yViolations({ profile: 'en301549-v4.1.1' });

Choosing a WCAG target (runOnly)

The third argument is the scan's runOnly, passed to the engine as it is. runOnly.wcag selects by conformance target: { wcag: { version: '2.2', level: 'AA' } } runs the rules for WCAG 2.2's Level A and AA criteria, by the level each criterion has in that version. bestPractices: true adds the rules that name no WCAG criterion, such as region and heading-order; on its own it runs only those, and beside engineOptions.profile it adds them to the profile's rules.

// WCAG 2.2 A and AA
expect(container).toHaveNoA11yViolations({}, { wcag: { version: '2.2', level: 'AA' } });
 
// The same, and the best practices
expect(container).toHaveNoA11yViolations({}, { wcag: { version: '2.2', level: 'AA' }, bestPractices: true });

runOnly also takes rule ids and tags (includeRuleIds, tags, excludeRuleIds, ...), which add to a target's rules; the excludes apply after. A runOnly that selects anything replaces the selection in engineOptions.rules/.tags outright: give a selection one way or the other, not both. Pass {} or null as engineOptions when you only need runOnly. Rule ids are read in any case ('IMG-ALT-PRESENT').

A target implies its level. A test that names rules or tags instead can set one with engineOptions.wcagLevel (@surea11y/core 1.12.0 and later): the level of the criteria the scan reports, without changing which rules run. contrast-computable checks 1.4.3 (AA) and 1.4.6 (AAA), so the scan below reports only 1.4.3. The assertion gates on fail outcomes, so this changes the criteria in the result rather than what fails the test.

expect(container).toHaveNoA11yViolations({ wcagLevel: 'AA' }, { includeRuleIds: ['contrast-computable'] });

Scanning with packs

A pack brings rules, a standard or a checklist, and their profiles and messages, from a package of its own. The matcher scans in Node, so a pack is passed as the object itself, in engineOptions.packs, and its profile is chosen with engineOptions.profile:

const acmePolicy = require('./acme-policy-pack');
const policy = { packs: [acmePolicy], profile: 'acme-web-2026' };
 
test("a page that breaks the pack's house rule fails the assertion", () => {
document.body.innerHTML = '<main><h1>Welcome</h1><video autoplay src="intro.mp4"></video></main>';
expect(document).not.toHaveNoA11yViolations(policy);
});
 
// A pack's checklist profile, and the best practices
expect(container).toHaveNoA11yViolations(policy, { bestPractices: true });

A pack's own rules appear in the result like core's, with ids that start with its namespace (acme-no-autoplay). Keep the pack objects in one place (a setup file or a module): core prepares an engine for each set of pack objects and keeps it for the next scan with the same ones. A pack core can't run (its core range leaves out the installed core, it isn't valid, or its namespace clashes with another pack's) is listed in the result's skippedPacks, and the assertion fails on it rather than passing with the pack's rules never run.

With @surea11y/core 1.15 and test-matchers 1.5.0, profile also takes a list, to judge the component by several profiles at once, a pack's beside WCAG's: each profile judges it as it would alone, and a failure under any of them fails the assertion. A profile in the list that didn't run fails it too, since the scan judged less than it was asked to; so does a single profile the scan didn't apply, which scans by core's default rules.

const waiAria = require('@surea11y/pack-wai-aria');
 
expect(container).toHaveNoA11yViolations({ packs: [waiAria], profile: ['wcag22-aa', 'wai-aria-1.2'] });

API

ParamTypeMeaning
receivedElement, Document, or scan resultWhat to check. A DOM Element/Document is scanned automatically; an object shaped like { checksResults: [...] } is asserted on directly, with no scan performed. Anything else throws a TypeError.
engineOptionsobject (optional)Passed straight through to @surea11y/core's scan — the same options object used everywhere else in the ecosystem (see Engine options). Ignored when received is already a scan result.
runOnlyobject, string or string[] (optional)Passed straight through as the scan's runOnly: a WCAG target ({ wcag: { version: '2.2', level: 'AA' } }), bestPractices, rule ids or tags. See Choosing a WCAG target. Ignored when received is already a scan result.
optionsobject (optional){ failOnSeverity, leanFail, wcagOnly, howToCheck }: see Failing only on serious problems and Questions that likely fail. Each overrides the default configureA11yMatchers() set.

The second argument can instead be one object holding them all, { engineOptions, runOnly, failOnSeverity, leanFail, wcagOnly, howToCheck }: toHaveNoA11yViolations({ leanFail: true }). It's read that way when it has any of those keys, none of which is an engine option; an object that mixes them with engine options throws a TypeError.

Returns the { pass, message } shape both frameworks' expect.extend() expect — you never call this directly; it's wired up once via expect.extend() as shown in Setup above. The package also exports configureA11yMatchers(options), for defaults across the suite; installInternalsHook(win?), for web components; and installListenerHook(win?), installLiveRegionHook(win?, options?) and the toHaveAnnounced() matcher, for click handlers and live regions.

What it checks and doesn't

Only failures gate the assertion — matching every other surea11y binding's convention that cantTell results are advisory, not failures. Each finding is read by its own outcome, not its rule's: a rule that fails one element can ask about another, and that question isn't counted as a failure. A vague link like <a href="/pricing">Click here</a> is cantTell, not fail, so it doesn't break a passing test on its own unless you ask for that with leanFail (below) — though when an assertion does fail for other reasons, the questions are listed after the failures, so they stay visible. See Known limitations for what this engine won't automate at all.

A scan that could not check what it was asked to fails too, rather than passing on nothing: an element that is not in the document the scan runs in (detached, or from another document), a custom rule that did not run (listed in skippedCustomRules), a pack that did not run (listed in skippedPacks), or a profile that did not run (listed in engine.profilesNotRun, or a single profile the scan didn't apply). The message names each with the reason. A custom rule the test excludes itself is not a gap, and doesn't fail it.

A call with no selection runs the best practices too. Since @surea11y/core 1.12, one of them, empty-heading, fails a heading with no accessible name where it used to ask about it, so a component that renders <h2></h2> (often a title prop left empty in the test) fails a default call. A screen reader announces that heading with nothing in it: give the test real content or fix the component. A call with a WCAG target, which leaves the best practices out, doesn't run it.

Since @surea11y/core 1.15, WCAG fails only where WCAG fails. Where only WAI-ARIA or the HTML content model is broken (an attribute the role doesn't support, an unnamed grid or dialog, a heading in a list), the ARIA and HTML structure rules ask (cantTell, uncertainty spec-only) instead of failing, so such a finding no longer fails the assertion. The failure message lists it with the questions, and it keeps its reason code. To fail on it by WAI-ARIA, add the WAI-ARIA pack's profile.

The scan itself can throw, and the test then fails with the engine's error, which carries a code: INVALID_RUN_ONLY (a rule or tag list in which no value names a rule or a tag, or a runOnly.wcag target that isn't one), INVALID_ENGINE_OPTIONS (with strictOptions: true, an option the engine doesn't read or a value it can't use), or PAGE_BUILTINS_BROKEN (the test's or the page's own code broke a JavaScript built-in the engine relies on).

Scoping to an element only reports what's inside it — a failure elsewhere on the page never fails an assertion scoped to one container. Under the hood the scanned element is temporarily tagged with a unique attribute and scanned by that selector, invisibly to your test.

A handful of rules check a whole-page fact, not anything in a specific subtree — page-title-present, html-lang-attr-present, and a few others check document.title/document.documentElement directly, since asking whether the page has a title makes no sense for an arbitrary component snippet. Passing an Element (a testing-library container, document.body) reports these notApplicable rather than fail, so a missing <title> never breaks a component-level test; passing document itself evaluates them for real. If you do scan document directly, set document.title/document.documentElement's lang once in your setup file rather than per test.

Failing only on serious problems

Every rule result has a severity: critical, serious, moderate or minor. To start by failing tests on serious problems while the rest are still reported, set a level for the whole suite in the setup file with configureA11yMatchers(), or for one assertion in its options, which override the default (failOnSeverity: null turns it off there):

// jest.setup.js (vitest.setup.js: the same, with import)
const { toHaveNoA11yViolations, configureA11yMatchers } = require('@surea11y/test-matchers');
 
expect.extend({ toHaveNoA11yViolations });
configureA11yMatchers({ failOnSeverity: 'serious' });
 
// one assertion
expect(container).toHaveNoA11yViolations({ failOnSeverity: 'critical' });

The failing rules below the level don't fail the test, but when it does fail, the message lists every failure and marks those below the level. It's the same gate as surea11y scan --fail-on-severity. A scan that left something out (a scope that matched nothing, a custom rule or pack that didn't run) fails the assertion whatever the level.

Questions that likely fail

A cantTell is a question for a person. Since @surea11y/core 1.14, a question can lean: where the page's evidence points one way, it says which (fail or pass) and why, and each question of a core rule carries the steps to answer it (howToCheck). A failure message lists the questions after the failures: those that likely fail, then those that likely pass, each with its reason, then the open ones, by rule. { howToCheck: true } lists every question in full, with its steps.

By default a question never fails the test. { leanFail: true } also fails it on the questions that likely fail, and wcagOnly: true leaves out those of best-practice rules:

test('the settings page has no failures or likely failures', () => {
const { container } = render(<Settings />); // an <h1>, then an <h3>
expect(container).toHaveNoA11yViolations(); // passes: heading-order is a question
expect(container).toHaveNoA11yViolations({ leanFail: true }); // fails: it leans fail
expect(container).toHaveNoA11yViolations({ leanFail: true, wcagOnly: true }); // passes: heading-order is a best practice
});

It's opt-in because a lean is evidence, not proof: whether a finding leans, and which way, may change in a minor release of core. configureA11yMatchers({ leanFail: true }) turns it on for the whole suite.

Web components

Many web components keep their ElementInternals in a private field and name themselves, set their states or give themselves a role through it. No script can read those back, so by default the scan asks about such an element (cantTell) instead of judging it: the test doesn't fail, but nothing is checked either, and a role set this way isn't seen at all.

installInternalsHook(win?) lets the scan read them. Call it once in the setup file, before any component is defined, so it sees every attachInternals() call; win defaults to the global window:

// jest.setup.js
const {
toHaveNoA11yViolations,
installInternalsHook
} = require('@surea11y/test-matchers');
 
installInternalsHook();
expect.extend({ toHaveNoA11yViolations });
// vitest.setup.js
import { expect } from 'vitest';
import {
toHaveNoA11yViolations,
installInternalsHook
} from '@surea11y/test-matchers';
 
installInternalsHook();
expect.extend({ toHaveNoA11yViolations });

With it, a component that names itself through its internals passes, one that attaches internals but sets no name fails like any unnamed control, and one that is a switch only through its internals is checked as one. A scan result's engine.environment.internalsHook says whether the hook was there. It wraps HTMLElement.prototype.attachInternals in the test window, which is harmless in a test and why it's opt-in. One limit: jest-environment-jsdom 30 ships jsdom 26, where a component that sets an ARIA property on its internals throws, hook or not (Vitest with jsdom 29 handles it). Since @surea11y/core 1.14 the hook is recorded as 'early' under Vitest too, so a component that never attached internals is no longer a question there. The package README has the details.

Click handlers and live regions

Two more opt-in hooks, from @surea11y/core 1.14, install the same way, in the setup file before any of your code runs. installListenerHook() records the click, key, focus and mouse listeners added from then on, so the keyboard rules judge them: a <div role="button"> that only listens for click fails (CLICK_HANDLER_NO_KEY_LISTENER), and passes once it also listens for keydown. installLiveRegionHook() records what the page's live regions are given: the scan asks about a region added with its text already in it, which screen readers commonly don't announce, and toHaveAnnounced() checks that an action told the user something. Install only the ones you want; each is independent:

// jest.setup.js (vitest.setup.js: the same, with import)
const {
toHaveNoA11yViolations,
toHaveAnnounced,
installInternalsHook,
installListenerHook,
installLiveRegionHook
} = require('@surea11y/test-matchers');
 
installInternalsHook();
installListenerHook();
installLiveRegionHook();
expect.extend({
toHaveNoA11yViolations,
toHaveAnnounced
});
test('saving says so', async () => {
const user = userEvent.setup();
const { container } = render(<SaveBar />);
await user.click(screen.getByRole('button', { name: 'Save' }));
 
expect(container).toHaveAnnounced('Saved');
expect(window).not.toHaveAnnounced(/error/i);
});

A string must be the whole text; a RegExp may match part of it. On an element, only the live regions it is or contains count; on window or document, every announcement since the hook was installed. It's what the page made available to announce, not what a screen reader said, and toHaveAnnounced() throws without the hook. Two things to know: React adds its listeners to the root it renders into, not to each element, so on a React component the scan sees the root's listeners and judges its buttons by their markup, as without the hook; and the live-region hook reads changes in batches, so render first and then act, with an await in between (await user.click()), as a real user does. The package README has the details.

// setup file
installLiveRegionHook(window, { assumeModal: true });
 
// in a test
expect(container).toHaveNoA11yViolations({ assumeModal: true });

A text given to a region behind a modal dialog isn't counted by toHaveAnnounced() unless you pass { includeBehindModal: true }, and the failure message lists it either way. Another option to the hook, or a value that isn't a boolean, throws a TypeError.

Failure messages

Lists every failing occurrence, not just every rule — one rule can flag several elements — with the rule ID, severity, a human-readable summary, the failing element's selector, and a fix hint where available. It names the @surea11y/core release that produced the result, to quote in a bug report, and then lists the questions, which don't count as failures. With a skipped heading level beside the image:

expected no accessibility violations, but found 1:
1) img-alt-present (serious): This <img> has no alt attribute.
at body > main > img
Add an alt attribute that says what the image shows, or alt="" if the image is purely decorative.
Scanned with @surea11y/core 1.16.1.
2 findings need manual review (cantTell, not counted as failures):
1) heading-order (minor, likely fails): This heading jumps from level 1 to level 3, skipping a level.
at body > main > h3
Go down one level at a time, so an <h2> is followed by an <h3> rather than an <h4>, and the outline of the page stays easy to follow.
Why it likely fails: The heading skips a level after the one before it.
Open questions, which only a person can answer: manual-review.

Before the version line, the message can carry notes on how the scan ran: when the scan asked about custom elements it could have read with the internals hook, or judged findings the listener or live-region hook would have decided, a line says how many and to install it. With { leanFail: true }, the questions that likely fail move up into the failures, and the header counts both (found 1 failure and 1 likely failure:).

A pack that did not run is named with the reason:

expected no accessibility violations, but the scan left out what it was asked to check:
No accessibility violations found in what was scanned.
Pack "@acme/a11y-policy" did not run: it supports core ^2.0.0, and this is core 1.16.1.
Scanned with @surea11y/core 1.16.1.

Runnable, side-by-side examples for both frameworks: Jest and Vitest, and the same with a pack: Jest and Vitest, and a small component checked with the listener and live-region hooks, toHaveAnnounced() and leanFail: Jest and Vitest. For the complete engineOptions surface (locale, contrast modes, shadow DOM, custom rules, WCAG-version tag combinations), see ENGINE_OPTIONS.md — this package forwards whatever you pass rather than duplicating that reference.