@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/playwright

Escaneo 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