Auto-waiting, asercje i walka z flakami
Opanuj asercje web-first i auto-waiting w Playwright, aby wyeliminować niestabilne testy. Dowiedz się, dlaczego waitForTimeout to błąd, kiedy networkidle pomaga, jak działa expect.poll i czy.
Ten wpis jest częścią serii Podstawy Playwright. Część 3 omówiła strukturyzowanie testów z obiektami stron i fixture’ami. Ten post skupia się na pisaniu testów, które nie zawodzą losowo — sztuce zabijania niestabilności.
Co sprawia, że test jest niestabilny?
Niestabilny test (flaky test) to test, który czasami przechodzi, a czasami nie, bez żadnych zmian w kodzie. Niestabilność to trucizna dla suit testowych. Gdy inżynierowie tracą zaufanie do suity, przestają reagować na błędy. Prawdziwe bugi przechodzą dalej.
Najczęstsza przyczyna niestabilności w testach E2E: problemy z czasem. Test działa szybciej niż aplikacja, albo aplikacja działa szybciej, niż test się spodziewa.
Rozwiązanie Playwright: auto-waiting i asercje web-first. Te funkcje eliminują większość problemów czasowych bez ręcznych oczekiwań.
Auto-waiting — czym jest i dlaczego ma znaczenie
Playwright automatycznie czeka, aż elementy będą akcjonowalne przed wykonaniem akcji. Akcjonowalne oznacza:
- Przyłączone do DOM — element istnieje na stronie
- Widoczne — element nie jest
display: noneanivisibility: hidden - Stabilne — element nie jest animowany ani nie porusza się
- Otrzymuje eventy — element nie jest zasłonięty przez inny element
- Włączone — element nie jest
disabled
Przykład: Auto-waiting w akcji
await page.getByRole('button', { name: 'Wyślij' }).click();
Playwright czeka, aż:
- Przycisk istnieje w DOM
- Przycisk jest widoczny
- Przycisk nie jest zakryty przez modal lub spinner
- Przycisk jest włączony (brak atrybutu
disabled) - Przycisk jest stabilny (nie jest animowany)
Dopiero wtedy Playwright klika. To dzieje się automatycznie. Bez waitForTimeout, bez ręcznych sprawdzeń.
Czego auto-waiting NIE obejmuje
Auto-waiting dotyczy akcji (click, fill, check, select), ale nie zapytań (isVisible(), textContent(), getAttribute()).
// ❌ ŹLE: Brak auto-waiting na zapytaniach
if (await page.getByRole('button').isVisible()) {
await page.getByRole('button').click();
}
Wywołanie isVisible() nie czeka. Zwraca natychmiast. Jeśli przycisku jeszcze nie ma w DOM, zwróci false nawet jeśli przycisk pojawi się 100ms później.
// ✅ DOBRZE: Użyj asercji, które CZEKAJĄ
await expect(page.getByRole('button')).toBeVisible();
await page.getByRole('button').click();
Asercje ponawiają próby, aż warunek zostanie spełniony (lub timeout).
Asercje web-first — właściwy sposób sprawdzania stanu
Playwright dostarcza asercje web-first, które automatycznie ponawiają próby, aż warunek jest prawdziwy. To fundament niezawodnych testów.
Asercje widoczności
// Czekaj, aż element będzie widoczny (ponawia do 5 sekund)
await expect(page.getByText('Zamówienie potwierdzone')).toBeVisible();
// Czekaj, aż element NIE będzie widoczny (np. spinner ładowania znika)
await expect(page.getByTestId('loading-spinner')).not.toBeVisible();
Asercje zawartości tekstowej
// Czekaj, aż element zawiera konkretny tekst
await expect(page.getByRole('heading')).toHaveText('Witaj');
// Częściowe dopasowanie tekstu
await expect(page.getByRole('status')).toContainText('Przetwarzanie');
// Dopasowanie regex
await expect(page.getByRole('alert')).toHaveText(/błąd|ostrzeżenie/i);
Asercje atrybutów
// Czekaj, aż input ma konkretną wartość
await expect(page.getByLabel('Email')).toHaveValue('user@example.com');
// Czekaj, aż przycisk jest włączony
await expect(page.getByRole('button', { name: 'Wyślij' })).toBeEnabled();
// Czekaj, aż element ma konkretną klasę
await expect(page.locator('.status')).toHaveClass(/active/);
Asercje liczenia
// Czekaj, aż dokładnie 3 przedmioty na liście
await expect(page.getByRole('listitem')).toHaveCount(3);
// Czekaj, aż przynajmniej 1 przedmiot
await expect(page.getByRole('row')).toHaveCount({ minimum: 1 });
Asercje URL
// Czekaj, aż URL pasuje do wzorca
await expect(page).toHaveURL(/\/dashboard/);
// Czekaj, aż URL jest dokładny
await expect(page).toHaveURL('https://example.com/profile');
Asercje tytułu
// Czekaj, aż tytuł strony pasuje
await expect(page).toHaveTitle('Dashboard | MyApp');
Wszystkie te asercje ponawiają automatycznie. Domyślny timeout: 5 sekund (konfigurowalny).
Antywzorzec waitForTimeout
Jeśli masz waitForTimeout w swoich testach, traktuj to jako błąd.
Dlaczego to złe
// ❌ ŹLE: Hardkodowane oczekiwanie
await page.getByRole('button', { name: 'Wyślij' }).click();
await page.waitForTimeout(2000); // Nadzieja, że 2 sekundy wystarczą
await expect(page.getByText('Sukces')).toBeVisible();
Ten test:
- Marnuje czas — czeka 2 sekundy nawet jeśli odpowiedź przychodzi w 200ms
- Wciąż niestabilny — zawodzi, jeśli odpowiedź zajmuje 2,1 sekundy
- Zależny od środowiska — 2 sekundy mogą działać lokalnie, ale zawieść w CI
Naprawa
// ✅ DOBRZE: Czekaj na warunek
await page.getByRole('button', { name: 'Wyślij' }).click();
await expect(page.getByText('Sukces')).toBeVisible(); // Czeka do 5s
Ten test:
- Szybki — kontynuuje, gdy tylko “Sukces” się pojawi
- Niezawodny — ponawia przez 5 sekund (konfigurowalny timeout)
- Przenośny — działa tak samo lokalnie i w CI
:::warning[Jedyne prawidłowe użycie waitForTimeout]
Debugowanie. Gdy potrzebujesz zobaczyć, jak wygląda strona w środku testu, await page.waitForTimeout(5000) wstrzymuje wykonanie. Usuń to przed commitem.
:::
Oczekiwanie na sieć — kiedy i jak używać
Czasami musisz czekać na aktywność sieciową, nie tylko zmiany DOM. Playwright dostarcza do tego narzędzia.
Czekaj na konkretne żądanie sieciowe
// Czekaj, aż wywołanie API się zakończy przed kontynuowaniem
const responsePromise = page.waitForResponse('**/api/orders');
await page.getByRole('button', { name: 'Załaduj zamówienia' }).click();
const response = await responsePromise;
expect(response.status()).toBe(200);
Czekaj na nawigację
// Czekaj na nawigację po kliknięciu linku
await Promise.all([
page.waitForNavigation(),
page.getByRole('link', { name: 'Dashboard' }).click(),
]);
Albo użyj prostszego waitForURL:
await page.getByRole('link', { name: 'Dashboard' }).click();
await page.waitForURL('**/dashboard');
Czekaj na stan ładowania
// Czekaj, aż sieć jest w większości bezczynna
await page.waitForLoadState('networkidle');
// Czekaj, aż DOM jest załadowany (ale skrypty mogą jeszcze działać)
await page.waitForLoadState('domcontentloaded');
// Czekaj, aż strona jest w pełni załadowana (domyślne dla goto)
await page.waitForLoadState('load');
:::info[Mit networkidle]
networkidle czeka, aż nie ma żądań sieciowych przez 500ms. Brzmi użytecznie, ale często jest pułapką. Nowoczesne SPA ciągle odpytują API (analityka, websockety, aktualizacje na żywo). networkidle może nigdy się nie rozwiązać. Używaj ukierunkowanych oczekiwań (konkretny element widoczny, konkretne wywołanie API zakończone).
:::
Pollowanie z expect.poll — niestandardowe warunki
Czasami musisz czekać na warunek, który nie jest prostym sprawdzeniem DOM. expect.poll() pozwala zdefiniować niestandardową logikę ponawiania.
Przykład: Czekaj na aktualizację danych API
// Polluj endpoint API, aż status zamówienia się zmieni
await expect.poll(async () => {
const response = await page.request.get('/api/orders/123');
const order = await response.json();
return order.status;
}, {
message: 'Status zamówienia powinien stać się "zakończone"',
timeout: 10_000, // 10 sekund
}).toBe('zakończone');
expect.poll() ponawia funkcję callback, aż asercja przejdzie lub timeout.
Przykład: Czekaj, aż plik się pojawi
// Polluj system plików, aż plik istnieje (przydatne dla testów pobierania)
import fs from 'fs/promises';
await expect.poll(async () => {
try {
await fs.access('./downloads/raport.pdf');
return true;
} catch {
return false;
}
}, {
timeout: 5000,
}).toBe(true);
Używaj expect.poll() oszczędnie. Większość przypadków jest lepiej obsługiwana przez asercje web-first.
Konfigurowanie timeout’ów
Domyślny timeout dla asercji: 5 sekund. Możesz nadpisać globalnie lub per-asercja.
Globalny timeout (playwright.config.ts)
export default defineConfig({
expect: {
timeout: 10_000, // 10 sekund dla wszystkich asercji
},
});
Timeout per-test
test('wolna operacja', async ({ page }) => {
test.setTimeout(30_000); // Ten test dostaje 30 sekund łącznie
await page.goto('/long-running-operation');
await expect(page.getByText('Zakończone')).toBeVisible({ timeout: 15_000 });
});
Timeout per-asercja
// Nadpisz timeout dla pojedynczej asercji
await expect(page.getByText('Dane załadowane')).toBeVisible({ timeout: 15_000 });
:::tip[Wytyczne timeout’ów]
- Domyślny (5s) — wystarczający dla 95% asercji
- Zwiększ (10-15s) — dla znanych-wolnych operacji (upload plików, generowanie raportów)
- Zmniejsz (1-2s) — dla scenariuszy szybkiego-zawodzenia (testowanie stanów błędów)
Jeśli często zwiększasz timeout’y, aplikacja jest zbyt wolna lub strategia testowa jest zła. :::
Ponawianie — siatka bezpieczeństwa czy code smell?
Playwright wspiera ponawianie testów. Jeśli test nie przejdzie, może automatycznie się uruchomić ponownie.
Włącz ponawianie (playwright.config.ts)
export default defineConfig({
retries: process.env.CI ? 2 : 0, // Ponawiaj dwa razy w CI, nigdy lokalnie
});
Kiedy ponawianie jest właściwe
Niestabilność infrastruktury — runnery CI są wolniejsze niż lokalne maszyny. Zakłócenia sieci, opóźnienia startowania kontenerów i konflikty zasobów mogą powodować przerywane błędy. Ponawianie wygładza te problemy.
Niestabilność third-party — Jeśli testujesz integrację z zewnętrzną usługą (bramka płatności, dostawca email), ich okazjonalne przestoje nie powinny blokować Twojego pipeline.
Kiedy ponawianie jest złe
Bugi aplikacji — Jeśli test nie przechodzi, bo Twój kod ma race condition, ponawianie ukrywa błąd. Napraw race condition.
Bugi testów — Jeśli test nie przechodzi, bo ma kruchy selektor, ponawianie maskuje problem. Napraw selektor.
Wolne testy — Ponawianie 5-minutowego testu mnoży czas CI. Zamiast tego napraw szybkość testu.
:::warning[Ponawianie to code smell] Jeśli Twoja suita testów potrzebuje ponawiania, żeby przejść, masz niestabilne testy. Ponawianie to siatka bezpieczeństwa dla problemów infrastruktury, nie substytut naprawiania przyczyn źródłowych. Śledź wskaźnik ponawiania. Jeśli jest powyżej 2%, zbadaj. :::
Naprawy flaków z rzeczywistości
Refaktoryzujmy częste niestabilne wzorce.
Flak #1: Race condition przy ładowaniu danych
// ❌ NIESTABILNE: Klika, zanim dane się załadują
test('zobacz pierwsze zamówienie', async ({ page }) => {
await page.goto('/orders');
await page.getByRole('row').first().click(); // Może jeszcze nie istnieć
});
// ✅ NAPRAWIONE: Czekaj, aż dane się załadują
test('zobacz pierwsze zamówienie', async ({ page }) => {
await page.goto('/orders');
await expect(page.getByRole('row')).toHaveCount({ minimum: 1 });
await page.getByRole('row').first().click();
});
Flak #2: Przycisk włączony, ale nie gotowy
// ❌ NIESTABILNE: Przycisk włączony, ale walidacja formularza działa async
test('wyślij formularz', async ({ page }) => {
await page.goto('/contact');
await page.getByLabel('Email').fill('test@example.com');
await page.getByRole('button', { name: 'Wyślij' }).click(); // Może zawieść
});
// ✅ NAPRAWIONE: Czekaj, aż przycisk submit będzie stabilny
test('wyślij formularz', async ({ page }) => {
await page.goto('/contact');
await page.getByLabel('Email').fill('test@example.com');
// Czekaj na zakończenie walidacji (przycisk włącza się ponownie po walidacji)
const submitButton = page.getByRole('button', { name: 'Wyślij' });
await expect(submitButton).toBeEnabled();
await submitButton.click();
});
Flak #3: Nieaktualny element po aktualizacji strony
// ❌ NIESTABILNE: DOM zmienia się między utworzeniem lokatora a akcją
test('usuń zamówienie', async ({ page }) => {
await page.goto('/orders');
const deleteButton = page.getByRole('button', { name: 'Usuń' }).first();
await page.getByRole('button', { name: 'Odśwież' }).click();
await deleteButton.click(); // Element nieaktualny — strona odświeżona
});
// ✅ NAPRAWIONE: Zlokalizuj element po stabilizacji strony
test('usuń zamówienie', async ({ page }) => {
await page.goto('/orders');
await page.getByRole('button', { name: 'Odśwież' }).click();
await expect(page.getByRole('row')).toHaveCount({ minimum: 1 });
// Zlokalizuj po odświeżeniu
await page.getByRole('button', { name: 'Usuń' }).first().click();
});
Debugowanie niestabilnych testów
Gdy test zawodzi przerywalnie, użyj tych narzędzi:
1. Uruchom z trace
npx playwright test --trace on
Przechwytuj pełny trace (screenshoty, sieć, DOM) dla każdego testu. Otwórz za pomocą:
npx playwright show-report
Trace viewer pokazuje dokładnie, co się stało, klatka po klatce.
2. Uruchom w trybie headed
npx playwright test --headed --workers=1
Obserwuj przeglądarkę. Czasami zobaczenie działającego testu ujawnia problem (timing animacji, modal overlay, itp.).
3. Powtarzaj, aż zawiedzie
npx playwright test --repeat-each=10 path/to/flaky.spec.ts
Uruchamia test 10 razy. Jeśli raz zawiedzie, odtworzyłeś flaka.
4. Dodaj jawne logowanie
test('niestabilny test', async ({ page }) => {
console.log('Nawigowanie do /orders');
await page.goto('/orders');
console.log('Czekanie na wiersze');
await expect(page.getByRole('row')).toHaveCount({ minimum: 1 });
console.log('Klikanie pierwszego wiersza');
await page.getByRole('row').first().click();
});
Sprawdź logi, żeby zobaczyć, gdzie test zawiódł.
Checklista niestabilności
Użyj tego do audytu swojej suity testów:
☐ Brak waitForTimeout w żadnym teście
☐ Brak waitForLoadState('networkidle'), chyba że aplikacja naprawdę jest bezczynna
☐ Wszystkie zapytania (isVisible, textContent) zastąpione asercjami
☐ Wszystkie asercje używają asercji web-first (toBeVisible, toHaveText)
☐ Liczba ponowień śledzona w CI (powinna być < 2%)
☐ Timeout'y zwiększone tylko dla znanych-wolnych operacji
☐ Race conditions naprawione u źródła, nie maskowane ponawianiem
☐ Testy przechodzą 10 razy z rzędu lokalnie
Podsumowanie
Niestabilne testy nie są nieuniknione. Auto-waiting i asercje web-first Playwright eliminują 90% problemów czasowych, jeśli są używane prawidłowo.
Złote zasady:
- Używaj asercji web-first (
toBeVisible,toHaveText) — nigdy surowych zapytań (isVisible(),textContent()) - Nigdy nie używaj
waitForTimeoutpoza debugowaniem - Czekaj na konkretne warunki, nie arbitralne czasy
- Ponawiania są dla flaków infrastruktury, nie bugów testów
Suita testów wymagająca ponawiania, żeby przejść, to suita z bugami. Napraw przyczynę źródłową.
Zadanie na ten tydzień: Znajdź jeden niestabilny test w swojej suicie. Uruchom go 10 razy z --repeat-each=10. Przechwyć błąd z --trace on. Użyj trace viewera, żeby zidentyfikować przyczynę źródłową. Napraw to. Potem uruchom go 10 razy więcej, żeby udowodnić naprawę.
Poprzednio: Część 3 — Page Object vs fixtures
Dalej: Część 5 — Testowanie API z Playwright