Aquele teste E2E 'instável' encontrou um bug real de UX

Erik Treviño avatar
Erik Treviño
Cover for Aquele teste E2E 'instável' encontrou um bug real de UX

O teste era simples. Clicar em um botão. Esperar um diálogo fechar. Verificar o resultado.

Passava 95% das vezes. Nos outros 5%, o diálogo levava mais de 30 segundos para fechar. Só na CI. Localmente funcionava perfeitamente. O time já tinha rotulado: instável.

A maioria dos times teria adicionado um timeout mais longo, configurado retries: 2, e seguido em frente. “A CI é lenta, os testes são instáveis, manda pra produção.” Já vi essa resposta tantas vezes que ela tem memória muscular própria. Mas “instável” não é um diagnóstico. É a ausência de um. Então eu investiguei a fundo.

O que encontrei não era um problema de teste. Era um bug real de UX que afeta cada usuário em produção.

O teste

Veja como era o teste — Playwright direto interagindo com um diálogo de mutação:

test('user can update record status', async ({ page }) => {
  await page.goto('/records');

  // Open the status update dialog
  await page.getByRole('row', { name: /Record-1042/ })
    .getByRole('button', { name: 'Update Status' })
    .click();

  // Select new status and confirm
  await page.getByRole('combobox', { name: 'Status' }).selectOption('approved');
  await page.getByRole('button', { name: 'Confirm' }).click();

  // Wait for dialog to close, then verify the result
  await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 });
  await expect(
    page.getByRole('row', { name: /Record-1042/ }).getByText('Approved')
  ).toBeVisible();
});

A falha sempre acontecia na mesma linha: await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 }). O diálogo ficava aberto por mais de 30 segundos. O timeout de 10 segundos expirava. Teste falhava.

A investigação

O passo 1 foi verificar que a falha era real e não um artefato da infraestrutura de CI. Executei o teste 100 vezes localmente com --repeat-each=100. Passou todas as vezes. Executei com CPU limitado (desaceleração de 6x via emulação do Playwright) para simular as restrições de recursos da CI. Falhou 3 vezes em 100. A falha dependia do ambiente — a contenção de recursos a fazia se manifestar.

O passo 2 foi entender o que o handler de fechamento do diálogo realmente fazia. Abri o código-fonte do frontend e rastreei o fluxo da mutação.

A causa raiz: o ciclo de vida da mutação

O diálogo usava TanStack Query (React Query) para sua mutação. Quando o usuário clica em “Confirmar”, a mutação é disparada. Em caso de sucesso, o handler onSuccess do diálogo é executado. É aqui que mora o problema:

// The mutation hook — simplified from the actual codebase
const updateStatus = useMutation({
  mutationFn: (data: StatusUpdate) =>
    api.patch(`/records/${data.id}/status`, { status: data.status }),

  onSuccess: async () => {
    // This is the problem: awaiting all cache invalidations
    // before closing the dialog
    await Promise.all([
      queryClient.invalidateQueries({ queryKey: ['records'] }),
      queryClient.invalidateQueries({ queryKey: ['record-detail'] }),
      queryClient.invalidateQueries({ queryKey: ['record-history'] }),
      queryClient.invalidateQueries({ queryKey: ['dashboard-stats'] }),
      queryClient.invalidateQueries({ queryKey: ['team-metrics'] }),
      queryClient.invalidateQueries({ queryKey: ['audit-log'] }),
      queryClient.invalidateQueries({ queryKey: ['notifications'] }),
      queryClient.invalidateQueries({ queryKey: ['pending-reviews'] }),
    ]);

    // Dialog only closes AFTER all 8 invalidations resolve
    onClose();
  },
});

Ali está. O handler onSuccess chama Promise.all() em 8 invalidações de cache de consultas. No TanStack Query, invalidateQueries não apenas marca o cache como obsoleto — ele dispara um refetch. Cada invalidação faz uma requisição de rede para recarregar aqueles dados.

A chamada onClose() — a que fecha o diálogo — fica depois do Promise.all(). O diálogo não fecha até que todos os 8 refetches sejam completados.

Em uma máquina local rápida com baixa latência para o servidor API, essas 8 requisições completam em menos de um segundo. Na CI, onde o runner de testes compartilha recursos com outros workers paralelos e o servidor API roda em um container com CPU limitado, essas 8 requisições levam de 10 a 30+ segundos para resolver.

Por que isso é um problema do usuário, não um problema de teste

O teste estava dizendo a verdade. Ele estava reportando exatamente o que acontece com um usuário real.

Todo usuário que clica em “Confirmar” nesse diálogo espera 8 refetches de cache completarem antes do diálogo fechar. Em uma rede corporativa rápida, eles talvez esperem 1-2 segundos e nem percebam. Em uma conexão móvel, ou durante pico de carga, ou quando a API está sob tráfego intenso — eles ficam olhando para um diálogo aberto se perguntando se a ação funcionou.

O teste E2E na CI estava vivenciando o que um usuário em uma conexão lenta vivencia. O teste não era instável. A UX era lenta.

A correção

A correção separa o fechamento do diálogo da invalidação do cache. O diálogo deve fechar no momento em que a mutação tem sucesso (HTTP 200). A invalidação do cache é uma operação de segundo plano — ela atualiza os dados na página nos bastidores, mas a ação do usuário já está completa.

// AFTER: Dialog closes on success. Cache invalidation is fire-and-forget.
const updateStatus = useMutation({
  mutationFn: (data: StatusUpdate) =>
    api.patch(`/records/${data.id}/status`, { status: data.status }),

  onSuccess: () => {
    // Close the dialog immediately — the user's action succeeded
    onClose();

    // Invalidate caches in the background — don't await
    // TanStack Query will handle the refetches asynchronously
    queryClient.invalidateQueries({ queryKey: ['records'] });
    queryClient.invalidateQueries({ queryKey: ['record-detail'] });
    queryClient.invalidateQueries({ queryKey: ['record-history'] });
    queryClient.invalidateQueries({ queryKey: ['dashboard-stats'] });
    queryClient.invalidateQueries({ queryKey: ['team-metrics'] });
    queryClient.invalidateQueries({ queryKey: ['audit-log'] });
    queryClient.invalidateQueries({ queryKey: ['notifications'] });
    queryClient.invalidateQueries({ queryKey: ['pending-reviews'] });
  },
});

A mudança chave: onClose() vai para o topo do onSuccess, e as chamadas invalidateQueries não são mais aguardadas com await. O wrapper Promise.all() sumiu. O TanStack Query gerencia os refetches de forma assíncrona — os dados na página se atualizam em segundo plano conforme cada consulta resolve, que é o que o usuário espera.

Depois da correção, o diálogo fecha em menos de 200 ms. Os dados da página se atualizam nos próximos 1-2 segundos conforme as invalidações de cache completam. O usuário vê sua ação confirmada imediatamente, e depois vê os dados atualizados aparecendo gradualmente.

O teste passou 680 execuções consecutivas após a correção. Zero falhas.

A armadilha do ciclo de vida do TanStack Query

Esse padrão de bug existe em qualquer codebase que use TanStack Query (ou bibliotecas similares de gerenciamento de cache) com a seguinte combinação:

  1. Uma mutação com um callback onSuccess
  2. Chamadas invalidateQueries dentro de onSuccess que são await-adas
  3. Uma ação de UI (fechamento de diálogo, navegação, toast) que depende da conclusão do onSuccess

A armadilha é que invalidateQueries retorna uma Promise. Se sua função onSuccess é async e você faz await da invalidação, a mutação fica em um estado quase-pending — onSuccess ainda não terminou, então qualquer coisa que dependa de sua conclusão fica bloqueada.

A documentação do TanStack Query é explícita sobre isso: retornar uma Promise de onSuccess mantém isPending como true até que a Promise resolva. Isso é útil quando você quer mostrar um estado de carregamento até que os dados sejam atualizados. É prejudicial quando você acidentalmente encadeia interações de UI à latência de rede.

O padrão de diagnóstico

Quando você encontrar um diálogo, modal ou navegação que responde lentamente após uma mutação, verifique o handler onSuccess:

// 🔍 Red flag: async onSuccess with awaited invalidations
onSuccess: async () => {
  await queryClient.invalidateQueries(/* ... */);  // <-- blocks
  closeDialog();  // <-- delayed
}

// ✅ Fix: separate the user-facing action from the background refresh
onSuccess: () => {
  closeDialog();  // <-- immediate
  queryClient.invalidateQueries(/* ... */);  // <-- background
}

Esse padrão se aplica além do TanStack Query. Qualquer biblioteca de gerenciamento de estado que vincule ações de UI a operações de atualização de cache pode produzir o mesmo bug: o usuário espera por operações de dados que deveriam ser invisíveis para ele.

O protocolo de investigação de testes instáveis

Quando um teste E2E expira em uma interação de UI — um diálogo que não fecha, um botão que não habilita, uma navegação que não completa — o instinto é aumentar o timeout. Resista a esse instinto. O timeout é um sintoma. A pergunta é: o que a UI está esperando?

Passo 1: Reproduzir sob restrição

Execute o teste com CPU limitado para simular as condições da CI. Se falha sob restrição mas passa normalmente, a falha depende de recursos, o que significa que há um problema de performance escondido sob condições normais.

// playwright.config.ts — add CPU throttling for investigation
use: {
  launchOptions: {
    args: ['--disable-gpu'],
  },
  // Simulate CI-like resource constraints
  contextOptions: {
    reducedMotion: 'reduce',
  },
},

Passo 2: Rastrear a interação

Use o trace viewer do Playwright para capturar o que acontece entre a ação do usuário e o resultado esperado. Procure requisições de rede que disparam entre o clique e o fechamento do diálogo. Conte-as. Cronometre-as.

# Run with trace on to capture the full interaction timeline
npx playwright test flaky-test.spec.ts --trace=on
# Open the trace viewer
npx playwright show-trace test-results/trace.zip

Passo 3: Verificar o ciclo de vida da mutação

Abra o código-fonte frontend do componente envolvido. Encontre o hook de mutação. Leia os handlers onSuccess, onError e onSettled. Procure:

  • await dentro de onSuccess (bloqueia a UI)
  • Promise.all() envolvendo múltiplas operações assíncronas (multiplica a latência)
  • Requisições de rede que precisam completar antes de atualizações de UI (dependência sequencial)

Passo 4: Contar as invalidações de cache

Se você encontrar chamadas invalidateQueries, conte-as. Cada uma é uma requisição de rede. Sob restrição de recursos, cada requisição adiciona latência. 8 invalidações x 3 segundos cada = 24 segundos de tempo bloqueado. Esse é o seu teste “instável”.

Passo 5: Propor a separação

A correção é quase sempre a mesma: separar a ação visível para o usuário da atualização de dados em segundo plano. Fechar o diálogo, depois invalidar caches. Navegar a página, depois refetchar dados. Mostrar o toast, depois atualizar o dashboard.

A lição mais profunda

Sua suite E2E é a coisa mais próxima que você tem de um usuário real. Ela clica em botões na velocidade que um usuário clicaria. Ela espera respostas da forma que um usuário esperaria. Ela experimenta latência da forma que um usuário em uma rede restrita experimentaria.

Quando sua suite E2E diz que algo está lento, acredite nela. Quando um teste expira em uma interação de UI, não é o teste sendo impaciente — é o teste vivenciando sua aplicação da forma que um usuário real a vivencia sob condições não ideais.

Aquele teste “instável” salvou cada usuário de um travamento de 30 segundos em um diálogo que deveria fechar em milissegundos. A investigação levou algumas horas. A correção foi direta. A melhoria de UX afeta cada usuário, toda vez que usa aquela funcionalidade.

Pare de aumentar timeouts. Comece a investigar causas raiz. O teste está tentando te dizer algo.