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 rad | P(alla klarar sig) | P(minst 1 fel) | Konfidens att buggen fortfarande finns |
|---|---|---|---|
| 1 | 99,50 % | 0,50 % | Nästan ingen — du lärde dig ingenting |
| 10 | 95,11 % | 4,89 % | Låg |
| 50 | 77,83 % | 22,17 % | Måttlig |
| 100 | 60,65 % | 39,35 % | Börjar komma någonstans |
| 200 | 36,77 % | 63,23 % | Myntkast |
| 460 | 10,00 % | 90,00 % | 90 % säker på att fixen är riktig |
| 680 | 3,31 % | 96,69 % | ~97 % säker vid 0,5 % felfrekvens |
| 1000 | 0,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:
| Grundorsakstyp | Symptom | Fixkategori |
|---|---|---|
| Timingdefekt | Går igenom lokalt, misslyckas i CI under last | Vänta på villkor, inte timers |
| Tillståndsläcka | Misslyckas bara när det körs efter specifika tester | Isolering — ny kontext per test |
| Kapplöpningsvillkor | Misslyckas intermittent vid snabba assertions | Vänta på nätverk/tillstånd, sedan assert |
| Resurskonkurrens | Misslyckas parallellt, går igenom seriellt | Worker-isolering eller resurslås |
| Miljödrift | Misslyckas i staging men inte i dev | Miljö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
doneJag 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.
