680 Execuções, Zero Retentativas: Como Realmente Provar que um Teste Flaky Foi Corrigido

Erik Treviño avatar
Erik Treviño
Cover for 680 Execuções, Zero Retentativas: Como Realmente Provar que um Teste Flaky Foi Corrigido

“O teste flaky foi corrigido — ele passou.”

Não. Ele passou uma vez. Isso não é correção.

Se a taxa de falha original era 0,5%, uma única execução bem-sucedida te dá 99,5% de confiança. Parece alto até o seu CI rodar 200 vezes por semana e você ver falhas “aleatórias” toda segunda-feira de manhã. Uma execução bem-sucedida não é evidência. É uma moeda jogada para o alto que você teve sorte de acertar.

Eu executo cada correção de teste flaky 680 vezes consecutivas antes de considerar resolvido. Zero retentativas. Zero allowedFlakes. Se falhar uma vez em 680 execuções, a correção não é real.

A Matemática Que Muda Sua Perspectiva

Esse é um problema de probabilidade binomial. Se um teste tem uma taxa real de falha p, a probabilidade de ele passar n execuções consecutivas é (1 − p)^n. A probabilidade de ver pelo menos uma falha é 1 − (1 − p)^n.

Para um teste com taxa de falha de 0,5%:

Passes ConsecutivosP(todos passam)P(pelo menos 1 falha)Confiança de que o bug ainda existe
199,50%0,50%Quase nenhuma — você não aprendeu nada
1095,11%4,89%Baixa
5077,83%22,17%Moderada
10060,65%39,35%Começando a chegar em algum lugar
20036,77%63,23%Cara ou coroa
46010,00%90,00%90% de confiança de que a correção é real
6803,31%96,69%~97% de confiança com taxa de falha de 0,5%
10000,67%99,33%99%+ de confiança

Existe também um atalho útil chamado Regra de Três: se você observa zero falhas em n tentativas, o limite superior da taxa de falha com 95% de confiança é aproximadamente 3/n. Para n = 680, isso é 3/680 = 0,44%. Então, 680 passes com zero falhas significa que você pode ter 95% de confiança de que a taxa real de falha está abaixo de 0,44%.

Por que 680 especificamente? É um número prático: 8 workers paralelos x 85 iterações cada. Cabe em uma única execução de stress, completa em tempo razoável e empurra o limite superior das taxas de falha prováveis para abaixo do limiar em que um pipeline de CI típico veria falhas semanais.

O ponto não é que 680 seja um número mágico. O ponto é que um não é suficiente, dez não é suficiente, e você precisa fazer as contas para a sua situação específica.

Por Que a Maioria das “Correções” Falha

Após meses executando esse protocolo, três padrões emergiram nas correções que falharam — testes que passaram uma vez ou até cinquenta vezes e depois falharam novamente.

Padrão 1: O Timeout Estendido

A não-correção mais comum. Um teste falha porque um elemento leva 6 segundos para aparecer. A “correção” muda o timeout de 5 segundos para 15 segundos. Passa hoje. Amanhã, sob carga de CI com workers de teste paralelos competindo por recursos, aquele elemento leva 18 segundos.

// ❌ The non-fix: extending the timeout
await page.getByRole('button', { name: 'Submit' }).click();
await expect(page.getByText('Success')).toBeVisible({ timeout: 15000 });

// ✅ The real fix: wait for the actual condition, not an arbitrary timer
await page.getByRole('button', { name: 'Submit' }).click();
await page.waitForResponse(resp =>
  resp.url().includes('/api/submit') && resp.status() === 200
);
await expect(page.getByText('Success')).toBeVisible();

Um timeout não é uma correção — é uma aposta de que o sistema sempre será pelo menos tão rápido. Essa aposta perde eventualmente.

Padrão 2: O Vazamento de Estado

O Teste B passa quando executado sozinho, falha quando executado após o Teste A. O Teste A deixa para trás um cookie, uma entrada no local storage ou uma linha no banco de dados que muda as condições iniciais do Teste B. A “correção” adiciona um cleanup no beforeEach. Mas o cleanup em si é frágil — ele limpa o estado conhecido mas perde o artefato que só é criado sob condições específicas do Teste A.

// ❌ Fragile cleanup: clearing known state
test.beforeEach(async ({ page }) => {
  await page.evaluate(() => localStorage.clear());
});

// ✅ Robust isolation: fresh context per test
// In playwright.config.ts — each test gets a pristine browser context
const config: PlaywrightTestConfig = {
  use: {
    // Every test starts with zero cookies, zero storage, zero history
    storageState: undefined,
    contextOptions: {
      ignoreHTTPSErrors: true,
    },
  },
  // Fully parallel — no shared state between workers
  fullyParallel: true,
};

Padrão 3: A Condição de Corrida

Um clique dispara. Uma requisição de rede começa. O teste faz a asserção sobre o resultado. Mas a asserção executa antes da resposta chegar. Funciona na sua máquina local rápida. Falha 0,5% das vezes no CI, onde a contenção de recursos adiciona 200ms de latência a cada salto de rede.

// ❌ Race condition: assert before the data arrives
await page.getByRole('button', { name: 'Load Data' }).click();
await expect(page.getByTestId('results-count')).toHaveText('42');

// ✅ Wait for the network response, then assert on the rendered result
await page.getByRole('button', { name: 'Load Data' }).click();
await page.waitForResponse(resp =>
  resp.url().includes('/api/data') && resp.status() === 200
);
await expect(page.getByTestId('results-count')).toHaveText('42');

Pare de Dizer “Flaky” — Comece a Classificar

A palavra “flaky” é um beco sem saída diagnóstico. Ela interrompe a investigação. É o equivalente em testes de um médico dizer “você está doente.” Tecnicamente verdade. Completamente inútil.

Toda falha de teste tem uma causa raiz. Classifique-a:

Classe da Causa RaizSintomaCategoria da Correção
Defeito de timingPassa localmente, falha no CI sob cargaEspere por condições, não por timers
Vazamento de estadoFalha somente quando executado após testes específicosIsolamento — contexto novo por teste
Condição de corridaFalha intermitentemente em asserções rápidasEspere pela rede/estado, depois faça a asserção
Contenção de recursosFalha em paralelo, passa em serialIsolamento de workers ou locks de recursos
Divergência de ambienteFalha em staging mas não em devFixtures com consciência de ambiente

Uma vez que você nomeia a classe, você pode buscá-la. Isso é hardening preditivo.

Hardening Preditivo: Corrija Testes Antes que Eles Falhem

Essa é a mudança metodológica que importa mais do que o próprio protocolo de 680 execuções.

Depois de corrigir 3 testes flaky conhecidos que compartilhavam a mesma causa raiz — condições de corrida entre handlers de clique e respostas de rede — eu busquei em toda a suíte pelo mesmo padrão. Grep por declarações de asserção que seguem imediatamente ações de clique sem um waitForResponse ou waitForLoadState intermediário.

# Find potential race conditions: assertions immediately after clicks
# with no waitForResponse in between
grep -n "\.click()" tests/**/*.spec.ts | while read line; do
  file=$(echo "$line" | cut -d: -f1)
  linenum=$(echo "$line" | cut -d: -f2)
  # Check if the next 3 lines contain a waitForResponse
  nextlines=$(sed -n "$((linenum+1)),$((linenum+3))p" "$file")
  if echo "$nextlines" | grep -q "expect\|toHave\|toBe" && \
     ! echo "$nextlines" | grep -q "waitFor"; then
    echo "POTENTIAL RACE: $file:$linenum"
  fi
done

Encontrei mais 3 testes com o mesmo padrão de vulnerabilidade — e os fortaleci antes que jamais falhassem no CI.

Essa é a diferença entre testes reativos (esperar quebrar, depois corrigir) e hardening preditivo (classificar o padrão de falha, depois varrer a suíte).

O Padrão FAST / STANDARD / EXTENDED

Substitua magic numbers ad-hoc por constantes de polling padronizadas. Cada chamada waitFor na suíte referencia uma constante nomeada, não um chute:

// test-constants.ts
export const TIMEOUTS = {
  FAST: 2_000,       // Elements that should appear immediately after navigation
  STANDARD: 10_000,  // API responses and re-renders
  EXTENDED: 30_000,  // Complex operations, streaming, file uploads
  STRESS: 60_000,    // Only used in stress test configurations
} as const;

// In tests:
await expect(page.getByRole('heading')).toBeVisible({
  timeout: TIMEOUTS.FAST
});

await expect(page.getByTestId('search-results')).toHaveCount(10, {
  timeout: TIMEOUTS.STANDARD
});

Quando um teste precisa de EXTENDED, isso é um sinal. Significa que ou a operação é genuinamente lenta (aceitável) ou algo na arquitetura está bloqueando (investigue). Constantes nomeadas tornam esses sinais visíveis no code review.

O Protocolo de Teste de Stress

Aqui está a configuração exata que eu uso para validação de stress:

// playwright.stress.config.ts
import { defineConfig } from '@playwright/test';

export default defineConfig({
  workers: 8,
  repeatEach: 85,     // 8 × 85 = 680 total runs
  retries: 0,         // Zero tolerance — a single failure invalidates the fix
  timeout: 30_000,
  use: {
    trace: 'on-first-retry',  // Won't fire with 0 retries — that's the point
  },
  reporter: [
    ['list'],
    ['json', { outputFile: 'stress-results.json' }],
  ],
});
# Run stress validation against a specific test file
npx playwright test tests/checkout-flow.spec.ts --config=playwright.stress.config.ts

# Verify: all 680 must pass
cat stress-results.json | jq '.stats | {total: .expected, passed: .expected, failed: .unexpected}'

Se todas as 680 passarem: a correção é real. Faça o deploy.

Se alguma falhar: não olhe apenas para a contagem de falhas. Olhe para quais workers falharam e quais iterações dentro desses workers. Falhas concentradas em iterações posteriores sugerem esgotamento de recursos (vazamento de memória, esgotamento do pool de conexões). Falhas espalhadas aleatoriamente sugerem que a correção está incompleta. Falhas apenas em workers específicos sugerem um problema de isolamento.

A Lição Mais Profunda

Testes flaky não são uma categoria de incômodo. Eles são uma oportunidade diagnóstica. Todo teste “flaky” está te dizendo algo específico sobre o comportamento do seu sistema sob condições para as quais você não projetou. O teste que falha intermitentemente no CI mas passa localmente está reportando informação real: sua aplicação se comporta de maneira diferente sob contenção de recursos. Não é o teste sendo não confiável. É o teste sendo mais honesto do que o seu ambiente local.

A mudança de “corrigir esse teste flaky” para “classificar e eliminar esse padrão de falha em toda a suíte” é a mudança mais impactante que eu fiz na minha metodologia de testes. Ela transforma um jogo reativo de whack-a-mole em uma redução sistemática da superfície de falha.

Pare de chamar seus testes de flaky. Comece a classificar suas falhas. E pare de chamar uma correção de pronta porque ela passou uma vez.

680 execuções. Zero retentativas. Confiança estatística, não esperança.