Uso con test-matchers

Para pruebas unitarias y de componentes: verifica la accesibilidad directamente dentro de una suite de Jest o Vitest, contra el DOM que la prueba ya tiene, en lugar de manejar un navegador real o headless. Si en cambio estás probando páginas completamente renderizadas, consulta las integraciones (binding) basadas en navegador.

@surea11y/test-matchers te da toHaveNoA11yViolations(), más toHaveAnnounced() para las regiones vivas. Ambos se conectan al expect.extend() de cualquiera de los dos frameworks sin modificaciones, sin código propio específico de ningún framework. Analiza cualquier entorno basado en jsdom bajo el que ya se ejecute tu prueba; no depende de jsdom en sí ni lo configura por ti. El motor de surea11y es síncrono, así que este matcher también lo es: sin await, sin el riesgo de olvidarlo y que la prueba se dé por superada sin esperar a que se resuelva la promesa.

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

@surea11y/core es el motor que ejecuta el matcher; instálalo con el matcher, como arriba. Para comprobar HTML compilado desde la terminal o un paso de CI, fuera de un ejecutor de pruebas, consulta la CLI.

Configuración

Tu entorno de pruebas ya debe estar basado en jsdom. Este matcher analiza cualquier document/window que proporcione. Registra el matcher una vez, en el archivo de configuración que tu ejecutor de pruebas ya cargue:

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

El .d.ts incluido amplía tanto la interfaz global jest.Matchers de Jest como las interfaces @vitest/expect de Vitest, sin necesitar ningún import de @types/jest o vitest para distribuirlo: sea cual sea el framework realmente instalado, solo se activa la ampliación correspondiente.

Uso

Funciona contra un nodo DOM simple:

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

...o cualquier container de testing-library: el de React Testing Library, el wrapper.element de Vue Test Utils, el de Angular Testing Library, el de SvelteTesting Library, ya que todos exponen un elemento DOM renderizado de la misma manera:

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

Esto también es agnóstico respecto al ejecutor de pruebas: los fragmentos anteriores son idénticos tanto si se ejecutan bajo Jest como bajo Vitest, una vez registrado el matcher.

Verificar con .not

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

Reutilizar un análisis en varias aserciones

Si ya estás calculando tú mismo un resultado de análisis de surea11y (para verificar reglas específicas en varios bloques it() sin volver a analizar cada vez), pasa el objeto de resultado directamente en lugar de un nodo DOM:

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

Filtrar y configurar análisis

El segundo argumento acepta cualquier cosa que acepte @surea11y/core. Consulta Opciones del motor para ver la superficie completa. Algunos casos comunes:

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

Elegir un objetivo de WCAG (runOnly)

El tercer argumento es el runOnly del análisis, que se pasa al motor tal cual. runOnly.wcag selecciona por objetivo de conformidad: { wcag: { version: '2.2', level: 'AA' } } ejecuta las reglas de los criterios de nivel A y AA de WCAG 2.2, según el nivel que tiene cada criterio en esa versión. bestPractices: true añade las reglas que no nombran ningún criterio de WCAG, como region y heading-order; por sí solo, ejecuta solo esas, y junto a engineOptions.profile las añade a las reglas del perfil.

// 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 también acepta IDs de regla y etiquetas (includeRuleIds, tags, excludeRuleIds, ...), que se añaden a las reglas de un objetivo; las exclusiones se aplican después. Un runOnly que selecciona algo sustituye por completo la selección de engineOptions.rules/.tags: indica una selección de una forma o de la otra, no de ambas. Pasa {} o null como engineOptions cuando solo necesites runOnly. Los IDs de regla se leen sin distinguir mayúsculas de minúsculas ('IMG-ALT-PRESENT').

Un objetivo implica su nivel. Una prueba que nombra reglas o etiquetas puede fijar uno con engineOptions.wcagLevel (@surea11y/core 1.12.0 y posteriores): el nivel de los criterios de los que informa el análisis, sin cambiar qué reglas se ejecutan. contrast-computable comprueba el 1.4.3 (AA) y el 1.4.6 (AAA), así que el análisis de abajo solo informa del 1.4.3. La aserción depende de los resultados fail, así que esto cambia los criterios del resultado, no lo que hace fallar la prueba.

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

Analizar con packs

Un pack trae reglas, un estándar o una lista de comprobación, junto con sus perfiles y mensajes, desde un paquete independiente. El matcher analiza en Node, así que un pack se pasa como el propio objeto, en engineOptions.packs, y su perfil se elige con 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 });

Las reglas propias de un pack aparecen en el resultado igual que las de core, con IDs que empiezan por su espacio de nombres (acme-no-autoplay). Mantén los objetos de pack en un solo lugar (un archivo de configuración o un módulo): core prepara un motor para cada conjunto de objetos de pack y lo conserva para el siguiente análisis con los mismos. Un pack que core no puede ejecutar (su rango core deja fuera el core instalado, no es válido, o su espacio de nombres choca con el de otro pack) figura en el skippedPacks del resultado, y la aserción falla por ello en lugar de pasar sin que se hayan ejecutado las reglas del pack.

Con @surea11y/core 1.15 y test-matchers 1.5.0, profile acepta también una lista, para juzgar el componente con varios perfiles a la vez, el de un pack junto al de WCAG: cada perfil lo juzga como lo haría por sí solo, y un fallo en cualquiera de ellos hace fallar la aserción. Un perfil de la lista que no se ejecutó también la hace fallar, ya que el análisis juzgó menos de lo que se le pidió; lo mismo ocurre con un perfil único que el análisis no aplicó, que analiza con las reglas predeterminadas de core.

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

API

ParámetroTipoSignificado
receivedElement, Document o un resultado de análisisQué comprobar. Un Element/Document del DOM se analiza automáticamente; un objeto con la forma { checksResults: [...] } se verifica directamente, sin realizar ningún análisis. Cualquier otra cosa lanza un TypeError.
engineOptionsobjeto (opcional)Se pasa directamente al análisis de @surea11y/core: el mismo objeto de opciones usado en el resto del ecosistema (consulta Opciones del motor). Se ignora cuando received ya es un resultado de análisis.
runOnlyobjeto, cadena o string[] (opcional)Se pasa tal cual como el runOnly del análisis: un objetivo de WCAG ({ wcag: { version: '2.2', level: 'AA' } }), bestPractices, IDs de regla o etiquetas. Consulta Elegir un objetivo de WCAG. Se ignora cuando received ya es un resultado de análisis.
optionsobjeto (opcional){ failOnSeverity, leanFail, wcagOnly, howToCheck }: consulta Fallar solo con problemas graves y Preguntas que probablemente fallan. Cada una sustituye al valor por defecto que fija configureA11yMatchers().

El segundo argumento puede ser, en cambio, un único objeto con todos ellos, { engineOptions, runOnly, failOnSeverity, leanFail, wcagOnly, howToCheck }: toHaveNoA11yViolations({ leanFail: true }). Se lee así cuando tiene alguna de esas claves, ninguna de las cuales es una opción del motor; un objeto que las mezcla con opciones del motor lanza un TypeError.

Devuelve la forma { pass, message } que el expect.extend() de ambos frameworks espera: nunca llamas a esto directamente; se conecta una vez mediante expect.extend() como se muestra en Configuración más arriba. El paquete también exporta configureA11yMatchers(options), para valores por defecto en toda la suite; installInternalsHook(win?), para componentes web; y installListenerHook(win?), installLiveRegionHook(win?, options?) y el matcher toHaveAnnounced(), para manejadores de clic y regiones vivas.

Qué comprueba y qué no

Solo los fallos condicionan la aserción: coincidiendo con la convención del resto de integraciones de surea11y de que los resultados cantTell son informativos, no fallos. Cada hallazgo se lee por su propio resultado, no por el de su regla: una regla que falla en un elemento puede preguntar por otro, y esa pregunta no cuenta como fallo. Un enlace vago como <a href="/pricing">Click here</a> es cantTell, no fail, así que no rompe por sí solo una prueba que pasa, salvo que lo pidas con leanFail (más abajo), aunque cuando una aserción sí falla por otros motivos, las preguntas se enumeran después de los fallos, para que sigan siendo visibles. Consulta Limitaciones conocidas para saber qué no automatiza en absoluto este motor.

Un análisis que no pudo comprobar lo que se le pidió también falla, en lugar de pasar sin haber comprobado nada: un elemento que no está en el documento en el que se ejecuta el análisis (desconectado, o de otro documento), una regla personalizada que no se ejecutó (listada en skippedCustomRules), un pack que no se ejecutó (listado en skippedPacks), o un perfil que no se ejecutó (listado en engine.profilesNotRun, o un perfil único que el análisis no aplicó). El mensaje nombra cada uno con el motivo. Una regla personalizada que la propia prueba excluye no es un hueco, y no la hace fallar.

Una llamada sin selección también ejecuta las buenas prácticas. Desde @surea11y/core 1.12, una de ellas, empty-heading, falla un encabezado sin nombre accesible, cuando antes preguntaba por él, así que un componente que renderiza <h2></h2> (a menudo una prop de título vacía en la prueba) falla una llamada por defecto. Un lector de pantalla anuncia ese encabezado sin nada dentro: da contenido real a la prueba o corrige el componente. Una llamada con un objetivo de WCAG, que deja fuera las buenas prácticas, no la ejecuta.

Desde @surea11y/core 1.15, WCAG falla solo donde WCAG falla. Donde solo se incumple WAI-ARIA o el modelo de contenido de HTML (un atributo que el rol no admite, una cuadrícula o un diálogo sin nombre, un encabezado en una lista), las reglas de estructura ARIA y HTML preguntan (cantTell, incertidumbre spec-only) en lugar de fallar, así que ese hallazgo ya no hace fallar la aserción. El mensaje de fallo lo enumera con las preguntas, y conserva su código de motivo. Para que falle según WAI-ARIA, añade el perfil del pack de WAI-ARIA.

El propio análisis puede lanzar una excepción, y entonces la prueba falla con el error del motor, que lleva un code: INVALID_RUN_ONLY (una lista de reglas o etiquetas en la que ningún valor nombra una regla o una etiqueta, o un objetivo runOnly.wcag que no es válido), INVALID_ENGINE_OPTIONS (con strictOptions: true, una opción que el motor no lee o un valor que no puede usar), o PAGE_BUILTINS_BROKEN (el código de la prueba o de la propia página rompió un objeto integrado de JavaScript del que depende el motor).

Limitar el alcance a un elemento solo informa de lo que hay dentro de él. Un fallo en otra parte de la página nunca hace fallar una aserción limitada a un container. Internamente, el elemento analizado se marca temporalmente con un atributo único y se analiza mediante ese selector, de forma invisible para tu prueba.

Un puñado de reglas comprueban un hecho de toda la página, no algo en un subárbol concreto: page-title-present, html-lang-attr-present, y algunas otras comprueban document.title/document.documentElement directamente, ya que preguntar si la página tiene un título no tiene sentido para un fragmento de componente arbitrario. Pasar un Element (un container de testing-library, document.body) informa estas notApplicable en lugar de fail, así que un <title> faltante nunca rompe una prueba a nivel de componente; pasar el propio document sí las evalúa de verdad. Si analizas document directamente, define una vez el lang de document.title/document.documentElement en tu archivo de configuración en lugar de en cada prueba.

Fallar solo con problemas graves

Cada resultado de regla tiene una gravedad: critical, serious, moderate o minor. Para empezar haciendo fallar las pruebas solo con problemas graves mientras el resto se sigue informando, fija un nivel para toda la suite en el archivo de configuración con configureA11yMatchers(), o para una aserción en sus opciones, que sustituyen al valor por defecto (failOnSeverity: null lo desactiva ahí):

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

Las reglas que fallan por debajo del nivel no hacen fallar la prueba, pero cuando falla, el mensaje enumera todos los fallos y marca los que quedan por debajo del nivel. Es el mismo umbral que surea11y scan --fail-on-severity. Un análisis que dejó algo fuera (un ámbito que no coincidió con nada, una regla personalizada o un pack que no se ejecutó) hace fallar la aserción sea cual sea el nivel.

Preguntas que probablemente fallan

Un cantTell es una pregunta para una persona. Desde @surea11y/core 1.14, una pregunta puede tener una tendencia: cuando las evidencias de la página apuntan hacia un lado, dice hacia cuál (fail o pass) y por qué, y cada pregunta de una regla de core lleva los pasos para responderla (howToCheck). Un mensaje de fallo enumera las preguntas después de los fallos: primero las que probablemente fallan, luego las que probablemente cumplen, cada una con su motivo, y después las abiertas, por regla. { howToCheck: true } enumera cada pregunta completa, con sus pasos.

Por defecto, una pregunta nunca hace fallar la prueba. { leanFail: true } la hace fallar también con las preguntas que probablemente fallan, y wcagOnly: true deja fuera las de reglas de buenas prácticas:

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

Es opcional porque una tendencia es una evidencia, no una prueba: si un hallazgo tiene tendencia, y hacia dónde, puede cambiar en una versión menor de core. configureA11yMatchers({ leanFail: true }) lo activa para toda la suite.

Componentes web

Muchos componentes web guardan sus ElementInternals en un campo privado y, a través de ellos, se dan un nombre, fijan sus estados o se asignan un rol. Ningún script puede volver a leerlos, así que por defecto el análisis pregunta por ese elemento (cantTell) en lugar de juzgarlo: la prueba no falla, pero tampoco se comprueba nada, y un rol asignado así ni siquiera se ve.

installInternalsHook(win?) permite que el análisis los lea. Llámalo una vez en el archivo de configuración, antes de que se defina ningún componente, para que vea cada llamada a attachInternals(); win es por defecto el 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 });

Con él, un componente que se da nombre a través de sus ElementInternals pasa, uno que los adjunta pero no fija ningún nombre falla como cualquier control sin nombre, y uno que es un switch solo a través de ellos se comprueba como tal. El campo engine.environment.internalsHook de un resultado indica si el hook estaba instalado. Envuelve HTMLElement.prototype.attachInternals en la ventana de la prueba, algo inofensivo en una prueba y la razón de que sea opcional. Un límite: jest-environment-jsdom 30 incluye jsdom 26, donde un componente que fija una propiedad ARIA en sus ElementInternals lanza un error, con hook o sin él (Vitest con jsdom 29 lo admite). Desde @surea11y/core 1.14 el hook se registra como 'early' también con Vitest, así que allí un componente que nunca adjuntó ElementInternals ya no es una pregunta. El README del paquete tiene los detalles.

Manejadores de clic y regiones vivas

Dos hooks opcionales más, desde @surea11y/core 1.14, se instalan del mismo modo, en el archivo de configuración, antes de que se ejecute nada de tu código. installListenerHook() registra los listeners de clic, teclado, foco y ratón que se añaden desde entonces, para que las reglas de teclado los juzguen: un <div role="button"> que solo escucha click falla (CLICK_HANDLER_NO_KEY_LISTENER), y pasa en cuanto también escucha keydown. installLiveRegionHook() registra lo que reciben las regiones vivas de la página: el análisis pregunta por una región añadida con su texto ya dentro, que los lectores de pantalla no suelen anunciar, y toHaveAnnounced() comprueba que una acción le dijo algo al usuario. Instala solo los que quieras; cada uno es independiente:

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

Un string tiene que ser el texto completo; un RegExp puede coincidir con una parte. Sobre un elemento, solo cuentan las regiones vivas que es o que contiene; sobre window o document, todos los anuncios desde que se instaló el hook. Es lo que la página puso a disposición para anunciar, no lo que dijo un lector de pantalla, y toHaveAnnounced() lanza un error sin el hook. Dos cosas que conviene saber: React añade sus listeners a la raíz en la que renderiza, no a cada elemento, así que en un componente de React el análisis ve los listeners de la raíz y juzga sus botones por su marcado, como sin el hook; y el hook de regiones vivas lee los cambios por lotes, así que primero renderiza y luego actúa, con un await entre medias (await user.click()), como hace un usuario real. El README del paquete tiene los detalles.

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

Un texto dado a una región detrás de un diálogo modal no lo cuenta toHaveAnnounced() salvo que pases { includeBehindModal: true }, y el mensaje de fallo lo lista en cualquier caso. Otra opción al hook, o un valor que no es booleano, lanza un TypeError.

Mensajes de fallo

Enumera cada ocurrencia que falla, no solo cada regla (una regla puede marcar varios elementos), con el ID de la regla, la gravedad, un resumen legible, el selector del elemento que falla y una pista para corregirlo cuando la hay. Nombra la versión de @surea11y/core que produjo el resultado, para citarla en un informe de error, y después enumera las preguntas, que no cuentan como fallos. Con un salto de nivel de encabezado junto a la imagen:

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.

Antes de la línea de versión, el mensaje puede incluir notas sobre cómo se ejecutó el análisis: cuando el análisis preguntó por elementos personalizados que podría haber leído con el hook de internals, o juzgó hallazgos que el hook de listeners o el de regiones vivas habría decidido, una línea dice cuántos y que lo instales. Con { leanFail: true }, las preguntas que probablemente fallan suben a los fallos, y la cabecera cuenta ambos (found 1 failure and 1 likely failure:).

Un pack que no se ejecutó se nombra con el motivo:

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.

Ejemplos ejecutables, en paralelo, para ambos frameworks: Jest y Vitest, y lo mismo con un pack: Jest y Vitest, y un componente pequeño comprobado con los hooks de listeners y de regiones vivas, toHaveAnnounced() y leanFail: Jest y Vitest. Para la superficie completa de engineOptions (locale, modos de contraste, shadow DOM, reglas personalizadas, combinaciones de etiquetas de versión de WCAG), consulta ENGINE_OPTIONS.md. Este paquete reenvía todo lo que le pasas en lugar de duplicar esa referencia.