Testes Automatizados de Acessibilidade
A11Y fazendo parte do seu dia a dia com Jest, aXe e Puppeteer.
Como desenvolvedores web, sempre tivemos ao nosso lado os padrões web. Tá certo que, para quem já está nesse barco a um tempo, essa afirmação não passa aquela segurança. Mas ó, pelo menos tem alguém tentando :P, tem melhorado ano após ano, ou, olhando por outro lado, ano após ano o navegador vilão e mocinho são alterados! (Estou 👀 para vocês Chrome/IE/Safari).
O que muitas vezes passa despercebido, é que Acessibilidade também é um padrão, e a web é Acessível por padrão, somos nós (e os Designers 👀) que gostamos de "pensar fora da caixa" (se é que esse termo se encaixa aqui).
Nossas soluções atuais
Se tem uma coisa que todas as aplicações tem (ok, nem todas :P) são testes. De unitários a funcionais, o que é importante é extrair valor dos seus testes.
Com a evolução de nossas ferramentas na linguagem JavaScript, hoje temos soluções bem sólidas para rodar testes, e uma delas é o Jest (você pode conferir meu guia sobre Jest aqui).
Também temos uma ferramentas para automatizar testes de acessibilidade em HTML chamada aXe. Que deixa bem simples a integração com diversas ferramentas. Vale lembra, que ele necessita de um navegador real, com sua aplicação rodando, para excutar os testes.
Até aqui, temos o seguinte:
- Uma ótima plataforma para rodar testes, Jest
- Uma ótima ferramenta para rodar testes de acessibilidade, aXe
O que está pendente aqui? Um navegador real para rodar os testes!
Como podemos resolver isso?
Resolvendo a última pendência
No final do ano passado, o time do Chrome DevTools lançou uma ferramenta chamada Puppeteer.
Puppeteer é um biblioteca Node, que disponibiliza uma API para controlar uma versão headless (sem a interface gráfica) do Chrome.
Ou seja, você pode rodar o Chrome, através da linha de comando e executar qualquer comando do navegador que você desejar, tais como: gerar PDF, testar eventos, prints de páginas e até mesmo, testes, principalmente os de acessibilidade!
Essa é a peça que faltava para criarmos testes automatizados de alta qualidade para validar a acessibilidade da sua aplicação.
Nosso projeto de exemplo
No exemplo a seguir, iremos abordar a seguinte estrutura:
- Uma pasta
public
para o código do nosso exemplo - Dentro de
public
, um arquivoindex.html
- Usaremos
jest
,jest-axe
epuppeteer
para criar um novo ambiente de testes para o Jest + Puppeteer e executar a validação padrão do aXe.
Vamos lá:
mkdir -p jest-axe-puppeteer/public
cd jest-axe-puppeteer
touch public/index.html
Iremos iniciar um repositório Git, para acompanhar as mudanças:
git init
git add -A
git commit -m "core: initial commit"
Vamos iniciar nosso package.json
e adicionar nossas depêndencias:
yarn init -y
yarn add jest jest-axe puppeteer
Vamos adicionar as mudanças ao nosso repositório:
git add -A
git commit -m "core: add Jest, aXe and Puppeteer deps"
Agora, vamos focar na configuração do Jest. Teremos que alterar 3 ciclos de vida do Jest, e segundo a documentação, a integração é simples.
Configurando Jest e Puppeteer
Essas alterações são relacionadas ao globalSetup
, globalTeardown
e testEnvironment
. Precisamos criar um ambiente diferente para os testes de integração de acessibilidade. Esse ambiente que iremos criar, contém um navegador real, rodando através da linha de comando. Ou seja, isso é um ambiente de testes E2E completo!
Vamos criar, a raíz do nosso projeto, um arquivo jest.config.js
touch jest.config.js
E adicionar:
module.exports = {
globalSetup: "./integration/config/setup.js",
globalTeardown: "./integration/config/teardown.js",
testEnvironment: "./integration/config/puppeteer_environment.js"
};
Vamos analisar passo a passo cada arquivo, mas primeiro, vamos criar a pasta integration
e adicionar tudo ao Git.
mkdir -p integration/config
touch integration/config/{setup,teardown,puppeteer_environment}.jsgit add -A
git commit -m "feat: add integration/ and jest.config.js"
Agora, vamos abrir integration/config/setup.js
e adicionar:
const fs = require('fs');
const os = require('os');
const path = require('path');
const puppeteer = require('puppeteer');
const mkdirp = require('mkdirp');
const express = require('express');const DIR = path.join(os.tmpdir(), 'jest_puppeteer_global_setup');const IS_WATCH = process.argv.includes('--watch');module.exports = async () => {
// [A]
global.BROWSER = await puppeteer.launch({
args: ['--no-sandbox', '--disable-setuid-sandbox'],
}); // [B]
mkdirp.sync(DIR); fs.writeFileSync(
path.join(DIR, 'wsEndpoint'),
global.BROWSER.wsEndpoint()
); // [C]
if (!IS_WATCH) {
const app = express();
app.use('/', express.static(path.join('public')));
global.SERVER = app.listen(5000);
}
};
Eita! Quanta coisa acontecendo! Vamos analisar as etapas [A]
, [B]
e [C]
:
- [A]: Estamos guardando a instância do Puppeteer em
global.BROWSER
, para podermos fechar o navegador após todos os testes forem realizados noteardown.js
- [B]: Estamos escrevendo, na pasta
tmp
do sistema operacional, o endereço do WebSockets do DevTools criado pela etapa [A]. Iremos precisar dele empuppeteer_environment.js
, onde iremos conectar ao navegador para realizar os testes - [C]: Aqui, um pequeno truque, quando não estivermos usando
--watch
, iremos criar uma instância doexpress
emglobal.SERVER
apontando para a pastapublic
. Isso é necessário pois, ao rodar os testes em CI, você não terá seu servidor de desenvolvimento rodando. Iremos ver mais detalhes disso empuppeteer_environment.js
Vamos adicionar express
ao package.json
e colocar tudo no Git:
yarn add expressgit add -A
git commit -m "feat: add integration/setup and express as dep"
Agora, vamos para o próximo arquivo, teardown.js
:
const rimraf = require('rimraf');
const os = require('os');
const path = require('path');const DIR = path.join(os.tmpdir(), 'jest_puppeteer_global_setup');module.exports = async () => {
await global.BROWSER.close(); rimraf.sync(DIR); if (global.SERVER) {
await global.SERVER.close();
}
};
Como o próximo nome diz, iremos destruir/limpar toda a bagunça que fizemos. No nosso caso, fechar a instância do navegador, remover a pasta do WebSocket e, caso nossa instância do express
exista, fechar o servidor.
Perceba que temos rimraf
como nova dependência.
Vamos adicionar isso ao package.json
e ao Git:
yarn add rimrafgit add -A
git commit -m "feat: add integration/teardown and rimraf as dep"
Vamos voltar nossa atenção ao arquivo puppeteer_environment
:
const NodeEnvironment = require('jest-environment-node');
const puppeteer = require('puppeteer');
const fs = require('fs');
const os = require('os');
const path = require('path');const DIR = path.join(os.tmpdir(), 'jest_puppeteer_global_setup');const IS_WATCH = process.argv.includes('--watch');/**
* Testes de integração E2E automatizados construído com Puppeteer.
* Veja https://facebook.github.io/jest/docs/en/puppeteer.html.
*/// [A]
class PuppeteerEnvironment extends NodeEnvironment {
constructor(config, options) {
super(config, options); // [B]
const port = IS_WATCH ? 3000 : 5000;
const index = '';
// [C]
this.global.ROOT = `http://localhost:${port}/${index}`;
} async setup() {
await super.setup(); // [D]
const wsEndpoint = fs.readFileSync(
path.join(DIR, 'wsEndpoint'),
'utf8',
); if (!wsEndpoint) {
throw new Error('wsEndpoint not found');
} this.global.BROWSER = await puppeteer.connect({
browserWSEndpoint: wsEndpoint,
});
} async teardown() {
await super.teardown();
} runScript(script) {
return super.runScript(script);
}
}module.exports = PuppeteerEnvironment;
Simple, mas complexo? Vamos analisar as etapas [A]
, [B]
, [C]
e [D]
:
- [A]: Estamos criando uma nova classe
PuppeteerEnvironment
que extende oNodeEnvironment
, que é um dos ambientes disponíveis no Jest, estamos usando a dependênciajest-envrironment-node
para isso. Isso é necessário para não executarmos nehum outro ambiente disponível no Jest (como ojsdom
). Ou seja, esse novo ambiente, é e roda por padrão, em Node. - [B]: Aqui é a continuação do truque que vimos em
setup.js
. Caso você execute os testes de integração com--watch
, iremos buscar a aplicação na porta3000
(normalmente, a porta de desenvolvimento), disponibilizando assim, uma experiência em tempo real para o desenvolvedor detectar os erros de acessibilidade. Caso contrário, iremos procurar a porta da instânciaexpress
que criamos. - [C]: Aqui, outro pequeno truque, estamos guarando a URL em
global.ROOT
, para facilitar o acesso da URL da aplicação nos testes de integração. Iremos ver mais detalhes disso ao escrever nosso primeiro teste. - [D]: Nesse ponto, estamos lendo o endereço de WebSockets do DevTools para podermos realizar a comunicação entre instância e API.
Vamos adicionar nossa nova dependência e colocar tudo no Git:
yarn add jest-environment-nodegit add -A
git commit -m "feat: add integration/puppeteer_environment and jest-environment-node as dep"
Ufa 😅… a parte de configuração já passou. Agora, vamos ao divertido!
Nosso primeiro teste e um pouco de HTML!
Lembra que criamos um arquivo HTML em public/index.html
? (É, nem eu, depois de tudo isso de configuração 👽).
Vamos adicionar um pouco de HTML nele, para podermos criar algum teste:
Em public/index.html
:
<!DOCTYPE html>
<html lang="pt-br">
<head>
<meta charset="UTF-8">
<title>Jest, aXe e Puppeteer</title>
</head>
<body>
<h1>Garçom, aqui nesse HTML...</h1>
</body>
</html>
E agora, dentro de integration
iremos criar home.test.js
e adicionar:
const { toHaveNoViolations } = require("jest-axe");expect.extend(toHaveNoViolations);jest.setTimeout(10000);describe("/", () => {
let page; // [A]
beforeAll(async () => {
page = await global.BROWSER.newPage();
await page.goto(global.ROOT); // [B]
await page.addScriptTag({ path: require.resolve("axe-core") });
}); // [C]
it("carrega corretamente", async () => {
const text = await page.evaluate(
() => document.body.textContent
);
expect(text).toContain("Garçom, aqui nesse HTML...");
}); // [D]
it("a11y", async () => {
const result = await page.evaluate(() => {
return new Promise(resolve => {
window.axe.run((err, results) => {
if (err) throw err;
resolve(results);
});
});
}); expect(result).toHaveNoViolations();
});
});
Parece um teste, mas e aí? Curiosidades em [A]
, [B]
, [C]
e [D]
:
- [A]: Antes de todos os testes, precisamos conectar o navegador na nossa aplicação. Com os truques que introduzimos na configuração, podemos acessar a instância do navegador e a URL aplicação através do objeto
global
. - [B]: Estamos adicionando, dinamicamente, a URL para o
axe-core
, assim como demonstrado no repositório oficial. Dessa maneira, você não precisa ficar lembrando de adicionar o script que contém as validações de acessibilidade.axe-core
é uma dependência dejest-axe
. - [C]: Como exemplo de um ambiente completo E2E, aqui estamos avaliando o conteúdo da página e testando se está correto.
- [D]: É aqui que executamos nosso teste de acessibilidade utilizando a biblioteca aXe. Da mesma maneira que realizamos na interface do navegador. Irado!!1 :) 🎉🎉🎉
Agora, vamos executar nosso teste com:
yarn jest
E nossa validação de acessibilidade com aXe já achou um erro e os testes estão em vermelho! O bacana é que o erro é bem amigável, contendo até uma URL para as regras utilizadas pelo aXe com possíveis soluções!
Antes de resolver esse erro, vamos adicionar tudo ao Git:
git add -A
git commit -m "feat: add home HTML and initial tests"
Refatorando código para passar nos testes!
Para resolver o problema acima, podemos adicionar um HTML semântico para a regra landmark-one-main
, em public/index.html
adicione:
...
<main>
<h1>Garçom, aqui nesse HTML...</h1>
</main>
...
E vamos rodar os testes novamente:
yarn jest
Com nosso código arrumado, vamos adicionar ao Git:
git add -A
git commit -m "task: HTML refactor to make a11y tests happy"
Agora não tem desculpa, A11Y todo dia!
Como exercício, vou deixar você encarregado de me mandar um PR para o servidor de desenvolvimento. Assim, podemos executar yarn jest --watch
e ter um ambiente de desenvolvimento com testes de integração de acessibilidade ao mesmo tempo.
O repositório desse exemplo você pode encontrar aqui.
Utilizando essa integração, temos certeza estarmos sempre verificando a acessibilidade básica da nossa aplicação, com um simples script na linha de comando, você faz a validação completa da sua aplicação.
Puppeteer é uma solução e tanto. Unindo esse ambiente com Jest, as possibilidades de testes de integração e regressão são enormes! (Por exemplo, você pode usar a API do Puppeteer e tirar print do simulador web mobile do Chrome em N devices e gravar tudo isso com snapshots).
Você leitor, meu muito obrigado! Se quiser me perguntar algo (dúvidas, projetos, etc), pode me mandar uma mensagem no Twitter ou deixar seu comentário abaixo. 🙏 🎉 🐔