O custo visível de um teste flaky é o tempo gasto re-executando o CI. O custo real é o momento em que o time para de tratar builds vermelhos como algo que vale investigar. Quando algumas falhas viram "provavelmente flaky", bugs reais recebem o mesmo tratamento.

O custo real da flakiness

O custo óbvio é tempo: desenvolvedores re-executando pipelines, testers investigando falhas que não são nada, engenheiros passando uma tarde de sexta bissectando um teste que "simplesmente começou a dar problema". Esse tempo se acumula rápido. Uma estimativa conservadora para um teste persistentemente flaky em um time ativo é de 30 a 60 minutos de investigação por semana.

O custo oculto é pior. Quando as falhas são não confiáveis, cada falha passa a ser suspeita. Bugs reais são descartados. O instinto de agir em um build vermelho (que é exatamente o que o CI foi projetado para criar) se erode. Eventualmente a suite de testes está verde no merge, vermelha na main, e ninguém pisca.

O custo psicológico também é real. Testes flaky fazem a automação parecer não confiável e frágil. Engenheiros júnior no time começam a acreditar que automação é inerentemente instável, o que molda como eles escrevem testes dali pra frente.

A correção começa com uma análise clara das causas raiz, não com --retries.

Race conditions e timing assíncrono: a causa número um

A esmagadora maioria dos testes flaky no Playwright vem de problemas de timing. O teste tenta clicar em um botão antes de ele estar pronto, ou faz uma assertion sobre texto antes que a requisição de rede que o popula tenha terminado. Em uma máquina rápida funciona. Em um runner de CI lento, não.

O instinto é adicionar um sleep:

// A correção errada — ainda flaky, só mais lento
await page.waitForTimeout(3000);
await page.getByRole('button', { name: 'Save' }).click();

Isso torna o teste três segundos mais lento e ainda falha em um dia ruim de CI. Você trocou um problema por dois.

O Playwright faz auto-wait para a maioria das coisas automaticamente. Quando você chama locator.click(), o Playwright espera o elemento estar visível, estável e não obstruído antes de agir. O teste só se torna flaky quando você curto-circuita esse comportamento ou quando está esperando algo que o Playwright não sabe sobre, como uma animação terminando ou um spinner desaparecendo.

A correção certa: espere pela condição específica que precisa ser verdadeira antes de agir.

// Esperar um spinner de carregamento desaparecer antes de interagir
await page.getByTestId('loading-spinner').waitFor({ state: 'hidden' });
await page.getByRole('button', { name: 'Save' }).click();

// Esperar uma resposta de rede que popula a página antes de fazer assertion
await page.waitForResponse(
  (resp) => resp.url().includes('/api/products') && resp.status() === 200
);
await expect(page.getByRole('table')).toBeVisible();

// Esperar um botão ficar habilitado após validação de formulário
const saveButton = page.getByRole('button', { name: 'Save' });
await expect(saveButton).toBeEnabled();
await saveButton.click();

Cada uma dessas esperas aguarda uma condição real em vez de adivinhar uma duração. O Playwright faz polling da condição com um timeout configurável (30 segundos por padrão), então o teste é tanto confiável quanto tão rápido quanto a aplicação permite.

Se você se encontrar escrevendo waitForTimeout mais de uma vez por semana, trate como code smell. Cada instância é um teste que vai ser flaky sob carga. Substitua cada uma por uma espera baseada em condição.

Ordem de testes e estado compartilhado

Testes que passam sozinhos mas falham quando a suite completa roda estão quase sempre deixando estado para trás. Um teste cria um registro, o próximo tropeça nele. Um teste define um cookie, o próximo se comporta diferente por causa dele. Um teste muda as configurações de um usuário, e todos os testes seguintes para aquele usuário estão agora em estado inesperado.

// Este teste deixa um "Test Item" no banco toda vez que roda
test('add item to inventory', async ({ page }) => {
  await page.goto('/inventory');
  await page.getByRole('button', { name: 'Add Item' }).click();
  await page.getByLabel('Name').fill('Test Item');
  await page.getByRole('button', { name: 'Save' }).click();
  await expect(page.getByText('Test Item')).toBeVisible();
  // Nada é limpo
});

// Este teste falha se o anterior rodou primeiro — encontra 2 itens esperando 1
test('inventory shows one item', async ({ page }) => {
  await page.goto('/inventory');
  await expect(page.getByRole('row')).toHaveCount(2); // 1 linha de dados + 1 cabeçalho
});

A correção é isolamento. Cada teste deve configurar seu próprio estado e limpá-lo depois. Fixtures do Playwright são a ferramenta certa para isso: elas rodam o setup antes de cada teste e o teardown depois, mesmo que o teste falhe.

import { test as base } from '@playwright/test';

type TestFixtures = {
  testItem: { id: string; name: string };
};

const test = base.extend<TestFixtures>({
  testItem: async ({ request }, use) => {
    // Cria o item antes do teste
    const response = await request.post('/api/inventory', {
      data: { name: `Test Item ${Date.now()}` },
    });
    const item = await response.json();

    await use(item); // roda o teste

    // Limpa depois, mesmo se o teste falhou
    await request.delete(`/api/inventory/${item.id}`);
  },
});

test('inventory item shows detail page', async ({ page, testItem }) => {
  await page.goto(`/inventory/${testItem.id}`);
  await expect(page.getByRole('heading', { name: testItem.name })).toBeVisible();
});

Fixtures garantem o teardown. Blocos afterEach não rodam se um teste trava durante o setup. Fixtures sim. Essa é a diferença entre isolamento que funciona na maioria das vezes e isolamento que funciona sempre.

Flakiness dependente de ambiente

Alguns testes funcionam perfeitamente no seu MacBook e falham a cada duas execuções no GitHub Actions. A diferença de ambiente é o fator. Culpados comuns:

Fuso horário. new Date() retorna valores diferentes dependendo de onde o teste roda. Um teste que faz assertion sobre uma string de data formatada vai falhar no CI se o runner está em UTC e a máquina local está em UTC-3.

// Flaky — depende do fuso horário local da máquina
const today = new Date().toLocaleDateString('pt-BR');
await expect(page.getByTestId('report-date')).toHaveText(today);

// Estável — fuso horário explicitamente fixado
const today = new Date().toLocaleDateString('pt-BR', { timeZone: 'UTC' });
await expect(page.getByTestId('report-date')).toHaveText(today);

Dados de teste aleatórios. Se você gera IDs ou nomes sem seed, execuções paralelas de testes podem colidir no mesmo valor ou produzir valores que coincidem com registros existentes.

// Arriscado em paralelo — dois workers podem gerar o mesmo ID no mesmo milissegundo
const id = Date.now();

// Melhor — combina timestamp com índice do worker
const id = `${Date.now()}-${workerInfo.workerIndex}`;

Viewport e resolução. Elementos visíveis em 1920px podem estar escondidos atrás de um menu hamburger no viewport padrão do CI. Defina um viewport consistente no playwright.config.ts e não dependa de breakpoints responsivos a menos que os esteja testando explicitamente.

// playwright.config.ts
export default defineConfig({
  use: {
    viewport: { width: 1280, height: 720 },
  },
});

Instabilidade de seletores

Nomes de classes dinâmicos e IDs gerados são uma armadilha. Frameworks como Tailwind e CSS Modules geram nomes de classes que incluem hashes de conteúdo. Apps compilados às vezes geram IDs de elementos baseados na ordem do build. Um seletor que funcionou ontem quebra após uma atualização de dependência.

// Frágil — este nome de classe é gerado e vai mudar
await page.locator('.tw-btn-primary-3af82').click();

// Frágil — ID gerado, sem significado e instável
await page.locator('#ember-423').click();

// Também frágil — nth-child depende da ordem
await page.locator('ul > li:nth-child(3)').click();

Os locators semânticos do Playwright vinculam o seletor ao que o elemento faz em vez de como ele está estilizado ou estruturado. São estáveis a refatorações porque refletem semântica voltada ao usuário, não detalhes de implementação.

// Estável — role + nome acessível
await page.getByRole('button', { name: 'Submit' }).click();

// Estável — texto do label
await page.getByLabel('Email address').fill('user@example.com');

// Estável — test ID (adicione data-testid ao elemento se necessário)
await page.getByTestId('submit-button').click();

// Estável — texto visível
await page.getByText('Order confirmed').waitFor();

Atributos data-testid são um contrato deliberado entre o teste e a aplicação. Sobrevivem a refatorações de CSS, mudanças de layout e upgrades de framework. Se a sua aplicação ainda não os tem, comece adicionando aos elementos interativos de alto valor.

Quando você precisar escrever um seletor CSS ou XPath, escope-o com precisão e ancore-o a um elemento pai estável:

// Escopado a uma seção nomeada, não à página inteira
const orderSummary = page.getByTestId('order-summary');
await expect(orderSummary.getByRole('cell', { name: 'Total' })).toBeVisible();

Testes dependentes de rede

Testes que acessam serviços externos reais são inerentemente flaky. Uma API de terceiros pode ser lenta, ter rate limiting ou estar temporariamente indisponível. Um teste que chama a API live do Stripe no CI não está testando seu código. Está testando se o Stripe está no ar.

O padrão a reconhecer: qualquer teste que faz uma chamada HTTP real para algo fora do seu controle é um teste flaky esperando para acontecer.

Para APIs externas, mock no nível de rede:

test('checkout completes with payment confirmation', async ({ page }) => {
  // Intercepta a chamada da API do Stripe e retorna uma resposta controlada
  await page.route('**/api/stripe/charge', async (route) => {
    await route.fulfill({
      status: 200,
      contentType: 'application/json',
      body: JSON.stringify({
        id: 'ch_test_123',
        status: 'succeeded',
        amount: 4999,
      }),
    });
  });

  await page.goto('/checkout');
  await page.getByLabel('Card number').fill('4242 4242 4242 4242');
  await page.getByRole('button', { name: 'Pay now' }).click();
  await expect(page.getByText('Payment confirmed')).toBeVisible();
});

Para endpoints internos lentos, use page.waitForResponse com um timeout generoso em vez de esperar que a resposta chegue dentro do timeout de ação padrão:

test('large report generates successfully', async ({ page }) => {
  await page.goto('/reports');
  await page.getByRole('button', { name: 'Generate Report' }).click();

  // Espera até 60 segundos por este endpoint especificamente lento
  await page.waitForResponse(
    (resp) => resp.url().includes('/api/reports/generate') && resp.status() === 200,
    { timeout: 60_000 }
  );

  await expect(page.getByRole('link', { name: 'Download Report' })).toBeVisible();
});

Se você está testando contra uma API real que você controla, considere usar um ambiente de teste dedicado que pode ser resetado entre execuções. Um banco de dados de testes seeded para um estado conhecido antes de cada execução elimina uma classe inteira de flakiness.

A armadilha dos retries

O Playwright suporta retries automáticos e eles são genuinamente úteis, mas também são a ferramenta mais mal usada no toolkit de testes flaky.

// playwright.config.ts
export default defineConfig({
  retries: process.env.CI ? 2 : 0,
});

Essa configuração é razoável como última linha de defesa contra flakiness genuína de infraestrutura: um soluço momentâneo de rede no CI, um runner que ocasionalmente falha ao iniciar um navegador. Não é uma correção para testes com problemas reais.

O detalhe sobre retries: um teste que passa na terceira tentativa ainda consome o tempo das duas primeiras falhas. Com retries: 2, uma suite que leva 10 minutos em execuções limpas pode levar 25 a 30 minutos quando vários testes são flaky. Você escondeu as falhas enquanto piorava o pipeline.

Retries são aceitáveis quando:

  • A flakiness é demonstravelmente relacionada à infraestrutura (runners de CI, falhas de inicialização do navegador, timeouts de rede para seus próprios serviços no mesmo datacenter)
  • Você investigou e confirmou que não há problema no código do teste
  • Você está tratando retries como temporários enquanto uma correção mais profunda está em andamento

Retries são prejudiciais quando:

  • São a primeira resposta a um novo teste flaky
  • Estão cobrindo problemas de timing ou isolamento no código do teste
  • A contagem de retries está sendo aumentada ao longo do tempo conforme a suite piora

// Errado: mascarando um problema real de timing
export default defineConfig({
  retries: 5, // Este teste continuava falhando, então adicionamos mais retries
});

// Certo: retries como rede de segurança com limite baixo e fixo
export default defineConfig({
  retries: process.env.CI ? 1 : 0,
  // Problemas reais de timing e isolamento são corrigidos no código do teste
});

Se você está aumentando retries ao longo do tempo em vez de diminuir, o problema de testes flaky está piorando, não melhorando. O contador de retries atual é uma métrica de saúde. Deve tender a zero.

Investigação sistemática: como diagnosticar um teste flaky

Quando um teste começa a falhar intermitentemente, siga esta sequência em vez de adivinhar e ajustar.

Passo 1: Reproduza deterministicamente. Rode o teste 20 vezes seguidas:

npx playwright test tests/checkout.spec.ts --repeat-each=20

Conte as falhas. Um teste que falha 1 em 20 execuções é levemente flaky. Um que falha 15 em 20 tem um problema real. Isso também diz quanto esforço a correção vale. Uma taxa de falha de 5% em uma suite que roda 50 vezes por dia ainda vai te atingir 2 a 3 vezes por dia.

Passo 2: Isole. Rode apenas aquele arquivo de teste. Se passa de forma confiável em isolamento mas falha na suite completa, o problema é poluição de outro teste rodando antes.

# Rodar em isolamento
npx playwright test tests/checkout.spec.ts

# Rodar em ordem da suite para reproduzir poluição
npx playwright test --workers=1

Passo 3: Capture o trace. Habilite tracing para falhas e veja o momento exato em que o teste quebrou:

// playwright.config.ts
export default defineConfig({
  use: {
    trace: 'on-first-retry',
  },
  retries: 1, // retry uma vez para acionar a captura do trace
});

npx playwright test tests/checkout.spec.ts
npx playwright show-report

O trace viewer mostra uma linha do tempo com screenshots antes/depois de cada ação, todas as requisições de rede e logs do console. Na maioria dos casos, o ponto de falha no trace revela imediatamente se o problema é timing, um elemento ausente ou uma resposta de rede inesperada.

Passo 4: Rode com headed e slow motion. Se o trace não for conclusivo, assista o teste rodar:

npx playwright test tests/checkout.spec.ts --headed --slow-mo=500

O slow motion adiciona uma pausa de 500ms entre ações. O que parece instantâneo em uma execução normal fica visível, e você frequentemente vai ver o momento exato em que a UI não está pronta para a próxima interação.

Passo 5: Verifique o que roda antes. Se o teste de isolamento revelou poluição, encontre o teste anterior:

# Roda com um único worker para obter uma ordem determinística, então verifique qual teste rodou antes do que falhou
npx playwright test --workers=1 --reporter=list

Procure testes no arquivo anterior que criam registros, definem cookies ou modificam estado da aplicação sem limpeza.

Passo 6: Aplique a correção certa. Com base no que você encontrou:
  • Problema de timing: substitua waitForTimeout por uma espera baseada em condição
  • Poluição de teste: adicione limpeza no afterEach ou converta setup/teardown em fixtures
  • Instabilidade de seletor: mude para getByRole, getByLabel ou getByTestId
  • Dependência de rede: mock a chamada externa com page.route
  • Diferença de ambiente: fixe fuso horário, viewport e quaisquer valores que variam por máquina

A maioria dos testes flaky cai no passo 2 (isolamento) ou passo 3 (trace viewer). A investigação raramente precisa chegar ao passo 6.

FAQ

Como sei se um teste é flaky ou se realmente encontrou um bug?

Rode 10 vezes no mesmo commit sem mudanças de código. Se falha 1 a 3 vezes em 10, é flaky. Se falha consistentemente (7 ou mais em 10), provavelmente encontrou uma regressão real. A distinção importa porque testes flaky precisam de investigação enquanto falhas consistentes precisam de correção de bug.

Meu teste só falha no CI, nunca localmente. O que é diferente?

Runners de CI são tipicamente mais lentos, headless e em um fuso horário diferente. As causas mais comuns específicas ao CI são problemas de timing. O hardware local mascara race conditions porque a página carrega rápido o suficiente. No CI, diferenças de renderização headless e incompatibilidades de fuso horário em assertions de datas também causam falhas. Rode localmente com --slow-mo=500 para simular uma máquina mais lenta, e verifique qualquer formatação de data para suposições de fuso horário.

Devo usar test.skip ou test.fixme para um teste flaky conhecido? test.skip exclui completamente. test.fixme marca como esperado para falhar: o teste ainda roda e espera-se que falhe. Vira um alerta visível se começar a passar, o que pode significar que o problema subjacente mudou. Para um teste genuinamente flaky sem correção imediata, test.skip com um comentário explicando por quê e linkando para a issue de rastreamento é a melhor escolha. Um test.fixme sem explicação é só confusão esperando para acontecer. Adicionei um data-testid mas o teste ainda é flaky. O que mais verificar?

Um seletor estável não garante um teste estável. Após corrigir o seletor, verifique se o elemento está sendo acessado antes de estar pronto (timing) ou se há estado conflitante de outro teste (isolamento). Confirme também se o teste passa em isolamento mas falha na suite (poluição). Estabilidade de seletor e isolamento de teste são problemas separados.

Temos 40 testes flaky. Por onde começar?

Ordene por taxa de falha, não por quanto incomodam. Corrija primeiro os que falham com mais frequência: estão degradando a confiabilidade do CI mais do que os outros. Conforme você os corrige, padrões vão emergir. Se 15 deles compartilham a mesma causa raiz (digamos, um spinner que precisa desaparecer antes de interações), um único padrão de correção se aplica a todos.

→ Veja também: Depurando Testes Instáveis: Um Guia Prático | Estratégias de Espera no Playwright: Sem sleep() | Isolamento de Testes: Por que Cada Teste Playwright Deve Ser sem Estado