Pruebas de accesibilidad con Playwright y axe: estados de la UI
Prueba diálogos, validación y navegación con Playwright y axe. Incluye ejemplos ejecutables, comprobaciones de foco, guía para CI y límites de cobertura.
Las pruebas de accesibilidad con Playwright son más útiles cuando la prueba llega al estado de la interfaz que una persona necesita: un diálogo abierto, un envío de formulario rechazado o una navegación desplegada. Un análisis de la página inicial no puede inspeccionar controles que todavía no se han renderizado. Playwright se encarga de la interacción y axe-core comprueba el DOM resultante con las reglas seleccionadas.1
Esta guía te ofrece una página de prueba (fixture) local ejecutable y tres pruebas. Después, explica qué debes cambiar para adaptarlas a una aplicación. Es una receta de pruebas de regresión, no una evaluación de conformidad con las WCAG. La evaluación manual de la accesibilidad y las pruebas inclusivas con usuarios siguen siendo necesarias.1
Elige estados, no solo rutas
Empieza por una tarea importante, como cambiar una dirección de correo electrónico. Anota la cuenta y los datos de partida, la acción, el resultado esperado y el alcance del análisis. Incluye los flujos de error y de recuperación, no solo un envío correcto. Una ruta puede contener varios estados que pueden probarse de forma independiente.
Usa STATE como lista de comprobación para planificar: selecciona un estado, provócalo, verifica el resultado, clasifica los hallazgos y deja constancia de las evidencias. Es una regla mnemotécnica editorial (en inglés), no un estándar. La secuencia siguiente equivale en texto a esos cinco pasos.
Es preferible un inventario pequeño y explícito a un recuento de rutas. En la configuración de la cuenta, cubre la apertura y el cierre del diálogo, la introducción de datos no válidos y la recuperación correcta. En la navegación, cubre los estados desplegado y contraído con un viewport estrecho. Añade autenticación, permisos, feature flags y fallos de carga cuando cambien los controles con los que se encuentra el usuario.
Espera al resultado de la acción
Playwright no aplica las mismas comprobaciones de espera a todas las acciones. click() comprueba la visibilidad, la estabilidad, la recepción de eventos y que el elemento esté habilitado. fill() comprueba la visibilidad, que el elemento esté habilitado y que sea editable; no exige estabilidad ni recepción de eventos. Que un clic o un relleno se complete no demuestra que la validación o el renderizado asíncrono hayan terminado.2
Antes de llamar a analyze(), usa una aserción con reintentos sobre el resultado real: que un diálogo con nombre esté visible, que aparezca un error o que un mensaje de estado contenga el texto esperado. Evita las esperas arbitrarias. Si tu aplicación carga opciones después de abrir un diálogo, no basta con comprobar que el contenedor está visible; espera también a las opciones o a un estado de disponibilidad documentado.12
Ejecuta el ejemplo en local
El ejemplo usa @playwright/test 1.58.1 y @axe-core/playwright 4.11.1. Guarda los siguientes archivos en un directorio vacío, con Node.js y npm disponibles. La configuración usa Google Chrome instalado en el equipo y no descarga ningún navegador. Si prefieres usar el Chromium que incluye Playwright, elimina channel y ejecuta npx playwright install chromium.
npm init -y
npm install --save-dev --save-exact @playwright/test@1.58.1 @axe-core/playwright@4.11.1
npx playwright testEjecuta el último comando después de guardar los tres archivos. Incluye en el control de versiones el lockfile generado, porque la versión del wrapper por sí sola no fija todas las dependencias transitivas. El 5 de octubre de 2026, las tres pruebas se superaron en Chrome en local con axe-core 4.11.4, la versión que resolvió el wrapper como dependencia. Fue una ejecución con una página de prueba sintética, no una prueba de un servicio de cuentas en producción.
1. Guarda fixture.html
La página de prueba usa un diálogo modal nativo, validación del correo electrónico en el cliente y una navegación desplegable convencional (patrón disclosure). Su acción «Save» solo cambia texto en local; no envía ningún correo ni almacena nada. El bucle de foco solo gestiona los campos y botones de esta página de prueba; una implementación reutilizable debe tener en cuenta otros controles y los elementos ocultos o deshabilitados. Son datos de prueba, no una biblioteca de componentes de producción.
<!doctype html><html lang="en"><title>Account settings fixture</title>
<style>body { color:#111; background:#fff; font:18px sans-serif } :focus-visible {outline:3px solid #005fcc} dialog {color:#111;background:#fff} button, input, a {font:inherit; min-height:32px; margin:4px} a {display:inline-block; padding:4px}</style>
<main><h1>Account settings</h1>
<button id="open">Edit email</button>
<dialog aria-labelledby="heading" aria-modal="true">
<h2 id="heading">Edit email</h2><form novalidate>
<label for="email">Email</label><input id="email" type="email" autocomplete="email" autofocus>
<p id="error" hidden>Enter a valid email address.</p>
<button type="submit">Save</button><button type="button" id="close">Cancel</button>
</form></dialog>
<button id="nav" aria-expanded="false" aria-controls="links">Navigation</button>
<nav id="links" aria-label="Account" hidden><a href="#profile">Profile</a><a href="#help">Help</a></nav>
<p role="status" id="status"></p></main>
<script>
const dialog = document.querySelector('dialog');
const opener = document.querySelector('#open');
const email = document.querySelector('#email');
const error = document.querySelector('#error');
const nav = document.querySelector('#nav');
const links = document.querySelector('#links');
opener.onclick = () => dialog.showModal();
document.querySelector('#close').onclick = () => dialog.close();
dialog.addEventListener('close', () => opener.focus());
dialog.addEventListener('keydown', event => {
const controls = [...dialog.querySelectorAll('input, button')];
const first = controls[0], last = controls.at(-1);
if (event.key === 'Tab' && event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus(); }
if (event.key === 'Tab' && !event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus(); }
});
document.querySelector('form').onsubmit = event => {
event.preventDefault();
const invalid = !email.value || !email.validity.valid;
error.hidden = !invalid;
if (invalid) { email.setAttribute('aria-invalid','true'); email.setAttribute('aria-describedby','error'); email.focus(); }
else { email.removeAttribute('aria-invalid'); email.removeAttribute('aria-describedby'); dialog.close(); document.querySelector('#status').textContent = 'Email saved.'; }
};
nav.onclick = () => { const open = nav.getAttribute('aria-expanded') === 'true'; nav.setAttribute('aria-expanded', String(!open)); links.hidden = open; };
</script></html>2. Guarda playwright.config.mjs
import { defineConfig } from '@playwright/test';
export default defineConfig({ testMatch: /states.spec.mjs/, workers: 1, retries: 0, use: { channel: 'chrome' }, reporter: [['list'], ['json', { outputFile: 'test-results.json' }]] });3. Guarda states.spec.mjs
Los localizadores por rol y por etiqueta expresan los nombres accesibles esperados. Son contratos útiles, pero localizar un control por su rol no demuestra que toda su interacción sea accesible.6
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
import { readFileSync } from 'node:fs';
test.beforeEach(async ({ page }) => {
await page.setContent(readFileSync('fixture.html', 'utf8'));
});
async function scan(page, testInfo, state) {
const results = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22aa'])
.analyze();
await testInfo.attach(`axe-${state}`, {
body: JSON.stringify(results, null, 2),
contentType: 'application/json',
});
expect(results.violations).toEqual([]);
// This small fixture has a strict review gate; real apps need triage.
expect(results.incomplete).toEqual([]);
}
test('dialog opens, contains focus and returns it', async ({ page }, testInfo) => {
const opener = page.getByRole('button', { name: 'Edit email', exact: true });
await opener.focus();
await page.keyboard.press('Enter');
const dialog = page.getByRole('dialog', { name: 'Edit email', exact: true });
await expect(dialog).toBeVisible();
await expect(dialog).toHaveAttribute('aria-modal', 'true');
const email = dialog.getByLabel('Email', { exact: true });
const save = dialog.getByRole('button', { name: 'Save', exact: true });
const cancel = dialog.getByRole('button', { name: 'Cancel', exact: true });
await expect(email).toBeFocused();
await page.keyboard.press('Tab');
await expect(save).toBeFocused();
await page.keyboard.press('Tab');
await expect(cancel).toBeFocused();
await page.keyboard.press('Tab');
await expect(email).toBeFocused();
await page.keyboard.press('Shift+Tab');
await expect(cancel).toBeFocused();
await scan(page, testInfo, 'dialog');
await page.keyboard.press('Escape');
await expect(dialog).toBeHidden();
await expect(opener).toBeFocused();
await page.keyboard.press('Enter');
await expect(email).toBeFocused();
await page.keyboard.press('Shift+Tab');
await page.keyboard.press('Enter');
await expect(dialog).toBeHidden();
await expect(opener).toBeFocused();
});
test('invalid email is associated and can be corrected', async ({ page }, testInfo) => {
await page.getByRole('button', { name: 'Edit email', exact: true }).click();
const dialog = page.getByRole('dialog', { name: 'Edit email', exact: true });
await expect(dialog).toBeVisible();
const email = dialog.getByLabel('Email', { exact: true });
await email.fill('wrong');
await dialog.getByRole('button', { name: 'Save', exact: true }).click();
await expect(page.locator('#error')).toBeVisible();
await expect(email).toHaveAttribute('aria-invalid', 'true');
await expect(email).toHaveAccessibleDescription('Enter a valid email address.');
await expect(email).toBeFocused();
await scan(page, testInfo, 'invalid');
await email.fill('reader@example.com');
await dialog.getByRole('button', { name: 'Save', exact: true }).click();
await expect(dialog).toBeHidden();
await expect(page.getByRole('status')).toHaveText('Email saved.');
await expect(page.locator('#error')).toBeHidden();
await expect(page.locator('#email')).not.toHaveAttribute('aria-invalid', 'true');
await scan(page, testInfo, 'saved');
});
test('navigation disclosure exposes ordinary links', async ({ page }, testInfo) => {
await page.setViewportSize({ width: 375, height: 667 });
const trigger = page.getByRole('button', { name: 'Navigation', exact: true });
const links = page.getByRole('navigation', { name: 'Account', exact: true });
await expect(trigger).toHaveAttribute('aria-expanded', 'false');
await expect(links).toBeHidden();
await trigger.focus();
await page.keyboard.press('Space');
await expect(trigger).toHaveAttribute('aria-expanded', 'true');
await expect(links).toBeVisible();
await page.keyboard.press('Tab');
await expect(links.getByRole('link', { name: 'Profile', exact: true })).toBeFocused();
await scan(page, testInfo, 'navigation');
await page.keyboard.press('Shift+Tab');
await page.keyboard.press('Enter');
await expect(trigger).toHaveAttribute('aria-expanded', 'false');
await expect(links).toBeHidden();
await expect(trigger).toBeFocused();
await expect(page.getByRole('link', { name: 'Profile', exact: true })).toHaveCount(0);
await scan(page, testInfo, 'navigation-collapsed');
});Qué demuestran las comprobaciones
La primera prueba abre el diálogo con el teclado, espera a su nombre accesible y a que esté visible, comprueba el foco de entrada y, a continuación, los dos extremos de su secuencia de tabulación. Escape y el botón visible «Cancel» lo cierran y devuelven el foco al elemento que lo abrió. El patrón de diálogo de la APG describe estos comportamientos, pero el foco inicial no tiene por qué ir siempre al primer campo: con contenido largo puede convenir llevarlo a un encabezado y, en una confirmación destructiva, a la acción más segura. El foco también puede volver a un siguiente paso lógico cuando el elemento que abrió el diálogo desaparece o el flujo de trabajo lo exige.3
El showModal() nativo de la página de prueba deja el fondo inerte. Un diálogo personalizado necesita una comprobación aparte de que los controles del fondo no se pueden usar; establecer aria-modal="true" por sí solo no implementa ese comportamiento.3 Revisa también el foco visible y el orden de lectura. Las comprobaciones programáticas del foco no pueden decirte si una cabecera fija tapa el indicador de foco.
La prueba de validación espera al error visible, a aria-invalid, a la descripción accesible del campo y al foco. Después introduce un valor válido y verifica que el diálogo se cierra, que el estado de error desaparece y que aparece el mensaje de éxito. Comprobar solo aria-describedby sería más débil: el elemento referenciado podría no existir o contener un texto incorrecto. La comprobación final del atributo usa un localizador del DOM porque el diálogo cerrado ya no está expuesto al localizador por rol predeterminado.
Comprobar el texto de una región de estado demuestra que el DOM se ha actualizado, no qué anunció un lector de pantalla ni cuándo. Prueba los anuncios con las combinaciones de navegador y tecnología de apoyo que admitas. En una aplicación, espera también a la respuesta real del guardado de datos y comprueba el valor persistido; esta página de prueba no hace ninguna de las dos cosas, de forma intencionada.
La navegación desplegable no es un menú ARIA
La tercera prueba usa un botón que despliega enlaces normales. Comprueba aria-expanded, la visibilidad, la activación con Espacio, el paso con Tab al primer enlace y el cierre con Intro. La navegación habitual de un sitio web no necesita el rol menu de ARIA ni su modelo de teclado especializado.7
Para un menú de comandos real, aplica en su lugar el contrato del botón de menú: un botón con aria-haspopup="menu", un aria-expanded sincronizado, un menú con nombre y elementos de menú con el nombre correcto. Intro y Espacio deben abrirlo y llevar el foco al primer elemento.4 Añade pruebas para la navegación con flechas que admita el menú, la activación y el cierre con Escape con devolución del foco. No copies las comprobaciones de la navegación desplegable en un widget de menú para darlo por cubierto.
Interpreta el resultado de axe, incluido incomplete
La función auxiliar selecciona las etiquetas WCAG A/AA hasta la versión 2.2 disponibles en esta versión de axe. Así se seleccionan las reglas automatizadas etiquetadas, no todos los criterios de conformidad de esos niveles. Los análisis predeterminados de axe también incluyen reglas clasificadas como buenas prácticas; filtrar por etiquetas WCAG cambia ese alcance.15
El resultado contiene violations, passes, incomplete e inapplicable. Un resultado incompleto requiere revisión: axe no pudo determinar si el nodo afectado cumplía o no la regla. Una regla no aplicable no encontró contenido pertinente; no demuestra que hayas probado esa funcionalidad.5
Esta pequeña página de prueba hace fallar la prueba tanto si hay infracciones como si hay hallazgos incompletos, después de adjuntar el resultado completo. En una aplicación real, puedes optar por enviar los hallazgos incompletos a una cola de revisión con un responsable asignado. Conviértelo en una política explícita con un plazo de revisión, en lugar de descartar el array sin avisar o darlo por superado. Guarda junto a los artefactos la versión del motor de results.testEngine, el navegador o proyecto, el nombre del estado y el commit. Actualiza la línea base de forma deliberada tras actualizar dependencias.
Delimita con precisión el alcance del análisis
Estas pruebas analizan toda la página actual. .include('#panel') puede reducir el ruido al comprobar un componente, pero no garantiza cobertura fuera de ese subárbol. .exclude() elimina el elemento seleccionado y sus descendientes de todas las reglas, así que una exclusión amplia puede ocultar regresiones que no tienen nada que ver.1
El wrapper de axe para Playwright inyecta automáticamente el motor en los frames. Su modo heredado desactiva el análisis de frames de origen cruzado e informa de los frames no analizados; un error de análisis o un frame sin analizar es una laguna de cobertura, no un resultado limpio.8 Espera a que se cargue el contenido incrustado, revisa los resultados relacionados con frames y prueba de forma explícita los flujos incrustados importantes.
Los localizadores de Playwright suelen funcionar con shadow roots abiertos, salvo XPath; los shadow roots cerrados no son compatibles. Axe puede recorrer el shadow DOM abierto, pero que un localizador encuentre el elemento no garantiza por sí mismo la cobertura del análisis.56 Documenta por separado los shadow roots cerrados y los widgets de terceros. La página de prueba anterior no contiene ningún iframe ni shadow root, así que superar sus pruebas no dice nada sobre ninguno de los dos.
Lleva fallos útiles a la CI
Usa solo usuarios sintéticos en las páginas de prueba y cuentas específicas para las pruebas con autenticación, y restringe el acceso a los artefactos. Los resultados de axe contienen fragmentos del DOM; las trazas, las capturas de pantalla y los registros pueden exponer datos personales y detalles de la sesión. Establece límites de retención y elimina los datos sensibles antes de compartirlos.
Ejecuta las pruebas de los estados críticos en las pull requests. Añade viewports móviles representativos, otros navegadores compatibles y comprobaciones manuales con una periodicidad adecuada al producto. El resultado de una página de prueba limitada a Chrome nunca debe presentarse como cobertura multinavegador. Cuando algo falle, conserva el estado, el ID de la regla, los elementos afectados, los pasos para reproducirlo y la persona responsable. Es preferible una excepción acotada y con caducidad a desactivar una regla en toda la aplicación.
En los flujos de cuenta, extiende este enfoque a las pantallas alternativas y de recuperación, tal como se describe en la guía de despliegue de passkeys. Empieza por una tarea de usuario que falle, escribe su contrato observable y, después, corrige el componente y vuelve a ejecutar ese estado. Así obtendrás una prueba de regresión que otra persona podrá mantener.
Puntos clave
- 1Llega a cada estado renderizado y verifícalo antes de ejecutar axe; que una acción termine no indica que la interfaz esté lista.
- 2Comprueba el nombre de los diálogos, que el foco no salga de ellos, su cierre y el destino adecuado del foco.
- 3Verifica a la vez los errores visibles, la asociación de los campos con sus mensajes y la recuperación correcta.
- 4Trata los hallazgos incompletos como tareas de revisión y registra las versiones y el alcance de los análisis.
- 5Usa STATE para seleccionar y provocar estados, verificar los resultados, clasificar los hallazgos y dejar constancia de las evidencias.
Conclusión
Elige un estado crítico para el usuario y define cómo debe quedar a nivel visual, semántico y de teclado antes de analizarlo. Mantén juntos la prueba, el alcance del análisis y las evidencias de revisión para que cualquier fallo futuro pueda reproducirse.
Preguntas frecuentes
¿Superar un análisis de axe demuestra la conformidad con las WCAG?
No. Solo informa de las comprobaciones automatizadas seleccionadas para el estado renderizado. La evaluación manual, las pruebas con tecnologías de apoyo y las pruebas inclusivas con usuarios siguen siendo necesarias.
¿Debo analizar toda la página o un solo componente?
Siempre que sea viable, analiza la página completa en ese estado. Un análisis acotado es útil para un componente, pero deja constancia de su alcance y mantén comprobaciones independientes para el contenido excluido.
¿Qué hago con los resultados incompletos?
Revisa los nodos afectados: axe no pudo determinar si cumplían o no la regla. Haz que la prueba falle hasta que se revisen o registra los hallazgos en una cola con un responsable y un plazo definidos.
¿Espera fill a que un elemento deje de moverse?
No. Con fill, Playwright comprueba que el elemento esté visible, habilitado y sea editable. Antes de analizar, comprueba por separado el estado resultante de la aplicación.
¿Puedo reutilizar estas pruebas en producción?
Sustituye la página de prueba sintética por tu aplicación y actualiza los nombres accesibles, los datos y las comprobaciones de disponibilidad. Añade navegadores compatibles, viewports y comprobaciones manuales; el ejemplo no prueba la persistencia ni un servicio real.
Fuentes
- https://playwright.dev/docs/accessibility-testing
- https://playwright.dev/docs/actionability
- https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal
- https://www.w3.org/WAI/ARIA/apg/patterns/menu-button
- https://www.deque.com/axe/core-documentation/api-documentation
- https://playwright.dev/docs/locators
- https://www.w3.org/WAI/ARIA/apg/patterns/disclosure/examples/disclosure-navigation
- https://raw.githubusercontent.com/dequelabs/axe-core-npm/0bcb1850683addb69343067263aa3fcd4ddd0561/packages/playwright/README.md
Escrito por
Hamza DiazHamza Diaz es el fundador de Optijara, donde crea agentes de IA prácticos, sistemas de automatización y flujos de trabajo de Copilot para empresas de servicios. Escribe sobre operaciones de IA, estrategia de agentes e implementación real para equipos que quieren sistemas útiles en lugar de promesas vacías.
