learning-front

Nivel 8 · Calidad: que no se rompa en producción

Accesibilidad como test

Las queries por rol ya empujan hacia HTML accesible; aquí se cierra con axe (vitest-axe) para detectar problemas de accesibilidad de forma automática en cada test. Accesibilidad que no depende de acordarse de revisarla.

En el capítulo de testing de componentes aprendiste a buscar por rol accesible con getByRole, y de paso viste un efecto secundario valioso: una query por rol que pasa te dice que ese elemento es alcanzable para un lector de pantalla. Pero getByRole solo comprueba lo que tú buscas. Una imagen sin alt que nunca consultaste, un input sin etiqueta que no probaste, un atributo aria mal escrito: todo eso pasa por debajo de tus tests. Y por debajo de tu revisión manual también, porque revisar la accesibilidad a mano depende de que te acuerdes de hacerlo, en cada componente, en cada cambio.

Este capítulo cierra ese hueco: convertir la accesibilidad en un test automático que corre sobre cada componente, sin que nadie tenga que acordarse. La herramienta es axe.

Qué es axe#

axe-core es el motor de reglas de accesibilidad más usado del sector: el mismo que hay detrás de la extensión axe DevTools del navegador y de la auditoría de accesibilidad de Lighthouse. Le das un trozo de DOM ya renderizado y lo compara con un catálogo de reglas derivadas de la WCAG, devolviéndote una lista de violaciones: cada una dice qué regla se incumple y en qué elemento.

Para usarlo en un test existe vitest-axe, que aporta dos piezas:

  • axe(contenedor): corre axe-core sobre lo que has renderizado y devuelve el informe.
  • el matcher toHaveNoViolations: afirma que ese informe no tiene ninguna violación.

axe encuentra lo que se te olvida#

Esta ficha de héroe es accesible, y su test de axe está en verde:

Ahora rómpela tú: en TarjetaHeroe.tsx, quita el alt de la imagen (déjala como <img src="..." />) y vuelve a correr los tests. Pasan a rojo con un mensaje que nombra la regla incumplida (image-alt) y te enseña el elemento culpable. Para quien ve la página no cambia nada; para quien usa un lector de pantalla, esa imagen acaba de convertirse en un agujero mudo. Devuélvele el alt (alt="Genji", o alt="" si fuera puramente decorativa y repitiera lo que ya dice el texto) y vuelve al verde.

Lo importante no es esta imagen concreta: es que no tuviste que escribir un test para el alt. axe vigila toda esa familia de reglas a la vez y te avisa en cuanto una se rompe.

El patrón: una línea más en tus tests#

El esqueleto es siempre el mismo:

tsx
import { render } from "@testing-library/react";
import { axe } from "vitest-axe";

it("pasa la auditoría de accesibilidad de axe", async () => {
  // render devuelve el container: el nodo del DOM donde se ha pintado el componente.
  const { container } = render(<MiComponente />);
  // axe recorre ese subárbol. Es asíncrono (analiza el DOM): se usa con await.
  const resultados = await axe(container);
  // El matcher pasa si la lista de violaciones está vacía; si no, las lista todas.
  expect(resultados).toHaveNoViolations();
});

Tres detalles que importan. El container que devuelve render es el trozo de DOM a inspeccionar; le pasas eso a axe, no el componente. La llamada es asíncrona: axe recorre el DOM por dentro y devuelve una promesa, así que va con await y el test es async (igual que con findBy* o user-event). Y toHaveNoViolations va precableado en este curso, como jest-dom: no lo importas. En un proyecto real lo cablearías una vez en tu fichero de setup (expect.extend con los matchers de vitest-axe) y quedaría disponible en todos los tests.

Una nota honesta, porque esto es material de empleabilidad: vitest-axe es el estándar de facto para juntar Vitest y axe (cientos de miles de descargas), pero su mantenimiento lleva tiempo parado. Sigue funcionando y es lo que te vas a encontrar en la mayoría de proyectos, así que es lo que enseñamos; solo conviene tenerlo en el radar. Si algún día se queda atrás, las alternativas son su hermano jest-axe (del que es un fork) o cablear axe-core a mano. Y un detalle que muerde en proyectos reales: vitest-axe necesita el entorno jsdom y no funciona con happy-dom; si tu vitest.config usa happy-dom, los tests de axe fallan hasta que pongas environment: "jsdom".

axe no sustituye a getByRole: lo complementa#

Es tentador pensar que con axe ya no hacen falta las queries por rol, o al revés. Falso: cada una ve cosas que la otra no. Este botón es accesible y sus dos tests están en verde:

Ahora la prueba clave: en BotonQuitar.tsx, quita el aria-label del botón (deja solo el icono dentro). Vuelve a correr y fíjate bien en lo que pasa: el primer test, screen.getByRole("button"), sigue en verde —el botón existe y su rol es button—, pero el de axe se pone en rojo con la regla button-name. ¿Por qué? Sin aria-label, el único contenido del botón es una equis decorativa marcada con aria-hidden, así que el botón se queda sin nombre accesible: un lector de pantalla anuncia «botón» y nada más. Tu query lo encontró —es un botón de verdad—, pero no comprobó que tuviera nombre. Ese es justo el hueco que tapa axe. Devuélvele el aria-label y los dos vuelven al verde.

Un suelo, no un techo#

Aquí toca ser honesto, porque es donde mucha gente se confía. Que axe pase no significa que tu componente sea accesible. Las herramientas automáticas detectan solo la parte que una máquina puede comprobar sin entender el contexto: en torno a un tercio de los problemas reales de accesibilidad. Cazan que falta un alt, que un control no tiene nombre, que el contraste es insuficiente, que un aria-* está mal escrito. Lo objetivo.

Lo que axe no puede juzgar es justo lo que viste en Accesibilidad en React (Nivel 7): si el orden del foco es lógico, si al validar el foco va al campo que falla, si «Haz clic aquí» es una etiqueta útil o ruido, si un menú se cierra con Escape, si un tablist se navega con flechas. Eso necesita criterio humano y los patrones manuales que ya conoces (useId, gestión del foco, navegación por teclado).

Así que el reparto es: un verde de axe quiere decir «sin violaciones detectables», no «usable por todo el mundo» —sigue revisando lo que la máquina no ve—. Un rojo de axe, en cambio, es casi siempre un problema real que arreglar ya. axe es el suelo automático que te garantiza que no se cuela lo evidente; el techo lo pones tú.

Compruébalo en cada estado#

Un último matiz que se olvida a menudo: la accesibilidad es una propiedad de cada estado de la interfaz, no solo del primero que se pinta. Un panel que filtra una lista, un menú que se despliega, un formulario que muestra un error: todos pintan markup nuevo que solo existe en ese estado. Si tu test de axe únicamente mira el render inicial, nunca llega a ver ese markup.

Por eso el patrón completo es: pasar axe tras el render y volver a pasarlo después de cada interacción o cambio de estado relevante —igual que ya pruebas el comportamiento tras un clic—. Es lo que vas a hacer en el ejercicio: comprobar el PanelHeroes en su estado inicial, tras filtrar, y en el estado vacío donde aparece el aviso de «sin resultados».

Comprueba lo que sabes#

Pregunta 1 de 6

¿Qué hace axe (de vitest-axe) en un test, y en qué se diferencia de una query como getByRole?

Tu turno#

Escríbele al PanelHeroes su red de accesibilidad. No toques el componente: ya es accesible. Tu trabajo es dejar tests que lo demuestren y que avisen si un cambio futuro lo rompe —en el render inicial, tras filtrar y en el estado vacío—. Cuando lo tengas (o si te atascas), despliega las soluciones y fíjate en el salto de un tier al siguiente.

Ejercicio · en esta página

La red de accesibilidad del PanelHeroes

Tienes el PanelHeroes del Team Builder: lista héroes, los filtra por rol y avisa cuando ninguno coincide. Está hecho para ser accesible. No lo toques: escríbele su red de accesibilidad con axe (de vitest-axe) y toHaveNoViolations, comprobando que NO se rompe al interactuar ni en el estado vacío. Para fiarte de tu red, cuando la tengas, rompe algo a propósito (quita un aria-label, o el alt de una imagen si la hay) y mira cómo tu test se pone en rojo; luego déjalo como estaba. Una red que nunca has visto fallar no demuestra que detecte nada.

Paso 1: La red básica

  • Montas el panel con render y te quedas con su container.
  • Pasas el container a axe (con await) y afirmas toHaveNoViolations sobre el resultado.
  • El test cubre el estado inicial del panel.
Ver soluciones
import { describe, it, expect } from "vitest";
import { render } from "@testing-library/react";
// axe corre el motor de reglas de accesibilidad sobre lo que has renderizado.
import { axe } from "vitest-axe";
import { PanelHeroes, type Heroe } from "./PanelHeroes";

// Datos de prueba: dos héroes de roles distintos.
const heroes: Heroe[] = [
  { nombre: "Genji", rol: "Daño", partidas: 20, victorias: 13 },
  { nombre: "Mercy", rol: "Apoyo", partidas: 30, victorias: 21 },
];

describe("PanelHeroes (accesibilidad)", () => {
  it("pasa la auditoría de accesibilidad de axe al renderizar", async () => {
    // Monta el panel y quédate con el container (el nodo donde se ha pintado).
    const { container } = render(<PanelHeroes heroes={heroes} />);
    // axe recorre ese DOM y lo compara con las reglas de la WCAG. Es asíncrono: await.
    const resultados = await axe(container);
    // El matcher pasa si no hay ninguna violación; si la hubiera, la listaría.
    expect(resultados).toHaveNoViolations();
  });
});

Por qué este nivel

  • El test más simple que ya aporta de verdad: monta el panel, pasa el container a axe y afirma toHaveNoViolations. En una sola aserción cubres toda una familia de fallos (imágenes sin alt, controles sin nombre, aria mal puesto) que antes solo cazabas si te acordabas de mirar.
  • Su límite: solo mira el estado INICIAL. Si la accesibilidad se rompe al filtrar o al vaciarse la lista, este test no se entera —ese markup ni siquiera existe cuando axe mira—.