@axe-core/playwright ejecuta verificaciones de accesibilidad junto con tus tests normales de Playwright, detectando alt text faltante, campos de formulario sin etiqueta, contraste de color insuficiente y uso incorrecto de ARIA en cada commit de CI. El escaneo automatizado detecta aproximadamente el 30-40% de las violaciones de WCAG; el resto requiere testing manual con teclado y lector de pantalla. Este artículo cubre la configuración completa: escaneo de páginas completas, componentes específicos, filtrado por nivel de WCAG y testing de flujos de navegación por teclado.
Por qué automatizar el testing de accesibilidad
Las auditorías manuales de accesibilidad son lentas y costosas. Las verificaciones automatizadas detectan los problemas más comunes al instante:
- Alt text faltante en imágenes
- Campos de formulario sin etiquetas
- Contraste de color insuficiente
- Roles y atributos ARIA faltantes
- Problemas de navegación por teclado
- Problemas de manejo del foco
Las herramientas automatizadas detectan aproximadamente el 30-40% de los problemas de accesibilidad. El resto requiere testing manual con tecnologías de asistencia reales. Pero detectar el 30-40% automáticamente, en cada commit, es muy superior a no detectar nada.
Configuración: axe-playwright
npm install --save-dev @axe-core/playwrightEscaneo básico de accesibilidad
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
test('la página de inicio no tiene violaciones de accesibilidad', async ({ page }) => {
await page.goto('/');
const results = await new AxeBuilder({ page }).analyze();
expect(results.violations).toEqual([]);
});Si hay violaciones, el test falla con detalles:
● la página de inicio no tiene violaciones de accesibilidad
expect(received).toEqual(expected)
Expected: []
Received: [
{
id: 'color-contrast',
description: 'Los elementos deben tener suficiente contraste de color',
nodes: [{ target: ['#nav-link'], ... }]
}
]Escanear páginas específicas
test.describe('Verificaciones de accesibilidad', () => {
const pages = [
{ name: 'Inicio', url: '/' },
{ name: 'Login', url: '/login' },
{ name: 'Productos', url: '/products' },
{ name: 'Contacto', url: '/contact' },
];
for (const { name, url } of pages) {
test(`La página ${name} es accesible`, async ({ page }) => {
await page.goto(url);
await page.waitForLoadState('networkidle');
const results = await new AxeBuilder({ page }).analyze();
expect(results.violations).toEqual([]);
});
}
});Filtrar reglas
Ejecuta solo criterios WCAG específicos, o excluye problemas conocidos:
test('la página de login cumple con WCAG AA', async ({ page }) => {
await page.goto('/login');
const results = await new AxeBuilder({ page })
// Solo verificar criterios WCAG 2.1 AA
.withTags(['wcag2a', 'wcag2aa', 'wcag21aa'])
.analyze();
expect(results.violations).toEqual([]);
});
test('la página de productos con problemas conocidos excluidos', async ({ page }) => {
await page.goto('/products');
const results = await new AxeBuilder({ page })
// Excluir temporalmente un problema conocido mientras se corrige
.disableRules(['color-contrast'])
.analyze();
expect(results.violations).toEqual([]);
});Escanear una parte de la página
test('el menú de navegación es accesible', async ({ page }) => {
await page.goto('/');
const results = await new AxeBuilder({ page })
.include('#main-navigation') // Solo escanear la nav
.analyze();
expect(results.violations).toEqual([]);
});
test('el diálogo modal es accesible', async ({ page }) => {
await page.goto('/products');
await page.click('[data-testid="add-to-cart"]');
// Esperar que el modal se abra
await page.waitForSelector('[role="dialog"]');
const results = await new AxeBuilder({ page })
.include('[role="dialog"]')
.analyze();
expect(results.violations).toEqual([]);
});Testing de navegación por teclado
Axe detecta atributos ARIA faltantes. El testing manual con teclado encuentra problemas de flujo de navegación:
test('el formulario de login es navegable por teclado', async ({ page }) => {
await page.goto('/login');
// Tabular por los campos del formulario
await page.keyboard.press('Tab');
await expect(page.locator(':focus')).toHaveAttribute('data-testid', 'email-input');
await page.keyboard.press('Tab');
await expect(page.locator(':focus')).toHaveAttribute('data-testid', 'password-input');
await page.keyboard.press('Tab');
await expect(page.locator(':focus')).toHaveAttribute('data-testid', 'submit-btn');
// Enviar con la tecla Enter
await page.keyboard.press('Enter');
await page.waitForURL('/dashboard');
});
test('el modal se puede cerrar con Escape', async ({ page }) => {
await page.goto('/products');
await page.click('[data-testid="filter-btn"]');
await expect(page.getByRole('dialog')).toBeVisible();
await page.keyboard.press('Escape');
await expect(page.getByRole('dialog')).not.toBeVisible();
});Manejo del foco
Cuando los modales se abren, el foco debe moverse hacia adentro. Cuando se cierran, el foco debe volver:
test('el modal atrapa el foco correctamente', async ({ page }) => {
await page.goto('/');
// Abrir el modal
const triggerButton = page.getByTestId('open-modal');
await triggerButton.click();
const modal = page.getByRole('dialog');
await expect(modal).toBeVisible();
// El foco debe estar dentro del modal
const focusedElement = page.locator(':focus');
await expect(modal).toContainElement(focusedElement);
// Tabular por el modal — el foco no debe escapar
await page.keyboard.press('Tab');
await page.keyboard.press('Tab');
await page.keyboard.press('Tab');
// Sigue dentro del modal
await expect(modal).toContainElement(page.locator(':focus'));
// Cerrar el modal
await page.keyboard.press('Escape');
// El foco vuelve al botón activador
await expect(triggerButton).toBeFocused();
});Verificar el alt text de las imágenes
test('todas las imágenes de productos tienen alt text', async ({ page }) => {
await page.goto('/products');
// Encontrar todas las imágenes
const images = page.locator('img');
const count = await images.count();
for (let i = 0; i < count; i++) {
const img = images.nth(i);
const alt = await img.getAttribute('alt');
const src = await img.getAttribute('src');
// El alt debe existir y no ser una cadena vacía (salvo que sea decorativa con role="presentation")
const role = await img.getAttribute('role');
if (role !== 'presentation') {
expect(alt, `La imagen ${src} no tiene alt text`).not.toBeNull();
expect(alt, `La imagen ${src} tiene alt text vacío`).not.toBe('');
}
}
});Roles y etiquetas ARIA
test('los campos de formulario tienen etiquetas', async ({ page }) => {
await page.goto('/contact');
const inputs = page.locator('input, textarea, select');
const count = await inputs.count();
for (let i = 0; i < count; i++) {
const input = inputs.nth(i);
const type = await input.getAttribute('type');
// Omitir inputs ocultos
if (type === 'hidden') continue;
const id = await input.getAttribute('id');
const ariaLabel = await input.getAttribute('aria-label');
const ariaLabelledBy = await input.getAttribute('aria-labelledby');
// El input debe tener una etiqueta (vía id+label, aria-label o aria-labelledby)
const hasLabel = id ? await page.locator(`label[for="${id}"]`).count() > 0 : false;
const isLabelled = hasLabel || ariaLabel || ariaLabelledBy;
expect(isLabelled, `Campo sin etiqueta: ${id || 'sin nombre'}`).toBeTruthy();
}
});Generar reportes de violaciones legibles
import AxeBuilder from '@axe-core/playwright';
// Helper que formatea las violaciones de forma legible
async function verificarAccesibilidad(page, selector?: string) {
const builder = new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa']);
if (selector) builder.include(selector);
const results = await builder.analyze();
if (results.violations.length > 0) {
const report = results.violations.map(v =>
`\n[${v.impact?.toUpperCase()}] ${v.id}: ${v.description}\n` +
v.nodes.map(n => ` - ${n.target.join(', ')}: ${n.failureSummary}`).join('\n')
).join('\n');
throw new Error(`Violaciones de accesibilidad encontradas:\n${report}`);
}
}
test('el dashboard es accesible', async ({ page }) => {
await page.goto('/dashboard');
await verificarAccesibilidad(page);
});Problemas de accesibilidad comunes detectados por automatización
| Problema | Regla de axe | Solución |
|----------|-------------|----------|
| Imagen sin alt | image-alt | Agregar alt="descripción" |
| Bajo contraste | color-contrast | Usar relación de contraste ≥ 4.5:1 |
| Campo sin etiqueta | label | Agregar o aria-label |
| Botón sin texto | button-name | Agregar texto o aria-label |
| Orden de encabezados | heading-order | No saltarse h1 a h3 |
| Lang faltante | html-has-lang | Agregar |
| Enlace sin nombre | link-name | Agregar texto de enlace descriptivo |
Resumen
# Instalar
npm install --save-dev @axe-core/playwright
# Uso básico
const results = await new AxeBuilder({ page }).analyze();
expect(results.violations).toEqual([]);
# Filtrar por nivel WCAG
.withTags(['wcag2a', 'wcag2aa'])
# Limitar a un elemento
.include('#main-nav')
# Excluir un problema conocido
.disableRules(['color-contrast'])Ejecuta verificaciones de accesibilidad en tus páginas críticas de la misma forma que ejecutas tests funcionales: en cada PR, automáticamente.
→ See also: Pruebas de Accesibilidad para Ingenieros QA: Herramientas, Técnicas y la Fecha Límite EAA 2025 | Eventos de Teclado y Ratón en Playwright | Testing Visual de Regresión con IA: Más Allá de las Capturas Pixel-Perfectas