Teste flaky: por que rerodar o CI piora tudo e o que fazer no lugar
Como medir a instabilidade de um teste, tirá-lo da trava do merge sem esquecê-lo e achar a causa real: tempo, ordem, recurso compartilhado ou espera mal feita.
Terça, 16h. O pipeline da loja fica vermelho no teste checkout aplica cupom. Alguém clica em "Re-run", fica verde, o merge entra. Na quinta, o mesmo teste falha por um bug real no cupom, alguém clica em "Re-run" de novo, passa por sorte, e o bug vai para produção. Rerodar não conserta o teste flaky: ensina o time a não acreditar no vermelho.
A resposta curta: meça quantas vezes o teste falha, tire-o da trava do merge com dono e prazo, investigue a causa (quase sempre tempo, ordem, recurso compartilhado ou dependência externa) e só devolva o teste à fila obrigatória quando ele passar dezenas de vezes seguidas. Enquanto isso, nada de aumentar o sleep até "passar na maioria".
Por que um teste passa e falha sem o código mudar
Um teste é determinístico só quando tudo de que ele depende também é. Na prática, ele depende de coisas que o código do teste não controla:
- Tempo: relógio real, fuso, virada do dia, timeouts curtos,
sleepfixo esperando algo assíncrono terminar. - Ordem: um teste deixa dado no banco ou numa variável global, e outro só passa se rodar depois (ou antes) dele.
- Recurso compartilhado: mesma porta, mesmo arquivo temporário, mesma tabela usada por testes em paralelo.
- Concorrência: uma promessa sem
await, um evento que às vezes chega antes da asserção. - Mundo externo: API de terceiro, DNS, fila real, máquina de CI mais lenta naquele horário.
O problema cresce com o tamanho da suíte. Se você tem 20 testes que falham sozinhos 2% das vezes cada, a chance de todos passarem numa rodada é 0,98 elevado a 20, cerca de 67%. Um terço das execuções fica vermelho sem nenhum bug. Nesse ponto, ninguém lê mais o erro, e o vermelho de verdade passa junto.
O próprio time de testes do Google, no texto que inspirou este guia, relatou que uma parcela grande dos testes da empresa tinha algum grau de instabilidade e que isso custava tempo de engenharia e confiança no CI. Não é problema de time pequeno ou desorganizado. É o estado natural de uma suíte que ninguém vigia.
Como tratar um teste flaky, passo a passo
- Confirme que é instabilidade, não bug. Rode o teste sozinho muitas vezes no mesmo commit. Se falha às vezes sem mudança de código, é flaky.
- Meça a taxa. "Falha de vez em quando" não ajuda ninguém. "Falhou 4 em 50" ajuda a priorizar e a saber quando o conserto funcionou.
- Abra um chamado com dono e data. Inclua o log da falha, a taxa medida e o commit. Sem dono, a quarentena vira cemitério.
- Tire da trava do merge, não da suíte. O teste continua rodando num job que não bloqueia, para você ver se ele melhora ou piora.
- Ache a causa pela categoria. Use a lista acima como roteiro: tempo, ordem, recurso, concorrência, mundo externo.
- Conserte e prove. Rode de novo as 50 vezes. Só então devolva o teste ao job obrigatório.
Medindo a taxa de falha
No exemplo da loja, o teste mora em tests/checkout.test.js e a suíte usa Jest. Um laço simples em bash já mostra a taxa:
falhas=0
for i in $(seq 1 50); do
if ! npx jest tests/checkout.test.js --silent > /dev/null 2>&1; then
falhas=$((falhas + 1))
echo "falhou na rodada $i"
fi
done
echo "total: $falhas falhas em 50"
Se sozinho ele passa 50 de 50, mas falha na suíte completa, a suspeita muda: é ordem ou recurso compartilhado. Rode a suíte inteira em ordem aleatória (o Jest tem a opção --randomize nas versões recentes; no pytest, o plugin pytest-randomly faz isso) e anote a semente que reproduz a falha.
Quarentena sem esquecer o teste
Mova o teste para uma pasta de quarentena e crie um job separado que roda essa pasta sem bloquear o merge. No GitHub Actions, fica assim:
jobs:
testes:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npx jest --testPathIgnorePatterns=tests/quarentena
quarentena:
runs-on: ubuntu-latest
continue-on-error: true
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npx jest tests/quarentena
Na proteção de branch, marque só o job testes como obrigatório. O job quarentena continua visível, e a falha dele aparece no histórico. Escreva no topo do arquivo em quarentena o link do chamado e a data combinada para voltar.
Consertando a causa mais comum: a espera fixa
No caso da loja, o culpado era este trecho:
test('pedido muda para pago', async () => {
await api.post('/pedidos/42/pagar');
await new Promise((r) => setTimeout(r, 500));
const pedido = await api.get('/pedidos/42');
expect(pedido.status).toBe('pago');
});
O pagamento é processado por um worker. Na máquina da pessoa, 500 ms sobram. No runner de CI compartilhado, às vezes não bastam. Aumentar para 2000 ms deixa a suíte mais lenta e só empurra o problema. O certo é esperar pela condição, com prazo máximo:
async function esperarAte(condicao, { timeoutMs = 5000, intervaloMs = 50 } = {}) {
const limite = Date.now() + timeoutMs;
while (Date.now() < limite) {
if (await condicao()) return;
await new Promise((r) => setTimeout(r, intervaloMs));
}
throw new Error('condição não atingida em ' + timeoutMs + ' ms');
}
test('pedido muda para pago', async () => {
await api.post('/pedidos/42/pagar');
await esperarAte(async () => {
const pedido = await api.get('/pedidos/42');
return pedido.status === 'pago';
});
});
No caminho feliz, o teste termina assim que o status muda, quase sempre bem antes dos 500 ms antigos. No caminho ruim, ele falha com uma mensagem clara em vez de uma asserção confusa. Depois do conserto, o laço de 50 rodadas deu 50 de 50, e o teste voltou ao job obrigatório.
Para as outras causas, há guias específicos: teste que depende do relógio real, teste que só passa sozinho e mock que não parece o serviço real.
Uma política de time, não um esforço heroico
O conserto técnico resolve um teste. A política impede que o próximo apodreça. Combine três regras simples e escreva no repositório:
- Quem viu o teste falhar sem motivo abre o chamado, mesmo que não vá consertar.
- Rerun automático, se existir, registra cada falha. Teste que só passa na segunda tentativa é contado como instável, não como verde.
- A pasta de quarentena tem limite. Se passar de, digamos, cinco testes, o time para e conserta antes de criar funcionalidade nova.
O objetivo é que vermelho volte a significar "tem problema aqui". Um teste em que o time não acredita é pior que teste nenhum, porque gasta tempo e ainda treina as pessoas a ignorar o alarme.
Erros comuns
- Aumentar o sleep até passar. A suíte fica mais lenta a cada ajuste e o teste volta a falhar no dia em que o runner estiver mais carregado.
- Apagar o teste "porque é instável". A funcionalidade fica sem cobertura e o bug que o teste às vezes pegava passa a chegar em produção.
- Quarentena sem dono nem data. Vira uma pasta de testes que ninguém olha, e a cobertura real cai em silêncio.
- Rerun automático que esconde a falha. O painel mostra 100% verde enquanto a taxa de instabilidade cresce sem ninguém medir.
- Investigar só na máquina local. O teste passa 200 vezes no notebook rápido e continua falhando no runner, que tem menos CPU e roda jobs em paralelo.
- Culpar a infraestrutura de cara. Às vezes é a rede do CI, mas na maioria dos casos o teste depende de algo que deveria estar controlado.
Checklist
- O teste instável tem chamado aberto com log, taxa medida e dono.
- Existe um job de quarentena que roda e não bloqueia o merge.
- Só os jobs confiáveis estão marcados como obrigatórios na proteção de branch.
- Nenhum teste espera com
sleepfixo por algo assíncrono. - A suíte já rodou em ordem aleatória pelo menos uma vez.
- Testes paralelos não compartilham porta, arquivo nem tabela sem isolamento.
- O conserto foi provado com dezenas de rodadas seguidas antes de sair da quarentena.
Abra o histórico do seu CI desta semana: quantas vezes alguém clicou em rodar de novo em vez de ler o erro, e qual teste aparece em todas elas?
Referência: https://testing.googleblog.com/2016/05/flaky-tests-at-google-and-how-we.html