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
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');
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 });
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.
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