Niestabilne testy — przyczyny i systematyczne naprawy
Niestabilne testy niszczą zaufanie do automatyzacji. Poznaj pełną taksonomię przyczyn niestabilności i jak zbudować proces zespołowy dla zapobiegania, kwarantanny i systematycznej eliminacji.
Ten wpis jest częścią serii Strategia automatyzacji. Jeśli przegapiłeś poprzedni wpis, przeczytaj najpierw Część 5 — Projektowanie utrzymywalnych suite’ów.
„Po prostu uruchom pipeline ponownie.”
Jeśli twój zespół mówi to regularnie, masz problem z niestabilnymi testami. Niestabilne testy — testy przechodzące lub failujące intermitentnie bez zmian w kodzie — to najbardziej korozyjna siła w automatyzacji testów.
Niszczą zaufanie. Gdy test failuje, inżynierowie pytają „czy to prawdziwy błąd, czy niestabilny test?” Jeśli odpowiedź brzmi „prawdopodobnie niestabilny”, ignorują go. Gdy zaufanie znika, cały suite testowy staje się formalnością — czymś, co uruchamiasz, bo CI wymaga, nie dlatego, że dostarcza wartość.
Ten wpis idzie głębiej niż Część 1 serii fundamentals. Omówimy pełną taksonomię przyczyn niestabilności, systematyczną detekcję i naprawy oraz jak zbudować proces zespołowy dla zarządzania budżetami niestabilności, kwarantanny i zapobiegania.
Koszt niestabilnych testów
Przed nurkaniem w naprawy zrozum koszt:
Koszty bezpośrednie
- Czas developera — Badanie fałszywych failur (5–20 minut na niestabilność)
- Koszt CI — Ponowne uruchamianie pipeline’ów marnuje compute i opóźnia wdrożenia
- Zablokowane wdrożenia — Zespoły nie mogą shipować czekając, aż niestabilne testy przejdą
Koszty pośrednie
- Erodowane zaufanie — Gdy zaufanie znika, prawdziwe błędy są ignorowane jako „prawdopodobnie niestabilne”
- Utrzymanie testów — Niestabilne testy pochłaniają nieproporcjonalny wysiłek utrzymaniowy
- Morale zespołu — Nic nie jest bardziej demoralizujące niż debugowanie failury testu, która nie jest błędem
:::warning[Zasada 2%] Badania pokazują, że gdy wskaźnik niestabilności przekracza 2%, zespoły przestają ufać suite’owi testowemu. Jeśli 1 na 50 uruchomień testów ma fałszywą failurę, inżynierowie zakładają, że każda failura jest fałszywa, dopóki się nie udowodni inaczej. Trzymaj wskaźnik niestabilności poniżej 2%. :::
Taksonomia niestabilności — źródła problemu
Niestabilne testy nie są losowe. Wpadają w przewidywalne kategorie. Oto pełna taksonomia.
Kategoria 1: Problemy z timingiem i async
Symptom: Test przechodzi lokalnie, failuje w CI. Failuje intermitentnie bez wzorca.
Źródła problemu:
- Hardcodowane waity —
await page.waitForTimeout(2000)zakłada, że strona ładuje się w < 2s - Race conditions — Test klika przycisk zanim JavaScript dołączył event listener
- Interwały pollingu — Aplikacja polluje API co 5s, test działa przed zakończeniem pierwszego poll
- Opóźnienia animacji — Element UI animuje się na pozycję, test klika przed zakończeniem animacji
Naprawy:
- Zastąp
waitForTimeoutwaitami opartymi na warunkach:// Źle await page.waitForTimeout(2000); await expect(page.getByText('Sukces')).toBeVisible(); // Dobrze await expect(page.getByText('Sukces')).toBeVisible({ timeout: 5000 }); - Używaj auto-waiting Playwright (retryuje asercje automatycznie)
- Czekaj na network idle po nawigacji:
await page.waitForLoadState('networkidle') - Wyłącz animacje w środowiskach testowych:
await page.addStyleTag({ content: '* { animation: none !important; }' });
Kategoria 2: Zanieczyszczenie danych testowych
Symptom: Test przechodzi gdy uruchamiany samodzielnie, failuje gdy w suite.
Źródła problemu:
- Współdzielone fikstury — Wiele testów modyfikuje ten sam rekord bazodanowy
- Brak sprzątania — Testy zostawiają dane wpływające na kolejne testy
- Race conditions w równoległym wykonaniu — Dwa testy tworzą/usuwają ten sam zasób
Naprawy:
- Każdy test tworzy i posiada swoje dane (omówione w Części 4)
- Używaj unikalnych identyfikatorów:
const email = `test-${Date.now()}-${Math.random().toString(36)}@example.com`; - Sprzątaj w
afterEach:test.afterEach(async ({ request }) => { await deleteUser(request, testUserId); }); - Używaj transakcji bazodanowych dla testów integracyjnych (rollback po każdym teście)
Kategoria 3: Niestabilność środowiska
Symptom: Test failuje w CI, ale przechodzi lokalnie lub odwrotnie.
Źródła problemu:
- Niestabilność sieci — Wywołania zewnętrznych API timeoutują w CI
- Skew zegara — Testy zależą od
Date.now(), failują przez strefy czasowe lub wolne maszyny - Różnice systemu plików — Testy hardcodują ścieżki (
C:\temp\) nieistniejące w Linux CI - Brakujące zależności — Fonty, biblioteki lub serwisy nieinstalowane w CI
Naprawy:
- Mockuj zewnętrzne API:
await page.route('https://api.external.com/**', route => { route.fulfill({ status: 200, body: JSON.stringify(mockData) }); }); - Używaj stałych zegarów w testach:
vi.useFakeTimers(); vi.setSystemTime(new Date('2025-01-01T12:00:00Z')); - Używaj Docker/TestContainers dla konsystentnych środowisk
- Upewnij się, że CI ma te same zależności co lokalne (pinuj wersje, używaj lockfiles)
Kategoria 4: Niestabilne selektory
Symptom: Test failuje z “element not found” intermitentnie.
Źródła problemu:
- Dynamiczne ID — Selekcja po ID gdy ID są auto-generowane (
<div id="item-1827364">) - Selektory pozycyjne —
.nth-child(3)łamie się gdy kolejność DOM się zmienia - Klasy CSS — CSS-in-JS generuje nowe nazwy klas przy każdym buildzie
- Zawartość tekstowa — Selekcja po tekście, który się zmienia (np. dynamiczne daty)
Naprawy:
- Używaj atrybutów
data-testiddla stabilnej selekcji:<button data-testid="submit-order-btn">Złóż zamówienie</button> - Preferuj selektory oparte na rolach:
await page.getByRole('button', { name: 'Złóż zamówienie' }).click(); - Unikaj kruchych selektorów jak
.nth-child(),> div > div > button
Kategoria 5: Zależność od zewnętrznych serwisów
Symptom: Test czasami failuje z “network timeout” lub “service unavailable.”
Źródła problemu:
- API trzeciej strony — Bramka płatności, serwis emailowy, API geokodujące pada
- Współdzielone środowisko staging — Inny zespół wdraża breaking change do współdzielonego backendu
- Limity rate — Test przekracza limit API rate gdy uruchamiany często
Naprawy:
- Mockuj zewnętrzne serwisy (jak pokazano w Kategorii 3)
- Używaj contract testów do walidacji kształtu API bez dotykania prawdziwego serwisu
- Uruchamiaj testy integracyjne przeciw dedykowanym instancjom testowym (nie współdzielony staging)
- Implementuj retry z exponential backoff dla prawdziwie niestabilnych serwisów:
await expect(async () => { const response = await fetch('/api/orders'); expect(response.status).toBe(200); }).toPass({ intervals: [1000, 2000, 5000] }); // Playwright retryuje z backoffem
Kategoria 6: Zależność kolejności testów
Symptom: Suite testowy przechodzi, ale pojedynczy test failuje gdy uruchomiony w izolacji.
Źródła problemu:
- Setup w poprzednim teście — Test A tworzy dane, Test B zależy od ich istnienia
- Mutacja globalnego stanu — Test modyfikuje singleton lub zmienną statyczną
Naprawy:
- Każdy test musi być idempotentny — uruchamialny w dowolnej kolejności
- Używaj fikstur testowych lub
beforeEachdla setupu:test.beforeEach(async ({ request }) => { testUser = await createUser(request); }); - Unikaj modyfikacji globalnego stanu (singletons, zmienne środowiskowe) — resetuj po każdym teście
Detekcja: mierzenie niestabilności
Nie możesz naprawić tego, czego nie mierzysz. Śledź wskaźnik niestabilności systematycznie.
Detekcja manualna
Uruchom testy wiele razy równolegle:
# Playwright: uruchom suite 10 razy równolegle
npx playwright test --repeat-each=10 --workers=10
# Obserwuj intermitentne failury
Jeśli jakikolwiek test failuje choćby raz z 10 uruchomień, jest niestabilny.
Detekcja automatyczna
Większość systemów CI może śledzić wskaźnik niestabilności:
- GitHub Actions: Używaj reporterów testowych śledzących niestabilne testy w czasie
- Playwright: Wbudowana detekcja niestabilnych testów — ponownie uruchamia failujące testy automatycznie i oznacza je jako niestabilne jeśli przejdą przy retry
Przykładowa konfiguracja Playwright:
export default defineConfig({
retries: 2, // Retry failujące testy dwa razy
reporter: [
['html'],
['json', { outputFile: 'test-results.json' }],
],
});
Playwright oznaczy testy jako niestabilne jeśli failują początkowo, ale przechodzą przy retry.
Dashboard niestabilności
Śledź wskaźnik niestabilności w czasie. Stwórz dashboard pokazujący:
- Wskaźnik niestabilności — % uruchomień testów z co najmniej jednym niestabilnym testem
- Niestabilne testy według nazwy — Zidentyfikuj chronicznych sprawców
- Trend — Czy wskaźnik niestabilności rośnie czy maleje?
:::tip[Ustaw budżet niestabilności] Zdefiniuj akceptowalny wskaźnik niestabilności (np. < 2%) i traktuj jego przekroczenie jako incydent P0. Gdy przekroczysz próg, przestań dodawać nowe testy, dopóki niestabilności nie zostaną naprawione. :::
Systematyczne naprawy: proces kwarantanny
Gdy wykryjesz niestabilny test, podążaj tym procesem:
Krok 1: Odtwórz lokalnie
Uruchom test 50–100 razy równolegle:
npx playwright test path/to/test.spec.ts --repeat-each=50 --workers=10
Jeśli failuje co najmniej raz, odtworzyłeś go. Zanotuj wskaźnik failur (np. failuje 3 razy z 50 = 6% wskaźnik niestabilności).
Krok 2: Poddaj test kwarantannie
Oznacz jako niestabilny i skipuj w CI, aby zapobiec blokowaniu wdrożeń:
test.fixme('użytkownik może checkout (NIESTABILNY)', async ({ page }) => {
// Implementacja testu
});
Lub używaj tagów:
test('użytkownik może checkout @flaky', async ({ page }) => {
// Implementacja testu
});
Potem w CI wyklucz niestabilne testy:
npx playwright test --grep-invert @flaky
:::warning[Kwarantanna, nie usuwanie] Nie usuwaj niestabilnych testów. Poddaj je kwarantannie. Niestabilny test walidujący krytyczną funkcjonalność jest wciąż wartościowy gdy naprawiony. Usunięcie go traci pokrycie. :::
Krok 3: Zbadaj źródło problemu
Używaj taksonomii powyżej do identyfikacji kategorii. Powszechne techniki badania:
- Dodaj debug logging: Loguj timestampy, requesty sieciowe, stany elementów
- Używaj trace viewer: Trace viewer Playwright pokazuje screenshoty, sieć, logi konsoli dla każdego kroku
- Porównaj przechodzące vs failujące uruchomienia: Co się różni? Timing? Dane? Środowisko?
Krok 4: Napraw i weryfikuj
Zastosuj naprawę (np. zastąp waitForTimeout, dodaj izolację danych, mockuj zewnętrzne API). Weryfikuj uruchamiając test 100 razy:
npx playwright test path/to/test.spec.ts --repeat-each=100 --workers=10
Jeśli przechodzi 100/100 razy, naprawa jest ważna. Jeśli wciąż failuje, powtórz Krok 3.
Krok 5: Wyciągnij z kwarantanny
Usuń .fixme() lub tag @flaky. Włącz ponownie w CI. Monitoruj wskaźnik niestabilności.
Zapobieganie: budowanie kultury wolnej od niestabilności
Naprawa istniejących niestabilności jest reaktywna. Zapobieganie jest proaktywne.
Checklista code review dla PR testowych
Gdy reviewujesz PR z nowymi testami, sprawdź:
- Brak
waitForTimeoutlubThread.Sleep - Brak hardcodowanych ID, nazw klas lub selektorów pozycyjnych
- Test tworzy własne dane (brak współdzielonych fikstur)
- Sprzątanie uwzględnione (
afterEachlub fikstury) - Przeszedł 10+ razy lokalnie równolegle
- Brak zależności od zewnętrznych serwisów (lub poprawnie mockowane)
CI gating
Nie merguj PR wprowadzających niestabilne testy. Konfiguruj CI do:
- Uruchomienia nowych testów 5–10 razy przed mergem
- Blokowania merge jeśli którekolwiek uruchomienie failuje
Przykładowy workflow GitHub Actions:
- name: Uruchom nowe testy wiele razy
run: npx playwright test --repeat-each=5 --workers=5
Budżet niestabilności
Ustaw zespołowy budżet niestabilności (np. < 2% wskaźnik niestabilności). Gdy go przekroczysz:
- Przestań dodawać nowe testy dopóki wskaźnik niestabilności nie spadnie poniżej progu
- Dedykuj czas na naprawę niestabilności — traktuj to jako pracę P0
- Przeprowadź post-mortem — Dlaczego wskaźnik niestabilności wzrósł? Co możemy poprawić?
Zaawansowane: mitigacja niestabilności gdy nie możesz naprawić
Niektóre niestabilności są nieuniknione (np. testy integracyjne przeciw prawdziwie niestabilnym serwisom trzeciej strony). W tych przypadkach używaj strategii mitigacyjnych:
Strategia 1: Retry testów
Playwright wspiera automatyczne retry:
export default defineConfig({
retries: 2, // Retry failujące testy do 2 razy
});
Jeśli test przechodzi przy retry, jest oznaczony niestabilny, ale nie blokuje CI.
Używaj oszczędnie. Retry maskują niestabilność — nie naprawiają jej.
Strategia 2: Asercje tolerujące niestabilność
Dla testów walidujących zachowanie „eventually consistent”:
await expect(async () => {
const count = await getOrderCount();
expect(count).toBeGreaterThan(0);
}).toPass({ timeout: 10000 }); // Retry asercji do 10s
To jest właściwe dla kolejek wiadomości, przetwarzania async lub scenariuszy eventual consistency.
Strategia 3: Oddziel niestabilne testy od rdzennego suite’a
Jeśli test jest wartościowy, ale nieuniknione niestabilny (np. test wydajnościowy wpływany przez obciążenie CI):
- Przenieś go do osobnego suite’a
- Uruchamiaj nocnie zamiast per-commit
- Nie blokuj wdrożeń na nim
Podsumowanie
Niestabilne testy nie są nieuniknione. Mają przewidywalne źródła problemu i systematyczne naprawy.
Taksonomia:
- Problemy z timingiem i async — Zastąp hardcodowane waity waitami opartymi na warunkach
- Zanieczyszczenie danych testowych — Izoluj dane na test
- Niestabilność środowiska — Używaj Dockera, mockuj zewnętrzne serwisy, fixuj zegary
- Niestabilne selektory — Używaj
data-testidi selekcji opartej na rolach - Zależności zewnętrznych serwisów — Mockuj lub używaj contract testów
- Zależność kolejności testów — Czyń testy idempotentnymi
Proces:
- Mierz — Śledź wskaźnik niestabilności
- Kwarantanna — Izoluj niestabilne testy, aby zapobiec blokowaniu CI
- Napraw — Stosuj naprawy oparte na taksonomii
- Zapobiegaj — Code review, CI gating, budżet niestabilności
Traktuj wskaźnik niestabilności poniżej 2% jako niepodlegający negocjacji próg jakości. Gdy zaufanie znika, cały suite testowy staje się bezwartościowy.
W ostatnim wpisie tej serii omówimy contract testy vs E2E — kiedy walidować granice integracyjne z lekkimi contract testami zamiast ciężką automatyzacją E2E i kiedy wciąż potrzebujesz pełnego pokrycia user journey.
Zadanie na ten tydzień: Zmierz swój obecny wskaźnik niestabilności. Uruchom swój pełny suite testowy 10 razy (manualnie lub w CI). Policz, ile uruchomień ma co najmniej jedną failurę. Jeśli wskaźnik niestabilności > 2%, zidentyfikuj trzech najczęstszych sprawców używając logów CI. Poddaj ich kwarantannie (.fixme() lub tag @flaky). Dedykuj 2 godziny w tym tygodniu na naprawę jednego używając taksonomii z tego wpisu.