O @axe-core/playwright executa verificações de acessibilidade junto com seus testes regulares a cada commit no CI. Detecta alt text ausente, inputs sem label, contraste de cor insuficiente e uso incorreto de ARIA. A varredura automatizada captura cerca de 30-40% das violações WCAG; o restante exige testes manuais de teclado e leitor de tela.

Por que Automatizar Testes de Acessibilidade?

Auditorias manuais de acessibilidade são lentas e caras. Verificações automatizadas encontram os problemas mais comuns na hora:

  • Alt text ausente em imagens
  • Inputs de formulário sem label
  • Contraste de cor insuficiente
  • Roles e atributos ARIA faltando
  • Problemas de navegação por teclado
  • Problemas de gerenciamento de foco

Ferramentas automatizadas detectam ~30-40% dos problemas de acessibilidade. O restante exige testes manuais com tecnologias assistivas reais. Mas capturar 30-40% automaticamente, a cada commit, é muito melhor do que nada.

Setup: axe-playwright

npm install --save-dev @axe-core/playwright

Scan Básico de Acessibilidade

import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';

test('home page sem violações de acessibilidade', async ({ page }) => {
  await page.goto('/');
  
  const results = await new AxeBuilder({ page }).analyze();
  
  expect(results.violations).toEqual([]);
});

Se houver violações, o teste falha com detalhes:

● home page sem violações de acessibilidade

  expect(received).toEqual(expected)
  
  Expected: []
  Received: [
    {
      id: 'color-contrast',
      description: 'Elements must have sufficient color contrast',
      nodes: [{ target: ['#nav-link'], ... }]
    }
  ]

Escaneando Páginas Específicas

test.describe('Verificações de acessibilidade', () => {
  const pages = [
    { name: 'Home', url: '/' },
    { name: 'Login', url: '/login' },
    { name: 'Produtos', url: '/products' },
    { name: 'Contato', url: '/contact' },
  ];

  for (const { name, url } of pages) {
    test(`${name} é acessível`, async ({ page }) => {
      await page.goto(url);
      await page.waitForLoadState('networkidle');
      
      const results = await new AxeBuilder({ page }).analyze();
      expect(results.violations).toEqual([]);
    });
  }
});

Filtrando Regras

Execute apenas critérios WCAG específicos ou exclua problemas conhecidos:

test('página de login em conformidade com WCAG AA', async ({ page }) => {
  await page.goto('/login');
  
  const results = await new AxeBuilder({ page })
    // Verifica apenas critérios WCAG 2.1 AA
    .withTags(['wcag2a', 'wcag2aa', 'wcag21aa'])
    .analyze();
  
  expect(results.violations).toEqual([]);
});

test('página de produtos com problemas conhecidos excluídos', async ({ page }) => {
  await page.goto('/products');
  
  const results = await new AxeBuilder({ page })
    // Excluir temporariamente um problema conhecido enquanto está sendo corrigido
    .disableRules(['color-contrast'])
    .analyze();
  
  expect(results.violations).toEqual([]);
});

Escaneando Parte da Página

test('menu de navegação é acessível', async ({ page }) => {
  await page.goto('/');
  
  const results = await new AxeBuilder({ page })
    .include('#main-navigation')  // Escaneia apenas o nav
    .analyze();
  
  expect(results.violations).toEqual([]);
});

test('modal é acessível', async ({ page }) => {
  await page.goto('/products');
  await page.click('[data-testid="add-to-cart"]');
  
  // Aguarda o modal abrir
  await page.waitForSelector('[role="dialog"]');
  
  const results = await new AxeBuilder({ page })
    .include('[role="dialog"]')
    .analyze();
  
  expect(results.violations).toEqual([]);
});

Testes de Navegação por Teclado

O axe detecta atributos ARIA faltando. Testes manuais de teclado encontram problemas no fluxo de navegação:

test('formulário de login navegável por teclado', async ({ page }) => {
  await page.goto('/login');
  
  // Navega pelos campos do formulário com Tab
  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');
  
  // Envia com Enter
  await page.keyboard.press('Enter');
  await page.waitForURL('/dashboard');
});

test('modal pode ser fechado com 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();
});

Gerenciamento de Foco

Quando modais abrem, o foco deve ir para dentro deles. Quando fecham, o foco deve retornar ao elemento que os abriu:

test('modal mantém o foco corretamente', async ({ page }) => {
  await page.goto('/');
  
  // Abre o modal
  const triggerButton = page.getByTestId('open-modal');
  await triggerButton.click();
  
  const modal = page.getByRole('dialog');
  await expect(modal).toBeVisible();
  
  // Foco deve estar dentro do modal
  const focusedElement = page.locator(':focus');
  await expect(modal).toContainElement(focusedElement);
  
  // Tab pelo modal — foco não deve sair
  await page.keyboard.press('Tab');
  await page.keyboard.press('Tab');
  await page.keyboard.press('Tab');
  
  // Ainda dentro do modal
  await expect(modal).toContainElement(page.locator(':focus'));
  
  // Fecha o modal
  await page.keyboard.press('Escape');
  
  // Foco retorna ao botão que abriu o modal
  await expect(triggerButton).toBeFocused();
});

Verificação de Alt Text em Imagens

test('todas as imagens de produto têm alt text', async ({ page }) => {
  await page.goto('/products');
  
  // Encontra todas as imagens
  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');
    
    // Alt deve existir e não ser string vazia (exceto decorativas com role="presentation")
    const role = await img.getAttribute('role');
    if (role !== 'presentation') {
      expect(alt, `Imagem ${src} sem alt text`).not.toBeNull();
      expect(alt, `Imagem ${src} com alt text vazio`).not.toBe('');
    }
  }
});

Roles e Labels ARIA

test('inputs do formulário têm labels', 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');
    
    // Ignora inputs hidden
    if (type === 'hidden') continue;
    
    const id = await input.getAttribute('id');
    const ariaLabel = await input.getAttribute('aria-label');
    const ariaLabelledBy = await input.getAttribute('aria-labelledby');
    
    // Input precisa ter label (via id+label, aria-label, ou aria-labelledby)
    const hasLabel = id ? await page.locator(`label[for="${id}"]`).count() > 0 : false;
    
    const isLabelled = hasLabel || ariaLabel || ariaLabelledBy;
    expect(isLabelled, `Input sem label: ${id || 'sem nome'}`).toBeTruthy();
  }
});

Gerando Relatórios HTML Acessíveis

import AxeBuilder from '@axe-core/playwright';

// Helper que formata as violações de forma legível
async function checkAccessibility(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(`Violações de acessibilidade encontradas:\n${report}`);
  }
}

test('dashboard é acessível', async ({ page }) => {
  await page.goto('/dashboard');
  await checkAccessibility(page);
});

Problemas Comuns Detectados pela Automação

| Problema | Regra axe | Correção |

|----------|-----------|----------|

| Imagem sem alt | image-alt | Adicionar alt="descrição" |

| Contraste baixo | color-contrast | Usar razão de contraste ≥ 4.5:1 |

| Input sem label | label | Adicionar ou aria-label |

| Botão sem texto | button-name | Adicionar texto ou aria-label |

| Ordem de heading | heading-order | Não pular h1 para h3 |

| Lang ausente | html-has-lang | Adicionar |

| Link sem nome | link-name | Adicionar texto descritivo ao link |

Resumo

# Instalação
npm install --save-dev @axe-core/playwright

# Uso básico
const results = await new AxeBuilder({ page }).analyze();
expect(results.violations).toEqual([]);

# Filtrar por nível WCAG
.withTags(['wcag2a', 'wcag2aa'])

# Limitar ao elemento
.include('#main-nav')

# Excluir problema conhecido
.disableRules(['color-contrast'])

Execute verificações de acessibilidade nas suas páginas críticas da mesma forma que você roda testes funcionais: em cada PR, automaticamente. Combinado com testes de navegação por teclado e verificações de gerenciamento de foco, você captura a maioria dos problemas antes que cheguem a usuários que dependem de tecnologia assistiva.

→ Veja também: Testes de Acessibilidade para Engenheiros QA: Ferramentas, Técnicas e o Prazo EAA 2025 | Eventos de Teclado e Mouse no Playwright | Testes de Regressão Visual com IA: Além das Capturas Pixel-Perfeitas