Flaky test não existe
Passei anos atendendo cliente irritado no telefone antes de virar QA. É por isso que eu desconfio de todo teste que o time decide chamar de instável e mandar retentar.
O spec de aprovação de ordem de compra falhava mais ou menos uma vez a cada oito execuções. Sempre na mesma asserção: o total consolidado aparecia como R$ 0,00 em vez do valor dos itens. Rodava local, passava. Rodava no Jenkins, passava. Na terceira, vermelho.
O card que abriram dizia, literalmente: "teste flaky, habilitar retry". Estimativa de 1 ponto. Ninguém perguntou por quê.
Rodei o spec quarenta vezes seguidas num loop de shell. Cinco falhas. Abri o vídeo de uma delas e vi o total piscar zerado antes de preencher — o teste tinha assertado exatamente nesse instante. No painel de rede estava tudo: a tela disparava GET /ordens-compra/:id e GET /ordens-compra/:id/itens em paralelo, e o componente do total somava o array que estivesse em memória no primeiro render. Se os itens chegassem depois, ficava zerado até algum evento forçar re-render. Mexer num filtro resolvia. Não mexer, não.
Traduzindo: qualquer produtor que abrisse uma ordem num momento em que aquela rota estivesse 300 ms mais lenta ia ver o valor da própria ordem zerado. E eu sei o que acontece depois, porque passei uns bons anos do outro lado dessa ligação. O cliente não abre chamado dizendo "acho que temos uma condição de corrida". Ele abre dizendo "o sistema apagou o valor da minha ordem de compra" — e do ponto de vista dele, ele está certo.
Aquele teste não era instável. Aquele teste era a única coisa no time inteiro que estava enxergando o bug.
"Flaky" é o ponto onde a investigação parou
A palavra virou diagnóstico, e não é. É descrição de sintoma que a gente aprendeu a usar como se fosse causa. "O teste é flaky" tem o mesmo valor informativo de "o paciente está com dor": não diz nada sobre o que fazer, mas dá a sensação confortável de que o assunto foi endereçado.
Minha tese é chata e eu defendo ela até o fim: quase todo teste instável está denunciando uma condição que também existe em produção. Muda só a frequência de encontro. O teste roda 40 vezes por dia no CI e tromba com a janela uma vez a cada oito. O usuário roda uma vez por dia e tromba numa terça às 14h, com o banco sob carga do fechamento. Aí não é bug de teste — é ticket P1.
A taxonomia honesta
Condição de corrida entre requisição e render. A que eu contei acima. O teste assertou antes do estado convergir — só que "antes do estado convergir" é uma janela em que a tela mostra informação errada para um ser humano também. Quando a resposta do time é "coloca um wait", a resposta implícita é "o usuário que espere". Ninguém assinaria isso escrito assim.
Estado vazando entre testes. Seed compartilhado, localStorage não limpo, sessão reaproveitada, registro criado no teste A que o teste B encontra sem esperar. Esse é o mais fácil de descartar como "problema só do teste" — e é onde eu discordo mais. Se dois specs disputam o mesmo produtor no mesmo banco e um sobrescreve o dado do outro, você reproduziu em miniatura o cenário de dois usuários da mesma fazenda editando o mesmo cadastro ao mesmo tempo. E no produto, quem ganha? Tem trava otimista? Nas vezes que eu fui olhar, era last write wins silencioso.
Dependência de ordem de execução. Primo do anterior: passa sozinho, morre na suíte. Aqui eu concedo, às vezes é só má higiene de teste. Mas vale confirmar antes se a ordem não está escondendo um cache de aplicação que não invalida direito.
Dado dependente de tempo e fuso. Meu favorito, porque no agro não é detalhe. Safra não é ano civil: 2025/2026 começa e termina numa data de negócio que varia por região e por cultura. Romaneio tem data de emissão que precisa bater com o dia da pesagem, e pesagem acontece de madrugada na safra da soja. Se o front manda new Date().toISOString() e o backend salva em UTC sem cuidado, uma carga às 23h51 em Goiás vira romaneio do dia seguinte. O teste que roda nesse horário fica vermelho uma vez por dia. Está mentindo? Não. Está sendo o único a rodar às 23h51.
Animação e transição CSS. Drawer com 240 ms de ease-out, e o Cypress clica em coordenada enquanto o elemento ainda está em translado. Aqui a chance de ser bug de produto é menor, mas não é zero: já vi botão de confirmar que aceitava clique com o modal ainda animando, e o duplo clique do operador apressado criava dois romaneios.
Dependência de serviço externo real. Consulta de CNPJ, emissão de nota. O teste chama o serviço de verdade, o serviço tem 99,4% de disponibilidade, você tem 0,6% de vermelho garantido. É o único caso da lista em que eu aceito de bom grado o rótulo de "problema de teste" — e mesmo assim, se a aplicação não trata esse timeout com uma mensagem decente, o teste continua tendo razão.
| Sintoma | Causa provável | Como confirmar |
|---|---|---|
| Falha só no CI, passa sempre na sua máquina | Corrida entre requisição e render, exposta por máquina mais lenta | Rodar local com throttle de CPU 4x e latência artificial no cy.intercept |
| Passa sozinho, falha dentro da suíte | Estado vazando: seed, sessão, localStorage, banco compartilhado |
Rodar isolado com --spec, depois rodar precedido só do spec vizinho |
| Falha sempre depois de um spec específico | Dependência de ordem ou cache de aplicação que não invalida | Inverter a ordem dos specs e ver se a falha muda de lugar |
| Falha em horário específico, ou perto da virada do dia | Data, fuso ou regra de safra calculada no cliente | Congelar o relógio com cy.clock; rodar o container em TZ=UTC e depois em TZ=America/Sao_Paulo |
| "element is being covered" ou detached from DOM | Animação, transição ou re-render no meio da ação | Desligar animação por classe no ambiente de teste e ver se a taxa cai a zero |
| Vermelho em rajada, vários specs de uma vez | Serviço externo real ou ambiente compartilhado instável | Conferir se o intercept cobre o domínio; olhar o status code da chamada que vazou |
O pecado original: cy.wait(5000)
Confissão antes de continuar: o cy.wait(2000) mais antigo da nossa suíte fui eu que escrevi. Passei meses achando que era pragmatismo — "tá passando, é o que importa". Não era. Era eu adiando entender como a ferramenta funciona.
O ponto técnico é que o Cypress já espera. Ele não executa comando por comando na hora em que você escreve: enfileira tudo e roda a fila de forma assíncrona, reexecutando os comandos de consulta até a asserção seguinte passar ou o defaultCommandTimeout estourar. Quando você escreve cy.wait(5000), não está somando segurança a isso — está trocando uma espera condicional, que termina no instante em que a condição vira verdadeira, por uma espera cega, que sempre custa cinco segundos e ainda pode ser curta demais no dia em que o runner estiver com metade da CPU.
Pior dos dois mundos. Lento quando o app está rápido, insuficiente quando está lento.
E existe sim o caso em que a retry-ability nativa não resolve: quando você precisa saber que uma requisição específica terminou, não que um elemento apareceu. Aí o wait fixo continua errado, por outro motivo — você está esperando o relógio quando deveria esperar o evento.
// A versão que o time chamava de flaky.
describe('Ordem de compra de insumos', () => {
it('exibe o total consolidado da ordem', () => {
cy.visit('/ordens-compra/8412');
cy.wait(5000); // "dá tempo da API responder"
// Seletor amarrado na estrutura da tabela do design system.
// Trocaram a ordem das colunas em maio e 11 specs morreram juntos.
cy.get('.MuiTable-root tbody tr:nth-child(3) td:nth-child(2)')
.should('contain', 'R$ 12.480,00');
});
});
cypress/e2e/ordem-compra.cy.ts — depois
describe('Ordem de compra de insumos', () => {
beforeEach(() => {
cy.intercept('GET', '/api/ordens-compra/*').as('ordem');
cy.intercept('GET', '/api/ordens-compra/*/itens').as('itensOrdem');
});
it('consolida o total depois que os itens chegam', () => {
cy.visit('/ordens-compra/8412');
// Espera o evento, não o relógio. Termina em 180ms ou em 4s,
// conforme o dia — e falha explicando qual chamada não veio.
cy.wait(['@ordem', '@itensOrdem']).spread((ordem, itens) => {
expect(ordem.response?.statusCode).to.eq(200);
expect(itens.response?.body).to.have.length.greaterThan(0);
});
cy.get('[data-cy="ordem-total"]').should('have.text', 'R$ 12.480,00');
});
it('nunca mostra total zerado enquanto os itens não chegaram', () => {
// O teste de regressão do bug real: força a corrida em vez de torcer.
cy.intercept('GET', '/api/ordens-compra/*/itens', (req) => {
req.on('response', (res) => res.setDelay(1500));
}).as('itensLentos');
cy.visit('/ordens-compra/8412');
cy.get('[data-cy="ordem-total"]')
.should('have.attr', 'data-status', 'carregando')
.and('not.contain', 'R$ 0,00');
cy.wait('@itensLentos');
cy.get('[data-cy="ordem-total"]').should('have.text', 'R$ 12.480,00');
});
});
data-cy
Seletor de teste precisa ser contrato explícito entre quem escreve a tela e quem escreve o teste: classe de CSS e posição de coluna são detalhes de implementação que mudam sem aviso e derrubam a suíte por um motivo que não tem nada a ver com comportamento.
Como a fila do Cypress realmente funciona
Essa é a parte que quase ninguém no meu time sabia direito, inclusive eu por muito tempo. Vale sentar cinco minutos com ela.
Quando você escreve cy.get(...).should(...), nada executa naquele momento. O Cypress enfileira e depois roda a fila, separando os comandos em duas categorias que se comportam de maneira oposta.
Comandos de consulta — get, find, contains, eq, first, filter, its, invoke — são funções puras que o Cypress pode reexecutar à vontade. Da versão 12 em diante, uma cadeia inteira de consultas é reexecutada como bloco até a asserção seguinte passar. Isso é a retry-ability: cy.get('[data-cy="ordem-total"]').should('have.text', 'R$ 12.480,00') consulta, verifica, não bateu, consulta de novo, por 4 segundos.
Comandos de ação e .then() — click, type, select, request — executam uma vez só. Ação não é reexecutável por definição: você não digita o mesmo texto quatro vezes "só pra ver se pega".
Daí sai a consequência que derruba mais gente do que qualquer outra:
cy.get('[data-cy="romaneio-peso"]').then(($el) => expect($el.text()).to.eq('42.780')) não retenta. O .then() é ação: roda uma vez, com o DOM daquele instante. Já cy.get('[data-cy="romaneio-peso"]').should('have.text', '42.780') retenta. Mesma verificação, dois jeitos de escrever, garantias opostas. Quando revisei nossa suíte procurando expect( dentro de .then(, achei 34 ocorrências. Boa parte dos nossos "flaky" estava ali, não no Cypress.
Duas notas que valem ouro. .and() é literalmente um alias de .should() encadeado — muda a leitura, não a semântica, e os dois participam do ciclo de retentativa. E quando você passa um callback para o .should(), ele vai ser chamado várias vezes, então precisa ser livre de efeito colateral. Incrementa um contador lá dentro e você perde a tarde.
.should('not.exist') passa instantaneamente se o elemento ainda não foi renderizado. Pra afirmar que o alerta de estoque insuficiente não aparece, espere antes algo que prove que a tela terminou de reagir — o alias da requisição, o estado final do botão. Senão você escreveu um teste que passa por ter chegado adiantado.
Congelar o relógio antes que ele te congele
O teste de emissão de romaneio ficou vermelho três dias seguidos, sempre por volta das 21h, e a suspeita natural foi "ambiente instável de noite". Não era. Era o front calculando a safra a partir do relógio do navegador, o backend calculando a partir do relógio do servidor em UTC, e as duas contas divergindo numa janela de três horas por dia.
cypress/e2e/romaneio-emissao.cy.tsdescribe('Emissão de romaneio na virada do dia', () => {
// 30/06/2026, 23h51, horário de Brasília. Nove minutos antes
// da virada — e da virada da safra 2025/2026 para 2026/2027.
const ANTES_DA_VIRADA = new Date('2026-06-30T23:51:00-03:00').getTime();
beforeEach(() => {
// Só o Date é substituído; timers do app continuam vivos.
cy.clock(ANTES_DA_VIRADA, ['Date']);
cy.intercept('POST', '/api/romaneios').as('criarRomaneio');
cy.seedOrdemCompra({ produtorId: 3391, safra: '2025/2026' });
});
it('emite com a data da pesagem, não a do servidor', () => {
cy.visit('/romaneios/novo');
cy.get('[data-cy="romaneio-talhao"]').select('T-14 · Gleba Norte');
cy.get('[data-cy="romaneio-peso-bruto"]').type('42780');
cy.get('[data-cy="romaneio-salvar"]').click();
cy.wait('@criarRomaneio').its('request.body').should((body) => {
// Antes: virava 2026-07-01 porque o front mandava ISO em UTC.
expect(body.dataEmissao).to.eq('2026-06-30');
expect(body.safra).to.eq('2025/2026');
});
cy.get('[data-cy="romaneio-data-emissao"]')
.should('have.text', '30/06/2026');
});
it('atravessa a meia-noite sem trocar a safra do romaneio aberto', () => {
cy.visit('/romaneios/novo');
cy.get('[data-cy="romaneio-safra"]').should('have.text', '2025/2026');
cy.tick(9 * 60 * 1000); // 23h51 -> 00h00 de 01/07
// Formulário já aberto não pode migrar de safra debaixo do operador.
cy.get('[data-cy="romaneio-safra"]').should('have.text', '2025/2026');
});
});
Repare no que esse teste virou: deixou de ser checagem genérica de cadastro e passou a ser a especificação executável de uma regra que ninguém tinha escrito em lugar nenhum — a safra de um romaneio é a do momento em que a pesagem começou. Isso não sai de retry. Sai de olhar.
E o seedOrdemCompra no beforeEach é outro hábito que baixou nossa instabilidade sem eu esperar: montar cenário por API em vez de atravessar 14 telas. Cada tela no caminho é mais uma chance de falhar por algo que não é o objeto do teste.
interface SeedOrdemParams {
produtorId: number;
safra: string;
itens?: Array<{ insumo: string; quantidade: number }>;
}
declare global {
namespace Cypress {
interface Chainable {
seedOrdemCompra(params: SeedOrdemParams): Chainable<number>;
}
}
}
Cypress.Commands.add('seedOrdemCompra', (params: SeedOrdemParams) => {
return cy.request({
method: 'POST',
url: `${Cypress.env('apiUrl')}/test-fixtures/ordens-compra`,
headers: { 'x-fixture-token': Cypress.env('fixtureToken') },
body: params,
}).then((res) => {
// cy.request é ação: roda uma vez e já vem resolvido.
// Aqui o .then() é legítimo — não há DOM convergindo.
expect(res.status).to.eq(201);
return res.body.id as number;
});
});
"Mas retry automático no CI é padrão da indústria"
É, e o argumento é bom o suficiente pra merecer resposta de verdade, não uma versão de palha. O próprio Cypress entrega retries na configuração, times enormes usam, e a justificativa é sólida: nenhum ambiente de execução é perfeitamente determinístico, e travar o deploy porque um container ficou sem memória é desperdício.
Concordo com a premissa. Discordo do uso.
Retry é defesa legítima contra infraestrutura: runner que morreu, ECONNRESET no registry, disco cheio, container despejado. É ilegítimo como defesa contra código — e a diferença entre os dois é observável, desde que alguém se dê ao trabalho de observar.
O problema não é a flag existir. É ela ser silenciosa. Um build que passou na segunda tentativa reporta verde igualzinho a um que passou de primeira, e a informação mais valiosa daquele dia foi jogada no lixo. Ligar retry sem instrumentar é tirar a bateria do detector de fumaça porque ele apita quando você frita alguma coisa — funciona, até o dia em que não era fritura.
O que a gente combinou, e funciona:
- Toda retentativa é registrada. Spec, título, tentativa em que passou, erro da tentativa que falhou — no mesmo lugar onde ficam as métricas de build.
- Taxa de instabilidade é métrica de release. Percentual de testes que precisaram de mais de uma tentativa nos últimos 30 builds, revisado junto com cobertura e tempo de pipeline.
- Existe orçamento. Acima de 2% dos specs precisando de retry, o build passa mas abre issue com os três piores ofensores, e eles entram na sprint. Gate que trava a gente aprende a burlar; dívida visível, não.
- Retry não vale pra spec novo. Teste escrito nesta sprint que já precisa de segunda tentativa é teste mal escrito ou funcionalidade mal implementada. Volta.
Provando que o teste é instável
Antes de discutir causa, prove a taxa. "Às vezes falha" não é dado. Roda 30 vezes num loop e anota — dá dez minutos e muda a conversa inteira, porque 3 falhas em 30 é uma coisa e 1 em 30 é outra.
Com o número na mão, o resto é bissecção. Roda isolado com --spec: se a taxa cai a zero, a causa está fora do spec, é estado ou ordem — aí roda precedido só do vizinho e vai voltando até achar o culpado. Inverte a ordem da suíte e vê se a falha muda de endereço. Congela o relógio com cy.clock: se estabilizou, você tem dado dependente de tempo e provavelmente um bug de fuso esperando o cliente. Limita a CPU do container ou põe setDelay nas rotas pesadas — se a taxa sobe, é corrida, e você ganhou um reprodutor confiável do bug de produção.
Em quase todo caso que investiguei assim, o final foi o mesmo: apareceu uma linha de código de produção assumindo uma ordem de eventos que ninguém garantia.
Um teste que falha uma vez a cada oito execuções te contou, com hora marcada e vídeo gravado, que existe uma janela em que seu produto mostra informação errada. De graça, antes do cliente.
Chamar isso de flaky e ligar o retry não é engenharia. É escolher descobrir depois — pelo telefone, com alguém gritando do outro lado.