Utilisation avec test-matchers

Pour les tests unitaires et de composants : vérifiez l’accessibilité directement dans une suite Jest ou Vitest, sur le DOM dont le test dispose déjà, plutôt que de piloter un navigateur réel ou headless. Si vous testez plutôt des pages entièrement rendues, voir les bindings pilotant un navigateur.

@surea11y/test-matchers vous donne toHaveNoA11yViolations(), plus toHaveAnnounced() pour les régions live. Les deux se branchent tels quels sur le expect.extend() de l’un ou l’autre framework, sans aucun code propre à un framework. Il analyse l’environnement basé sur jsdom dans lequel votre test tourne déjà ; il ne dépend pas lui-même de jsdom et n’en met pas en place pour vous. Le moteur de surea11y est synchrone, donc ce matcher l’est aussi : pas de await, et donc pas de risque d’en oublier un et de voir le test passer en silence sur une promesse résolue.

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

@surea11y/core est le moteur qu’exécute le matcher ; installez-le avec le matcher, comme ci-dessus. Pour vérifier du HTML compilé depuis le terminal ou une étape de CI, hors d’un lanceur de tests, voir la CLI.

Mise en place

Votre environnement de test doit déjà être basé sur jsdom : ce matcher analyse le document/window qu’il fournit. Enregistrez le matcher une seule fois, dans le fichier de setup que votre lanceur de tests charge déjà :

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']
}
};

Le .d.ts fourni étend à la fois l’interface globale jest.Matchers de Jest et les interfaces @vitest/expect de Vitest, sans qu’un import de @types/jest ou de vitest soit nécessaire pour le livrer : quel que soit le framework réellement installé, seule l’extension correspondante s’applique.

Utilisation

Fonctionne sur un simple nœud DOM :

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

… ou sur n’importe quel container de testing-library (celui de React Testing Library, le wrapper.element de Vue Test Utils, ceux d’Angular Testing Library et de Svelte Testing Library), puisque tous exposent un élément DOM rendu de la même façon :

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

Côté lanceur de tests aussi, rien ne dépend du framework : une fois le matcher enregistré, les extraits ci-dessus sont identiques sous Jest et sous Vitest.

Vérifier avec .not

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

Réutiliser une analyse pour plusieurs assertions

Si vous calculez déjà vous-même un résultat d’analyse surea11y (pour vérifier des règles précises dans plusieurs blocs it() sans relancer l’analyse à chaque fois), passez directement l’objet résultat au lieu d’un nœud DOM :

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

Filtrer et configurer les analyses

Le deuxième argument accepte tout ce qu’accepte @surea11y/core ; voir Options du moteur pour l’ensemble des options. Quelques cas courants :

// 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' });

Choisir une cible WCAG (runOnly)

Le troisième argument est le runOnly de l’analyse, transmis tel quel au moteur. runOnly.wcag sélectionne par cible de conformité : { wcag: { version: '2.2', level: 'AA' } } exécute les règles des critères de niveau A et AA de WCAG 2.2, selon le niveau de chaque critère dans cette version. bestPractices: true ajoute les règles qui ne nomment aucun critère WCAG, comme region et heading-order ; seule, elle n’exécute que celles-ci, et à côté d’engineOptions.profile, elle les ajoute aux règles du profil.

// 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 accepte aussi des identifiants de règles et des tags (includeRuleIds, tags, excludeRuleIds, ...), qui s’ajoutent aux règles d’une cible ; les exclusions s’appliquent ensuite. Un runOnly qui sélectionne quoi que ce soit remplace entièrement la sélection de engineOptions.rules/.tags : donnez une sélection d’une façon ou de l’autre, pas les deux. Passez {} ou null comme engineOptions quand vous n’avez besoin que de runOnly. Les identifiants de règle sont lus sans tenir compte de la casse ('IMG-ALT-PRESENT').

Une cible implique son niveau. Un test qui nomme des règles ou des étiquettes peut en fixer un avec engineOptions.wcagLevel (@surea11y/core 1.12.0 et ultérieur) : le niveau des critères que l’analyse rapporte, sans changer les règles exécutées. contrast-computable vérifie le 1.4.3 (AA) et le 1.4.6 (AAA), donc l’analyse ci-dessous ne rapporte que le 1.4.3. L’assertion dépend des résultats fail : cela change donc les critères du résultat, pas ce qui fait échouer le test.

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

Analyser avec des packs

Un pack apporte des règles, une norme ou une liste de contrôle, avec leurs profils et leurs messages, depuis un paquet qui lui est propre. Le matcher analyse dans Node : un pack est donc passé sous forme d’objet, dans engineOptions.packs, et son profil est choisi avec 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 });

Les règles propres à un pack apparaissent dans le résultat comme celles de core, avec des identifiants qui commencent par son espace de noms (acme-no-autoplay). Gardez les objets pack en un seul endroit (un fichier de setup ou un module) : core prépare un moteur pour chaque ensemble d’objets pack et le conserve pour la prochaine analyse avec les mêmes. Un pack que core ne peut pas exécuter (sa plage core exclut la version de core installée, il n’est pas valide, ou son espace de noms entre en conflit avec celui d’un autre pack) est listé dans le skippedPacks du résultat, et l’assertion échoue à cause de lui au lieu de réussir sans que les règles du pack se soient jamais exécutées.

API

ParamètreTypeSignification
receivedElement, Document ou résultat d’analyseCe qu’il faut vérifier. Un Element/Document DOM est analysé automatiquement ; un objet de la forme { checksResults: [...] } est vérifié directement, sans analyse. Toute autre valeur lève une TypeError.
engineOptionsobjet (facultatif)Transmis tel quel à l’analyse de @surea11y/core : le même objet d’options que partout ailleurs dans l’écosystème (voir Options du moteur). Ignoré lorsque received est déjà un résultat d’analyse.
runOnlyobjet, chaîne ou string[] (facultatif)Transmis tel quel comme runOnly de l’analyse : une cible WCAG ({ wcag: { version: '2.2', level: 'AA' } }), bestPractices, des identifiants de règles ou des tags. Voir Choisir une cible WCAG. Ignoré lorsque received est déjà un résultat d’analyse.
optionsobjet (facultatif){ failOnSeverity, leanFail, wcagOnly, howToCheck } : voir N’échouer que sur les problèmes graves et Questions en échec probable. Chacune remplace la valeur par défaut fixée par configureA11yMatchers().

Le deuxième argument peut aussi être un seul objet qui les regroupe tous, { engineOptions, runOnly, failOnSeverity, leanFail, wcagOnly, howToCheck } : toHaveNoA11yViolations({ leanFail: true }). Il est lu ainsi dès qu’il a l’une de ces clés, dont aucune n’est une option du moteur ; un objet qui les mélange avec des options du moteur lève une TypeError.

Renvoie la forme { pass, message } qu’attend le expect.extend() des deux frameworks — vous ne l’appelez jamais directement ; il est branché une fois via expect.extend(), comme indiqué dans la configuration ci-dessus. Le paquet exporte aussi configureA11yMatchers(options), pour des valeurs par défaut dans toute la suite ; installInternalsHook(win?), pour les composants web ; et installListenerHook(win?), installLiveRegionHook(win?) et le matcher toHaveAnnounced(), pour les gestionnaires de clic et régions live.

Ce qu’il vérifie, et ce qu’il ne vérifie pas

Seuls les échecs font échouer l’assertion, conformément à la convention de tous les autres bindings surea11y selon laquelle les résultats cantTell sont indicatifs, pas des échecs. Chaque constat est lu selon son propre résultat, pas celui de sa règle : une règle qui échoue sur un élément peut poser une question sur un autre, et cette question ne compte pas comme un échec. Un lien vague comme <a href="/pricing">Click here</a> est cantTell, pas fail, il ne fait donc pas échouer à lui seul un test qui réussit, sauf si vous le demandez avec leanFail (plus bas) — mais quand une assertion échoue bel et bien pour d’autres raisons, les questions sont listées après les échecs, pour rester visibles. Voir Limites connues pour ce que ce moteur n’automatisera pas du tout.

Une analyse qui n’a pas pu vérifier ce qu’on lui demandait échoue aussi, au lieu de réussir sur rien : un élément qui n’est pas dans le document où s’exécute l’analyse (détaché, ou issu d’un autre document), une règle personnalisée qui ne s’est pas exécutée (listée dans skippedCustomRules), ou un pack qui ne s’est pas exécuté (listé dans skippedPacks). Le message nomme chacun d’eux avec la raison. Une règle personnalisée que le test exclut lui-même n’est pas une lacune, et ne le fait pas échouer.

Un appel sans sélection exécute aussi les bonnes pratiques. Depuis @surea11y/core 1.12, l’une d’elles, empty-heading, met en échec un titre sans nom accessible, là où elle posait la question, donc un composant qui rend <h2></h2> (souvent une prop de titre laissée vide dans le test) fait échouer un appel par défaut. Un lecteur d’écran annonce ce titre sans rien dedans : donnez au test un vrai contenu ou corrigez le composant. Un appel avec une cible WCAG, qui laisse de côté les bonnes pratiques, ne l’exécute pas.

L’analyse elle-même peut lever une erreur, et le test échoue alors avec l’erreur du moteur, qui porte un code : INVALID_RUN_ONLY (une liste de règles ou de tags dont aucune valeur ne désigne une règle ou un tag, ou une cible runOnly.wcag qui n’en est pas une), INVALID_ENGINE_OPTIONS (avec strictOptions: true, une option que le moteur ne lit pas ou une valeur qu’il ne peut pas utiliser), ou PAGE_BUILTINS_BROKEN (le code du test ou de la page a cassé un objet natif JavaScript sur lequel le moteur s’appuie).

Limiter l’analyse à un élément ne rapporte que ce qu’il contient : un échec ailleurs dans la page ne fait jamais échouer une assertion limitée à un container. En interne, l’élément analysé reçoit temporairement un attribut unique et est analysé via ce sélecteur, de façon invisible pour votre test.

Quelques règles vérifient une propriété de la page entière, et non d’un sous-arbre précis : page-title-present, html-lang-attr-present et quelques autres vérifient directement document.title/document.documentElement, car se demander si la page a un titre n’a pas de sens pour un fragment de composant quelconque. Si vous passez un Element (un container de testing-library, document.body), ces règles renvoient notApplicable plutôt que fail, donc un <title> absent ne fait jamais échouer un test de composant ; si vous passez document lui-même, elles sont réellement évaluées. Si vous analysez directement document, définissez document.title et le lang de document.documentElement une seule fois dans votre fichier de setup plutôt que dans chaque test.

N’échouer que sur les problèmes graves

Chaque résultat de règle a une gravité : critical, serious, moderate ou minor. Pour commencer par ne faire échouer les tests que sur les problèmes graves pendant que les autres restent signalés, fixez un niveau pour toute la suite dans le fichier de configuration avec configureA11yMatchers(), ou pour une assertion dans ses options, qui remplacent la valeur par défaut (failOnSeverity: null la désactive à cet endroit) :

// 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' });

Les règles en échec sous le niveau ne font pas échouer le test, mais quand il échoue, le message liste chaque échec et marque ceux qui sont sous le niveau. C’est le même seuil que surea11y scan --fail-on-severity. Une analyse qui a laissé quelque chose de côté (une portée qui ne correspond à rien, une règle personnalisée ou un pack qui ne s’est pas exécuté) fait échouer l’assertion quel que soit le niveau.

Questions en échec probable

Un cantTell est une question pour une personne. Depuis @surea11y/core 1.14, une question peut pencher : quand les éléments de la page vont dans un sens, elle dit lequel (fail ou pass) et pourquoi, et chaque question d’une règle de core porte les étapes pour y répondre (howToCheck). Un message d’échec liste les questions après les échecs : d’abord celles en échec probable, puis celles en réussite probable, chacune avec sa raison, puis les questions ouvertes, par règle. { howToCheck: true } liste chaque question en entier, avec ses étapes.

Par défaut, une question ne fait jamais échouer le test. { leanFail: true } le fait aussi échouer sur les questions en échec probable, et wcagOnly: true laisse de côté celles des règles de bonnes pratiques :

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
});

C’est facultatif, car une tendance est un indice, pas une preuve : qu’un constat penche, et de quel côté, peut changer dans une version mineure de core. configureA11yMatchers({ leanFail: true }) l’active pour toute la suite.

Composants web

Beaucoup de composants web gardent leurs ElementInternals dans un champ privé, et s’en servent pour se nommer, fixer leurs états ou se donner un rôle. Aucun script ne peut les relire, donc par défaut l’analyse pose la question pour un tel élément (cantTell) au lieu de le juger : le test n’échoue pas, mais rien n’est vérifié non plus, et un rôle donné ainsi n’est pas vu du tout.

installInternalsHook(win?) permet à l’analyse de les lire. Appelez-le une fois dans le fichier de setup, avant la définition de tout composant, pour qu’il voie chaque appel à attachInternals() ; win vaut par défaut le window global :

// 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 });

Avec lui, un composant qui se nomme par ses ElementInternals passe, un composant qui les attache sans fixer de nom échoue comme tout contrôle sans nom, et un composant qui n’est un switch que par eux est vérifié comme tel. Le champ engine.environment.internalsHook d’un résultat indique si le hook était là. Il enveloppe HTMLElement.prototype.attachInternals dans la fenêtre du test, ce qui est sans danger dans un test, et c’est pourquoi il est facultatif. Une limite : jest-environment-jsdom 30 embarque jsdom 26, où un composant qui fixe une propriété ARIA sur ses ElementInternals lève une erreur, avec ou sans hook (Vitest avec jsdom 29 le gère). Depuis @surea11y/core 1.14, le hook est aussi enregistré comme 'early' sous Vitest, si bien qu’un composant qui n’a jamais attaché d’ElementInternals n’y est plus une question. Le README du paquet donne les détails.

Gestionnaires de clic et régions live

Deux autres hooks facultatifs, depuis @surea11y/core 1.14, s’installent de la même façon, dans le fichier de configuration, avant que votre code ne s’exécute. installListenerHook() enregistre les écouteurs de clic, de clavier, de focus et de souris ajoutés à partir de là, pour que les règles de clavier les jugent : un <div role="button"> qui n’écoute que click échoue (CLICK_HANDLER_NO_KEY_LISTENER), et réussit dès qu’il écoute aussi keydown. installLiveRegionHook() enregistre ce que reçoivent les régions live de la page : l’analyse pose une question sur une région ajoutée avec son texte déjà dedans, que les lecteurs d’écran n’annoncent souvent pas, et toHaveAnnounced() vérifie qu’une action a bien informé l’utilisateur. Installez seulement ceux que vous voulez ; chacun est indépendant :

// 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);
});

Une chaîne doit être le texte entier ; une RegExp peut n’en correspondre qu’à une partie. Sur un élément, seules comptent les régions live qu’il est ou qu’il contient ; sur window ou document, toutes les annonces depuis l’installation du hook. C’est ce que la page a mis à disposition pour être annoncé, pas ce qu’un lecteur d’écran a dit, et toHaveAnnounced() lève une erreur sans le hook. Deux choses à savoir : React ajoute ses écouteurs à la racine dans laquelle il effectue le rendu, pas à chaque élément, si bien que sur un composant React l’analyse voit les écouteurs de la racine et juge ses boutons d’après leur balisage, comme sans le hook ; et le hook de régions live lit les changements par lots, donc effectuez le rendu, puis agissez, avec un await entre les deux (await user.click()), comme le fait un vrai utilisateur. Le README du paquet donne les détails.

Messages d’échec

Le message liste chaque occurrence en échec, pas seulement chaque règle — une règle peut signaler plusieurs éléments —, avec l’identifiant de la règle, sa gravité, un résumé lisible, le sélecteur de l’élément en échec et une piste de correction quand il y en a une. Il nomme la version de @surea11y/core qui a produit le résultat, à citer dans un rapport de bug, puis liste les questions, qui ne comptent pas comme des échecs. Avec un saut de niveau de titre à côté de l’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.14.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.

Avant la ligne de version, le message peut contenir des notes sur la façon dont l’analyse s’est déroulée : quand l’analyse a posé des questions sur des éléments personnalisés qu’elle aurait pu lire avec le hook d’internals, ou a jugé des constats que le hook d’écouteurs ou de régions live aurait tranchés, une ligne indique combien et invite à l’installer. Avec { leanFail: true }, les questions en échec probable remontent dans les échecs, et l’en-tête compte les deux (found 1 failure and 1 likely failure:).

Un pack qui ne s’est pas exécuté est nommé avec la raison :

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.14.0.
Scanned with @surea11y/core 1.14.1.

Des exemples exécutables, côte à côte, pour les deux frameworks : Jest et Vitest, et la même chose avec un pack : Jest et Vitest, et un petit composant vérifié avec les hooks d’écouteurs et de régions live, toHaveAnnounced() et leanFail : Jest et Vitest. Pour l’ensemble des engineOptions (locale, modes de contraste, shadow DOM, règles personnalisées, combinaisons de tags de version WCAG), voir ENGINE_OPTIONS.md : ce paquet transmet tout ce que vous lui passez au lieu de dupliquer cette référence.