Det där 'instabila' E2E-testet hittade en riktig UX-bugg

Erik Treviño avatar
Erik Treviño
Cover for Det där 'instabila' E2E-testet hittade en riktig UX-bugg

Testet var enkelt. Klicka på en knapp. Vänta tills en dialog stängs. Verifiera resultatet.

Det passerade 95 % av gångerna. De övriga 5 % tog dialogen 30+ sekunder att stänga. Bara i CI. Lokalt fungerade det felfritt. Teamet hade redan märkt det: instabilt.

De flesta team hade lagt till en längre timeout, satt retries: 2, och gått vidare. “CI:n är långsam, tester är instabila, skeppa det.” Jag har sett den reaktionen så många gånger att den har sitt eget muskelminne. Men “instabilt” är inte en diagnos. Det är frånvaron av en. Så jag grävde djupare.

Det jag hittade var inte ett testproblem. Det var en riktig UX-bugg som påverkar varje användare i produktion.

Testet

Så här såg testet ut — rakt på sak Playwright som interagerar med en mutationsdialog:

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();
});

Felet inträffade alltid på samma rad: await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 }). Dialogen förblev öppen i 30+ sekunder. Timeouten på 10 sekunder löpte ut. Testet misslyckades.

Utredningen

Steg 1 var att verifiera att felet var verkligt och inte en artefakt av CI-infrastrukturen. Jag körde testet 100 gånger lokalt med --repeat-each=100. Det passerade varje gång. Jag körde det med strypt CPU (6x nedbromsning via Playwrights emulering) för att simulera CI:ns resursbegränsningar. Det misslyckades 3 gånger av 100. Felet var miljöberoende — resurskonkurrens fick det att visa sig.

Steg 2 var att förstå vad dialogens stängningshanterare faktiskt gör. Jag öppnade frontend-källkoden och spårade mutationsflödet.

Grundorsaken: mutationens livscykel

Dialogen använde TanStack Query (React Query) för sin mutation. När användaren klickar på “Confirm” utlöses mutationen. Vid framgång körs dialogens onSuccess-hanterare. Här är problemet:

// 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();
  },
});

Där har vi det. onSuccess-hanteraren anropar Promise.all()8 cache-invalideringar av queries. I TanStack Query markerar invalidateQueries inte bara cachen som inaktuell — den utlöser en refetch. Varje invalidering skickar en nätverksförfrågan för att ladda om den datan.

Anropet till onClose() — det som stänger dialogen — sitter efter Promise.all(). Dialogen stängs inte förrän alla 8 refetches har slutförts.

På en snabb lokal maskin med låg latens till API-servern slutförs dessa 8 förfrågningar på under en sekund. I CI, där testkörningsmiljön delar resurser med andra parallella workers och API-servern körs i en container med begränsad CPU, tar dessa 8 förfrågningar 10-30+ sekunder att slutföra.

Varför detta är ett användarproblem, inte ett testproblem

Testet talade sanning. Det rapporterade exakt vad som händer med en riktig användare.

Varje användare som klickar på “Confirm” i den här dialogen väntar på att 8 cache-refetches ska slutföras innan dialogen stängs. På ett snabbt företagsnätverk väntar de kanske 1-2 sekunder utan att märka det. På en mobilanslutning, eller under hög belastning, eller när API:et har mycket trafik — stirrar de på en öppen dialog och undrar om deras åtgärd fungerade.

E2E-testet i CI upplevde det som en användare på en långsam anslutning upplever. Testet var inte instabilt. UX:en var långsam.

Lösningen

Lösningen separerar dialogstängningen från cache-invalideringen. Dialogen ska stängas i det ögonblick mutationen lyckas (HTTP 200). Cache-invalidering är en bakgrundsoperation — den uppdaterar datan på sidan bakom kulisserna, men användarens åtgärd är redan slutförd.

// 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'] });
  },
});

Den avgörande förändringen: onClose() flyttas till toppen av onSuccess, och invalidateQueries-anropen awaitas inte längre. Promise.all()-wrappern är borta. TanStack Query hanterar refetches asynkront — datan på sidan uppdateras i bakgrunden allt eftersom varje query resolvar, vilket är vad användaren förväntar sig.

Efter lösningen stängs dialogen på under 200 ms. Sidans data uppdateras under de följande 1-2 sekunderna allt eftersom cache-invalideringarna slutförs. Användaren ser sin åtgärd bekräftad omedelbart, och ser sedan den uppdaterade datan dyka upp gradvis.

Testet passerade 680 körningar i rad efter lösningen. Noll fel.

TanStack Query-livscykelfällan

Det här buggmönstret finns i vilken kodbas som helst som använder TanStack Query (eller liknande cache-hanteringsbibliotek) med följande kombination:

  1. En mutation med en onSuccess-callback
  2. invalidateQueries-anrop inuti onSuccess som await-as
  3. En UI-åtgärd (dialogstängning, navigering, toast) som beror på att onSuccess slutförs

Fällan är att invalidateQueries returnerar en Promise. Om din onSuccess-funktion är async och du await-ar invalideringen, stannar mutationen i ett nästan-pending-tillstånd — onSuccess har inte slutförts ännu, så allt som beror på att den slutförs blockeras.

TanStack Querys dokumentation är tydlig med detta: att returnera en Promise från onSuccess håller isPending som true tills Promisen resolvar. Det är användbart när du vill visa ett laddningstillstånd tills datan har uppdaterats. Det är skadligt när du oavsiktligt kedjar UI-interaktioner till nätverkslatens.

Diagnostikmönstret

När du stöter på en dialog, modal eller navigering som svarar långsamt efter en mutation, kontrollera onSuccess-hanteraren:

// 🔍 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
}

Det här mönstret gäller bortom TanStack Query. Varje tillståndshanteringsbibliotek som kopplar UI-åtgärder till cache-uppdateringsoperationer kan producera samma bugg: användaren väntar på dataoperationer som borde vara osynliga för dem.

Protokollet för utredning av instabila tester

När ett E2E-test timeout:ar på en UI-interaktion — en dialog som inte stängs, en knapp som inte aktiveras, en navigering som inte slutförs — är instinkten att öka timeouten. Motstå den instinkten. Timeouten är ett symptom. Frågan är: vad väntar UI:t på?

Steg 1: Reproducera under begränsningar

Kör testet med strypt CPU för att simulera CI-förhållanden. Om det misslyckas under begränsningar men passerar normalt är felet resursberoende, vilket betyder att det finns ett prestandaproblem som gömmer sig under normala förhållanden.

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

Steg 2: Spåra interaktionen

Använd Playwrights trace viewer för att fånga vad som händer mellan användaråtgärden och det förväntade resultatet. Leta efter nätverksförfrågningar som utlöses mellan klicket och dialogstängningen. Räkna dem. Tidsmät dem.

# 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

Steg 3: Kontrollera mutationens livscykel

Öppna frontend-källkoden för den berörda komponenten. Hitta mutations-hooken. Läs onSuccess-, onError- och onSettled-hanterarna. Leta efter:

  • await inuti onSuccess (blockerar UI:t)
  • Promise.all() som wrappar flera asynkrona operationer (multiplicerar latensen)
  • Nätverksförfrågningar som måste slutföras innan UI-uppdateringar (sekventiellt beroende)

Steg 4: Räkna cache-invalideringarna

Om du hittar invalidateQueries-anrop, räkna dem. Vart och ett är en nätverksförfrågan. Under resursbegränsningar lägger varje förfrågan till latens. 8 invalideringar x 3 sekunder vardera = 24 sekunders blockeringstid. Det är ditt “instabila” test.

Steg 5: Föreslå separationen

Lösningen är nästan alltid densamma: separera den användarsynliga åtgärden från datauppdateringen i bakgrunden. Stäng dialogen, sedan invalidera cachar. Navigera sidan, sedan refetcha data. Visa toasten, sedan uppdatera dashboarden.

Den djupare lärdomen

Din E2E-suite är det närmaste du har en riktig användare. Den klickar på knappar i den hastighet en användare skulle. Den väntar på svar på det sätt en användare skulle. Den upplever latens på det sätt en användare på ett begränsat nätverk skulle.

När din E2E-suite säger att något är långsamt, tro på den. När ett test timeout:ar på en UI-interaktion är det inte testet som är otåligt — det är testet som upplever din applikation på det sätt en riktig användare upplever den under icke-ideala förhållanden.

Det där “instabila” testet räddade varje användare från en 30 sekunders hängning på en dialog som borde stängas på millisekunder. Utredningen tog några timmar. Lösningen var enkel. UX-förbättringen påverkar varje användare, varje gång de använder den funktionen.

Sluta öka timeouts. Börja utreda grundorsaker. Testet försöker säga något till dig.