← Voltar ao Blog
Design & UI/UX

Acessibilidade com Playwright e axe: testar estados da interface

Teste diálogos, validação e navegação com Playwright e axe. Inclui exemplos executáveis, verificações de foco, orientação para CI e limites de cobertura.

Escrito por Hamza Diaz
5 de outubro de 202612 min de leitura92 visualizações

Os testes de acessibilidade com Playwright são mais úteis quando o teste chega ao estado da interface de que uma pessoa precisa: um diálogo aberto, uma submissão de formulário rejeitada ou uma navegação expandida. Uma análise da página inicial não consegue inspecionar controlos que ainda não foram renderizados. O Playwright conduz a interação; o axe-core verifica o DOM resultante face às regras selecionadas.1

Este guia apresenta uma página de teste local executável e três testes e, depois, explica o que alterar para uma aplicação. É uma receita de testes de regressão, não uma avaliação de conformidade com as WCAG. A avaliação manual da acessibilidade e os testes inclusivos com utilizadores continuam a ser necessários.1

Escolha estados, não apenas rotas

Comece por uma tarefa importante, como alterar um endereço de e-mail. Registe a conta e os dados de partida, a ação, o resultado esperado e o limite da análise. Inclua os caminhos de erro e de recuperação, não apenas uma submissão bem-sucedida. Uma rota pode conter vários estados testáveis de forma independente.

Use STATE como lista de verificação de planeamento: selecionar um estado, desencadeá-lo, verificar o resultado, fazer a triagem dos problemas detetados e reunir evidências. É uma mnemónica editorial baseada em termos ingleses, não uma norma. A sequência abaixo tem um equivalente textual nestes cinco passos.

flowchart TD A[Selecionar um estado] --> B[Desencadear a interação] B --> C[Verificar e analisar] C --> D[Fazer a triagem dos problemas] D --> E[Guardar evidências]

Prefira um inventário pequeno e explícito a uma contagem de rotas. Nas definições da conta, cubra a abertura e o fecho do diálogo, a introdução de dados inválidos e a recuperação bem-sucedida. Na navegação, cubra os estados expandido e recolhido numa janela de visualização estreita. Acrescente autenticação, permissões, sinalizadores de funcionalidades e falhas de carregamento sempre que alterem os controlos com que o utilizador se depara.

Aguarde o resultado da ação

O Playwright não aplica as mesmas verificações de espera a todas as ações. click() verifica a visibilidade, a estabilidade, a receção de eventos e o estado ativado. fill() verifica a visibilidade, o estado ativado e a editabilidade; não exige estabilidade nem receção de eventos. Um clique ou um preenchimento concluído não prova que a validação ou a renderização assíncrona tenham terminado.2

Use uma asserção com novas tentativas automáticas sobre o resultado real antes de chamar analyze(): um diálogo com nome está visível, surge um erro ou uma mensagem de estado contém o texto esperado. Evite pausas arbitrárias. Se a sua aplicação carregar opções depois de abrir um diálogo, verificar apenas que a estrutura do diálogo está visível é insuficiente; aguarde também pelas opções ou por um estado de prontidão documentado.12

Execute o exemplo localmente

O exemplo usa @playwright/test 1.58.1 e @axe-core/playwright 4.11.1. Guarde os ficheiros seguintes num diretório vazio, com o Node.js e o npm disponíveis. A configuração usa um Google Chrome instalado; não transfere nenhum navegador. Para usar antes o Chromium incluído, remova channel e execute 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 test

Execute o último comando depois de guardar os três ficheiros. Mantenha o ficheiro de bloqueio (lockfile) gerado no controlo de versões: a versão do pacote de integração, por si só, não fixa todas as dependências transitivas. A 5 de outubro de 2026, os três testes passaram no Chrome local com o axe-core 4.11.4, a dependência resolvida pelo pacote de integração. Foi uma execução com uma página de teste sintética, não um teste de um serviço de contas em produção.

1. Guarde fixture.html

A página de teste usa um diálogo modal nativo, validação de e-mail do lado do cliente e uma navegação expansível comum (padrão disclosure). A ação Save altera apenas texto local; não envia nenhum e-mail nem armazena nada. O ciclo de foco trata apenas os campos e os botões desta página de teste; uma implementação reutilizável tem de ter em conta outros controlos e elementos ocultos ou desativados. Trata-se de dados de teste, não de uma biblioteca de componentes de produção.

<!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. Guarde 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. Guarde states.spec.mjs

Os localizadores por função e por rótulo exprimem os nomes acessíveis esperados. São contratos úteis, mas localizar um controlo pela sua função não prova que toda a interação com ele seja acessível.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');
});

O que as asserções comprovam

O primeiro teste ativa o diálogo pelo teclado, aguarda o seu nome acessível e o estado visível, verifica o foco à entrada e depois verifica as duas extremidades da sequência de Tab. A tecla Escape e o botão Cancel visível fecham-no e devolvem o foco ao elemento que o abriu. O padrão de diálogo do APG descreve estes comportamentos, mas o foco inicial nem sempre tem de ir para o primeiro campo: um conteúdo longo pode exigir que vá para um título e uma confirmação destrutiva pode exigir que fique na ação mais segura. O foco também pode regressar a um passo seguinte lógico quando o elemento que abriu o diálogo desaparece ou quando o fluxo de trabalho o exige.3

O showModal() nativo da página de teste torna o fundo inerte. Um diálogo personalizado precisa de uma verificação separada de que os controlos em segundo plano não podem ser operados; definir apenas aria-modal="true" não implementa esse comportamento.3 Inspecione também o foco visível e a ordem de leitura. As asserções programáticas de foco não indicam se um indicador de foco está tapado por um cabeçalho fixo.

O teste de validação aguarda o erro visível, aria-invalid, a descrição acessível do campo e o foco. Depois, introduz dados válidos e verifica o fecho do diálogo, a limpeza do estado de erro e a mensagem de sucesso. Verificar apenas aria-describedby seria mais fraco: o elemento referenciado poderia não existir ou conter o texto errado. A asserção final sobre o atributo usa um localizador de DOM porque o diálogo fechado deixa de estar exposto ao localizador por função predefinido.

Uma asserção sobre o texto da região de estado comprova a atualização do DOM, não o que um leitor de ecrã anunciou nem quando. Teste os anúncios com as combinações de navegador e tecnologia de apoio que suporta. Numa aplicação, aguarde também a resposta real da gravação dos dados e verifique o valor persistido; esta página de teste, intencionalmente, não faz nenhuma das duas coisas.

Uma navegação expansível não é um menu ARIA

O terceiro teste usa um botão que expande ligações comuns. Verifica aria-expanded, a visibilidade, a ativação com a tecla Espaço, a passagem com Tab para a primeira ligação e o recolhimento com Enter. A navegação típica de um site não precisa da função ARIA menu nem do respetivo modelo de teclado especializado.7

Para um verdadeiro menu de comandos, use antes o contrato do botão de menu: um botão com aria-haspopup="menu", aria-expanded sincronizado, um menu com nome e itens de menu com nomes corretos. Enter e Espaço devem abri-lo e colocar o foco no primeiro item.4 Acrescente testes para a navegação com as teclas de seta suportada pelo menu, para a ativação e para o fecho com Escape e a devolução do foco. Não cole asserções de navegação expansível num widget de menu para depois dar essa cobertura como completa.

Interprete o resultado do axe, incluindo os resultados incompletos

O auxiliar seleciona as etiquetas WCAG A/AA até à versão 2.2 disponíveis nesta versão do axe. Isto seleciona as regras automatizadas com essas etiquetas, não todos os critérios de sucesso desses níveis. As análises predefinidas do axe incluem também regras classificadas como boas práticas; filtrar por etiquetas WCAG altera esse âmbito.15

O resultado contém violations, passes, incomplete e inapplicable. Um resultado incompleto exige revisão: o axe não conseguiu decidir se o nó afetado passou ou falhou. Uma regra não aplicável não encontrou conteúdo relevante; não é prova de que testou essa funcionalidade.5

Esta pequena página de teste falha tanto com violações como com resultados incompletos, depois de anexar o resultado completo. Numa aplicação real, pode, em alternativa, encaminhar os resultados incompletos para uma fila de revisão com um responsável atribuído. Torne isso uma política explícita, com um prazo de revisão, em vez de descartar silenciosamente a lista de resultados ou de a tratar como aprovação. Guarde, juntamente com os artefactos, a versão do motor obtida em results.testEngine, o navegador/projeto, o nome do estado e o commit. Redefina a linha de base de forma deliberada após atualizações de dependências.

Seja preciso quanto aos limites da análise

Estes testes analisam toda a página atual. .include('#panel') pode reduzir o ruído numa verificação de componente, mas não garante cobertura fora dessa subárvore. .exclude() remove o elemento selecionado e os seus descendentes de todas as regras, pelo que uma exclusão abrangente pode ocultar regressões não relacionadas.1

O pacote de integração do axe para Playwright injeta automaticamente o motor nos frames. O seu modo legado desativa os testes de frames de origem cruzada e reporta os frames não testados; um erro de análise ou um frame não testado é uma lacuna de cobertura, não um resultado limpo.8 Aguarde o carregamento do conteúdo incorporado, inspecione os resultados relativos a frames e exercite explicitamente os fluxos incorporados importantes.

Os localizadores do Playwright suportam, em geral, shadow roots abertas, exceto com XPath; as shadow roots fechadas não são suportadas. O axe consegue percorrer o shadow DOM aberto, mas o sucesso de um localizador não garante, por si só, a cobertura da análise.56 Documente separadamente as shadow roots fechadas e os widgets de terceiros. A página de teste acima não contém nenhum iframe nem shadow root, pelo que o facto de passar não permite concluir nada sobre nenhum deles.

Integre falhas úteis na CI

Mantenha sintéticos os utilizadores da página de teste, use contas dedicadas para os testes autenticados e restrinja o acesso aos artefactos. Os resultados do axe contêm excertos do DOM; os traces, as capturas de ecrã e os registos podem expor dados pessoais e detalhes de sessão. Defina limites de retenção e limpe os dados sensíveis antes de os partilhar.

Execute os testes dos estados críticos nos pull requests. Acrescente janelas de visualização móveis representativas, outros navegadores suportados e verificações manuais com uma periodicidade adequada ao produto. O resultado de uma página de teste restrita ao Chrome nunca deve ser reapresentado como cobertura entre navegadores. Nas falhas, conserve o estado, o ID da regra, os alvos afetados, os passos de reprodução e o responsável atribuído. Prefira uma exceção específica e com prazo de validade à desativação de uma regra em toda a aplicação.

Nos fluxos de conta, alargue esta abordagem aos ecrãs alternativos e de recuperação, conforme descrito no guia de implementação de passkeys. Comece por uma tarefa do utilizador que falha, escreva o respetivo contrato observável e, depois, corrija o componente e volte a executar esse estado. O resultado é um teste de regressão que outra pessoa consegue manter.

Pontos principais

  • 1Chegue a cada estado renderizado e verifique-o antes de executar o axe; a conclusão de uma ação não é um sinal de prontidão.
  • 2Verifique os nomes dos diálogos, a contenção do foco, o fecho e o destino adequado do foco.
  • 3Verifique em conjunto os erros visíveis, as associações aos campos e a recuperação bem-sucedida.
  • 4Trate os resultados incompletos como trabalho de revisão e registe as versões e os limites da análise.
  • 5Use STATE para selecionar e desencadear estados, verificar resultados, fazer a triagem dos problemas detetados e reunir evidências.

Conclusão

Escolha um estado crítico para o utilizador e defina os resultados visíveis, semânticos e de teclado antes de executar a análise. Mantenha juntos o teste, o âmbito da análise e as evidências de revisão, para que uma falha futura seja reproduzível.

Perguntas frequentes

Uma análise do axe sem falhas prova a conformidade com as WCAG?

Não. Apenas reporta as verificações automatizadas selecionadas para o estado renderizado. A avaliação manual, os testes com tecnologias de apoio e os testes inclusivos com utilizadores continuam a ser necessários.

Devo analisar a página inteira ou apenas um componente?

Sempre que possível, faça uma análise da página inteira para o estado. Uma análise de âmbito restrito é útil para um componente, mas registe o respetivo limite e mantenha verificações separadas para o conteúdo excluído.

O que devo fazer com os resultados incompletos?

Reveja os nós afetados. O axe não conseguiu determinar se passaram ou falharam. Faça o teste falhar até haver revisão ou acompanhe os resultados numa fila com responsável e prazo definidos.

O fill espera que um elemento deixe de se mover?

Não. No fill, o Playwright verifica se o elemento está visível, ativado e editável. Verifique separadamente o estado resultante da aplicação antes de executar a análise.

Posso reutilizar estes testes em produção?

Substitua a página de teste sintética pela sua aplicação e atualize os nomes acessíveis, os dados e as asserções de prontidão. Acrescente os navegadores e as janelas de visualização suportados, bem como verificações manuais; o exemplo não testa a persistência nem um serviço real.

Fontes

Compartilhar este artigo

Hamza Diaz

Escrito por

Hamza Diaz

Hamza Diaz é o fundador da Optijara, onde cria agentes de IA práticos, sistemas de automação e fluxos de trabalho do Copilot para empresas de serviços. Ele escreve sobre operações de IA, estratégia de agentes e implementação no mundo real para equipes que querem sistemas úteis em vez de exagero.