680 körningar, noll omförsök: Hur du faktiskt bevisar att ett flaky test är fixat

Erik Treviño avatar
Erik Treviño
Cover for 680 körningar, noll omförsök: Hur du faktiskt bevisar att ett flaky test är fixat

“Det flaky testet är fixat — det gick igenom.”

Nej. Det gick igenom en gång. Det är inte fixat.

Om den ursprungliga felfrekvensen var 0,5 % ger en enda lyckad körning dig 99,5 % konfidens. Det låter högt tills din CI körs 200 gånger i veckan och du ser “slumpmässiga” fel varje måndag morgon. En lyckad körning är inte bevis. Det är ett myntkast du råkade vinna.

Jag kör varje fix av ett flaky test 680 gånger i rad innan jag kallar det klart. Noll omförsök. Noll allowedFlakes. Om det misslyckas en enda gång på 680 körningar är fixen inte riktig.

Matematiken som förändrar din uppfattning

Det här är ett binomialt sannolikhetsproblem. Om ett test har en verklig felfrekvens på p är sannolikheten att det klarar n körningar i rad (1 − p)^n. Sannolikheten att se minst ett fel är 1 − (1 − p)^n.

För ett test med 0,5 % felfrekvens:

Lyckade körningar i radP(alla klarar sig)P(minst 1 fel)Konfidens att buggen fortfarande finns
199,50 %0,50 %Nästan ingen — du lärde dig ingenting
1095,11 %4,89 %Låg
5077,83 %22,17 %Måttlig
10060,65 %39,35 %Börjar komma någonstans
20036,77 %63,23 %Myntkast
46010,00 %90,00 %90 % säker på att fixen är riktig
6803,31 %96,69 %~97 % säker vid 0,5 % felfrekvens
10000,67 %99,33 %99 %+ säker

Det finns också en användbar genväg som kallas Treregeln: om du observerar noll fel i n försök är den övre gränsen för felfrekvensen vid 95 % konfidens ungefär 3/n. För n = 680 blir det 3/680 = 0,44 %. Så 680 lyckade körningar utan fel innebär att du kan vara 95 % säker på att den verkliga felfrekvensen ligger under 0,44 %.

Varför just 680? Det är ett praktiskt antal: 8 parallella workers x 85 iterationer vardera. Det ryms i en enda stresskörning, blir klart inom rimlig tid och trycker ner den övre gränsen för troliga felfrekvenser under tröskeln där en typisk CI-pipeline skulle se veckovisa fel.

Poängen är inte att 680 är ett magiskt tal. Poängen är att en inte räcker, tio inte räcker, och att du behöver göra matten för just din situation.

Varför de flesta “fixar” misslyckas

Efter månader av att ha kört det här protokollet framträdde tre mönster bland misslyckade fixar — tester som gick igenom en gång eller till och med femtio gånger och sedan failade igen.

Mönster 1: Den förlängda timeouten

Den vanligaste icke-fixen. Ett test misslyckas för att ett element tar 6 sekunder att dyka upp. “Fixen” ändrar timeouten från 5 sekunder till 15 sekunder. Det går igenom idag. Imorgon, under CI-belastning med parallella test-workers som tävlar om resurser, tar det elementet 18 sekunder.

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

En timeout är inte en fix — det är en satsning på att systemet alltid kommer att vara minst så här snabbt. Den satsningen förlorar man förr eller senare.

Mönster 2: Tillståndsläckan

Test B går igenom när det körs ensamt, misslyckas när det körs efter Test A. Test A lämnar efter sig en cookie, ett local storage-värde eller en databasrad som ändrar startvillkoren för Test B. “Fixen” lägger till en beforeEach-rensning. Men rensningen i sig är bräcklig — den rensar känt tillstånd men missar den enda artefakten som bara skapas under specifika Test A-förhållanden.

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

Mönster 3: Kapplöpningsvillkoret

Ett klick avfyras. En nätverksförfrågan startar. Testet gör en assertion på resultatet. Men assertionen körs innan svaret har anlänt. Det fungerar på din snabba lokala maskin. Det misslyckas 0,5 % av gångerna i CI där resurskonkurrens lägger till 200 ms latens på varje nätverkshopp.

// ❌ 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');

Sluta säg “flaky” — börja klassificera

Ordet “flaky” är en diagnostisk återvändsgränd. Det stoppar utredningen. Det är testvärldens motsvarighet till att en läkare säger “du är sjuk.” Tekniskt korrekt. Fullständigt oanvändbart.

Varje testfel har en grundorsak. Klassificera den:

GrundorsakstypSymptomFixkategori
TimingdefektGår igenom lokalt, misslyckas i CI under lastVänta på villkor, inte timers
TillståndsläckaMisslyckas bara när det körs efter specifika testerIsolering — ny kontext per test
KapplöpningsvillkorMisslyckas intermittent vid snabba assertionsVänta på nätverk/tillstånd, sedan assert
ResurskonkurrensMisslyckas parallellt, går igenom serielltWorker-isolering eller resurslås
MiljödriftMisslyckas i staging men inte i devMiljömedvetna fixtures

När du namnger klassen kan du söka efter den. Det är prediktiv härdning.

Prediktiv härdning: Fixa tester innan de misslyckas

Det här är metodskiftet som spelar större roll än själva 680-körningsprotokollet.

Efter att ha fixat 3 kända flaky tester som alla delade samma grundorsak — kapplöpningsvillkor mellan klickhanterare och nätverkssvar — sökte jag igenom hela testsviten efter samma mönster. Grep efter assertion-satser som omedelbart följer klickåtgärder utan ett mellanliggande waitForResponse eller waitForLoadState.

# 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

Jag hittade 3 ytterligare tester med samma sårbarhetsmönster — och härdade dem innan de någonsin failade i CI.

Det är skillnaden mellan reaktiv testning (vänta tills det går sönder, fixa sedan) och prediktiv härdning (klassificera felmönstret, sök sedan igenom sviten).

Mönstret FAST / STANDARD / EXTENDED

Ersätt ad hoc-magiska tal med standardiserade pollningskonstanter. Varje waitFor-anrop i sviten refererar till en namngiven konstant, inte en gissning:

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

När ett test behöver EXTENDED är det en signal. Det betyder antingen att operationen genuint är långsam (acceptabelt) eller att något i arkitekturen blockerar (undersök). Namngivna konstanter gör dessa signaler synliga vid kodgranskning.

Stresstestprotokollet

Här är den exakta konfigurationen jag använder för stressvalidering:

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

Om alla 680 går igenom: fixen är riktig. Skeppa den.

Om några misslyckas: titta inte bara på antalet fel. Titta på vilka workers som failade och vilka iterationer inom dessa workers. Fel som klustrar sig i senare iterationer tyder på resursutmattning (minnesläcka, uttömning av anslutningspool). Slumpmässigt spridda fel tyder på att fixen är ofullständig. Fel bara i specifika workers tyder på ett isoleringsproblem.

Den djupare lärdomen

Flaky tester är inte en irritationskategori. De är en diagnostisk möjlighet. Varje “flaky” test berättar något specifikt om ditt systems beteende under förhållanden du inte designade för. Testet som misslyckas intermittent i CI men går igenom lokalt rapporterar verklig information: din applikation beter sig annorlunda under resurskonkurrens. Det är inte testet som är opålitligt. Det är testet som är mer ärligt än din lokala miljö.

Skiftet från “fixa det här flaky testet” till “klassificera och eliminera detta felmönster genom hela sviten” är den mest verkningsfulla förändringen jag har gjort i min testmetodik. Det förvandlar ett reaktivt mullvadsspel till en systematisk minskning av felytan.

Sluta kalla dina tester flaky. Börja klassificera dina fel. Och sluta kalla en fix klar bara för att den gick igenom en gång.

680 körningar. Noll omförsök. Statistisk konfidens, inte hopp.