Jak pisać testy, które zrozumieją inni
Jest taka pokusa w programowaniu - napisać coś sprytnego. Jeden liner zamiast pięciu linii. Generyczna funkcja zamiast trzech specyficznych. Elegancja za wszelką cenę.
W testach to podejście często działa przeciwko tobie.
Wyobraź sobie sytuację: dołączasz do nowego projektu. Jest kilkaset testów e2e, CI miga na czerwono i Twoim zadaniem jest ogarnięcie co się dzieje. Otwierasz plik, patrzysz na kod - i nic nie rozumiesz. Funkcje bez kontekstu, zmienne jak x i data, nazwy testów w stylu "test1" i "form check". Godzina szukania, zanim wiesz co ten test w ogóle sprawdza.
Test automatyczny ma dwa zadania: sprawdzać czy aplikacja działa, i dokumentować jak aplikacja powinna działać. Kiedy test przestaje przechodzić, ktoś (często nowa osoba w projekcie, albo Ty za trzy miesiące) musi szybko zrozumieć co jest nie tak i dlaczego. Sprytny, zwięzły test wymaga więcej czasu na zrozumienie. Czytelny, może rozwleklejszy test - mówi sam za siebie.
Naming conventions - test jako zdanie
Nazwa testu to pierwsza dokumentacja. Kiedy raport CI pokazuje czerwony wynik, nazwa testu jest jedyną wskazówką bez otwierania kodu.
// Złe nazwy - mówią co, ale nie warunki ani oczekiwanietest('login test', async () => { ... });test('form', async () => { ... });test('booking', async () => { ... });// Dobre nazwy - opisują scenariusz, warunek i oczekiwanietest('login - poprawne dane - użytkownik trafia na dashboard', async () => { ... });test('formularz rezerwacji - brakujący email - wyświetla komunikat błędu', async () => { ... });test('rezerwacja biletu - happy path - numer rezerwacji z prefiksem KN-', async () => { ... });
Wzorzec, który lubię: [co testuję] - [warunek] - [oczekiwany wynik]. Trzy elementy, myślnik jako separator. Czytasz nazwę testu i wiesz dokładnie co sprawdza, bez otwierania kodu.
Struktura describe - grupowanie kontekstu
describe tworzy kontener dla powiązanych testów. Dobre grupowanie ułatwia nawigację w dużych suitach.
describe('KinoTest - flow rezerwacji biletu', () => {describe('wybór seansu', () => {test('filtr kategorii - wybór Sci-Fi - wyświetla tylko filmy Sci-Fi', async () => { ... });test('brak dostępnych seansów - wyświetla komunikat', async () => { ... });});describe('formularz danych', () => {test('poprawne dane - przejście do potwierdzenia', async () => { ... });test('niepoprawny email - walidacja blokuje submit', async () => { ... });test('brakujący telefon - pole oznaczone jako wymagane', async () => { ... });});describe('strona potwierdzenia', () => {test('numer rezerwacji - format KN-XXXXXXXX', async () => { ... });test('email potwierdzający - wysłany na podany adres', async () => { ... });});});
W raporcie CI widać: KinoTest - flow rezerwacji biletu > formularz danych > niepoprawny email - walidacja blokuje submit. Nawet bez otwierania pliku wiesz dokładnie co się posypało.
Wzorzec AAA - porządek w strukturze testu
Każdy test powinien mieć trzy wyraźne sekcje. Komentarze // Arrange, // Act, // Assert możesz wstawiać jawnie, szczególnie w dłuższych testach:
test('formularz - brakujący email - błąd walidacji', async ({ page }) => {// Arrangeawait page.goto('https://kinotest.howtest.it');const kino = new KinoTestPage(page);await kino.selectMovie('Dune: Part Three');await kino.selectShowtime('18:30 - sala 2');await kino.selectSeat('D7');// Act - wypełnij tylko imię, pomiń emailawait page.getByLabel('Imię i nazwisko').fill('Jan Kowalski');await page.getByRole('button', { name: 'Zarezerwuj' }).click();// Assertawait expect(page.getByText('E-mail jest wymagany')).toBeVisible();await expect(page.getByRole('button', { name: 'Zarezerwuj' })).toBeEnabled();});
Kiedy test wykryje regresję, trzy sekcje mówią ci gdzie patrzeć: czy problem jest w setupie (Arrange), w akcji (Act) czy w tym co sprawdzamy (Assert).
test.step() - czytelność w długich testach
Wzorzec AAA działa świetnie w krótkich testach. Ale co z testami e2e, które mają 5, 10, 15 kroków? Cały flow rezerwacji, od wyboru seansu po stronę potwierdzenia?
Tu wchodzi test.step(). Pozwala podzielić długi test na nazwane etapy - i od razu widać co robi każda część:
// Bez test.step() - długi test bez strukturytest('pełny flow rezerwacji biletu', async ({ page }) => {await page.goto('https://kinotest.howtest.it');await page.getByRole('link', { name: 'Dune: Part Three' }).click();await page.getByText('18:30 - sala 2').click();await page.getByTestId('seat-D7').click();await page.getByRole('button', { name: 'Dalej' }).click();await page.getByLabel('Imię i nazwisko').fill('Jan Kowalski');await page.getByLabel('Telefon').fill('+48 600 100 200');await page.getByRole('button', { name: 'Zarezerwuj' }).click();await expect(page.getByTestId('booking-number')).toContainText('KN-');});// Z test.step() - od razu widać strukturętest('pełny flow rezerwacji biletu', async ({ page }) => {await test.step('Wybór seansu', async () => {await page.goto('https://kinotest.howtest.it');await page.getByRole('link', { name: 'Dune: Part Three' }).click();await page.getByText('18:30 - sala 2').click();await page.getByTestId('seat-D7').click();await page.getByRole('button', { name: 'Dalej' }).click();});await test.step('Wypełnienie danych', async () => {await page.getByLabel('Imię i nazwisko').fill('Jan Kowalski');await page.getByLabel('Telefon').fill('+48 600 100 200');await page.getByRole('button', { name: 'Zarezerwuj' }).click();});await test.step('Weryfikacja potwierdzenia', async () => {await expect(page.getByTestId('booking-number')).toContainText('KN-');});});
Obie wersje robią to samo. Ale kiedy drugi test przestanie przechodzić, raport HTML Playwright pokaże dokładnie który krok nie zadziałał: "Wypełnienie danych" albo "Weryfikacja potwierdzenia". Bez test.step() masz tylko numer linii i musisz sam zgadywać.
To samo działa w Allure Report - każdy krok to osobny wpis w timeline, z czasem wykonania i statusem. Nowa osoba w projekcie otwiera raport i bez czytania kodu rozumie co test robi.
Praktyczna zasada: jeśli test ma więcej niż 8-10 linii w sekcji Act, rozważ podział na kroki. Nie każdy długi test musi być rozbity na osobne testy - czasem chcesz przetestować cały przepływ jako całość. Właśnie wtedy test.step() robi różnicę.
Antywzorce - co niszczy czytelność
Magiczne liczby i wartości
// Złe - co znaczy '3'? Co to 'KN-20260302'?await expect(page.getByRole('listitem')).toHaveCount(3);await expect(page.getByTestId('booking-number')).toHaveText('KN-20260302');// Dobre - czytelna intencjaconst EXPECTED_MOVIES_ON_HOMEPAGE = 3;await expect(page.getByRole('listitem')).toHaveCount(EXPECTED_MOVIES_ON_HOMEPAGE);await expect(page.getByTestId('booking-number')).toContainText('KN-');
Zbyt sprytne asercje
// Złe - co tu się sprawdza? Trzeba analizowaćconst texts = await page.getByRole('listitem').allTextContents();expect(texts.filter(t => t.includes('Sci-Fi')).length).toBeGreaterThan(0);// Dobre - bezpośrednia asercjaawait expect(page.getByRole('listitem').first()).toContainText('Sci-Fi');
Zagnieżdżone opisy bez wartości
// Złe - zbyt głębokie zagnieżdżenie bez sensudescribe('strona', () => {describe('formularz', () => {describe('walidacja', () => {describe('email', () => {test('błąd', async () => { ... });});});});});// Dobre - dwupoziomowa hierarchia wystarczydescribe('Formularz rezerwacji', () => {test('walidacja email - brakująca domena - komunikat błędu', async () => { ... });test('walidacja email - poprawny format - brak błędu', async () => { ... });});
Współdzielony stan między testami
// Złe - test 2 zależy od stanu po teście 1let bookingNumber: string;test('test 1 - zapisz numer rezerwacji', async ({ page }) => {// ... robi rezerwacjębookingNumber = await page.getByTestId('booking-number').innerText();});test('test 2 - sprawdź potwierdzenie email', async ({ page }) => {// Jeśli test 1 nie przeszedł - bookingNumber jest undefinedawait page.goto('/confirmation/' + bookingNumber);});
Każdy test musi być samodzielny. Jeśli test 1 nie przejdzie, test 2 nie powinien na tym ucierpieć.
Jeden scenariusz na test
Pokusa: skoro już doszedłem do strony potwierdzenia, sprawdzę przy okazji email, numer rezerwacji, tekst na stronie, i czy link powrotny działa. W jednym teście.
Problem: kiedy test failuje, nie wiadomo która z tych czterech rzeczy się posypała. Raport CI mówi "rezerwacja - coś nie gra", ale co konkretnie?
// Złe - jeden test sprawdza wszystkotest('potwierdzenie rezerwacji', async ({ page }) => {// ... (cały flow)await expect(page.getByRole('heading', { name: 'Rezerwacja potwierdzona' })).toBeVisible();await expect(page.getByTestId('booking-number')).toContainText('KN-');await expect(page.getByText('Email potwierdzający został wysłany')).toBeVisible();await expect(page.getByRole('link', { name: 'Wróć na stronę główną' })).toBeVisible();});// Lepiej - osobne testy dla osobnych aspektówtest('potwierdzenie - nagłówek jest widoczny', async ({ page }) => { ... });test('potwierdzenie - numer rezerwacji ma format KN-', async ({ page }) => { ... });test('potwierdzenie - informacja o emailu jest widoczna', async ({ page }) => { ... });
Tak, to więcej kodu. Ale raport CI pokazuje dokładnie co się zepsuło. I każdy test jest prostszy do zrozumienia i utrzymania.
Bez if/else i try/catch w testach
Kiedy w teście pojawia się if, to znak że masz dwa scenariusze w jednym teście. A try/catch ukrywa co test właściwie sprawdza - framework testowy sam obsługuje wyjątki i pokaże Ci czytelny komunikat błędu.
// Złe - logika warunkowa ukrywa dwa scenariuszetest('rezerwacja - obsługa dostępności miejsc', async ({ page }) => {await page.goto('https://kinotest.howtest.it');const seat = page.getByTestId('seat-D7');if (await seat.isEnabled()) {await seat.click();await expect(page.getByText('Miejsce wybrane')).toBeVisible();} else {await expect(page.getByText('Miejsce niedostępne')).toBeVisible();}});// Dobre - dwa osobne testy, każdy liniowytest('rezerwacja - miejsce dostępne - można wybrać', async ({ page }) => {// Arrange: setup z dostępnym miejscemawait page.goto('https://kinotest.howtest.it');const seat = page.getByTestId('seat-D7');// Actawait seat.click();// Assertawait expect(page.getByText('Miejsce wybrane')).toBeVisible();});test('rezerwacja - miejsce zajęte - wyświetla komunikat', async ({ page }) => {// Arrange: setup z zajętym miejscemawait page.goto('https://kinotest.howtest.it/sold-out-screening');const seat = page.getByTestId('seat-D7');// Actawait seat.click();// Assertawait expect(page.getByText('Miejsce niedostępne')).toBeVisible();});
To samo dotyczy try/catch:
// Złe - try/catch ukrywa prawdziwy błądtest('rezerwacja - numer potwierdzenia', async ({ page }) => {try {await page.goto('https://kinotest.howtest.it/confirmation/123');await expect(page.getByTestId('booking-number')).toContainText('KN-');} catch (error) {console.log('Strona nie załadowała się');}});// Dobre - pozwól frameworkowi obsłużyć błądtest('rezerwacja - numer potwierdzenia ma format KN-', async ({ page }) => {await page.goto('https://kinotest.howtest.it/confirmation/123');await expect(page.getByTestId('booking-number')).toContainText('KN-');});
Czytelny test czytasz od góry do dołu. Zero rozgałęzień, zero przechwytywania wyjątków. Jeśli test ma if - rozbij go na dwa.
Lokatory, które czyta się jak zdanie
Artykuł pełen jest przykładów z getByRole i getByLabel. To nie przypadek - dobry lokator czyta się jak opis tego, co użytkownik widzi na ekranie.
// Złe - selektor CSS mówi o implementacji, nie o interfejsieawait page.locator('.btn-primary.mt-4').click();await page.locator('div.alert.alert-danger > span').isVisible();// Dobre - lokator opisuje co użytkownik widziawait page.getByRole('button', { name: 'Zarezerwuj' }).click();await expect(page.getByText('E-mail jest wymagany')).toBeVisible();
Hierarchia czytelności lokatorów w Playwright:
getByRole- najczytelniejszy, opisuje rolę elementu (button,heading,link)getByLabel- świetny do formularzy, czytasz jak etykietę polagetByText- szukasz po tekście widocznym dla użytkownikagetByTestId- gdy nie ma lepszej opcji, ale przynajmniej nadaj mu sensowną nazwę (booking-number, niediv-42)- CSS / XPath - ostateczność, łamie się przy każdej zmianie layoutu
Kiedy ktoś czyta test z dobrymi lokatorami, nie musi otwierać aplikacji żeby zrozumieć co test robi.
Dane testowe z intencją
Dane, które wpisujesz w test, to też dokumentacja. Czytelnik powinien z nich wyczytać sens scenariusza.
// Złe - dane nic nie mówiąawait page.getByLabel('Imię i nazwisko').fill('aaa bbb');await page.getByLabel('Telefon').fill('123');// Dobre - dane opowiadają historię scenariuszaawait page.getByLabel('Imię i nazwisko').fill('Jan Kowalski');await page.getByLabel('Telefon').fill('+48 600 100 200');
A kiedy testujesz walidację - dane powinny jasno pokazywać co jest nie tak:
// Złe - czemu ten email jest zły? Trzeba się domyślaćtest('walidacja email - błędny format', async ({ page }) => {await page.getByLabel('Adres e-mail').fill('x');// ...});// Dobre - od razu widać co jest nie taktest('walidacja email - brakująca domena - komunikat błędu', async ({ page }) => {await page.getByLabel('Adres e-mail').fill('jan.kowalski@');await page.getByRole('button', { name: 'Zarezerwuj' }).click();await expect(page.getByText('Podaj poprawny adres e-mail')).toBeVisible();});
Dobra zasada: gdybyś usunął nazwę testu, czy z samego kodu widać co sprawdzasz? Jeśli tak - dane testowe robią swoją robotę.
Czytelność to inwestycja
Wszystkie zasady z tego artykułu sprowadzają się do jednej myśli: test piszesz raz, ale czytany jest dziesiątki razy. Przez Ciebie za trzy miesiące, przez kolegę z zespołu który nie zna kontekstu, przez nową osobę w projekcie która właśnie siada do czerwonego CI i próbuje ogarnąć co się dzieje.
Sprytny test imponuje przez chwilę. Czytelny test oszczędza godziny - przy każdym code review, przy każdym czerwonym CI, przy każdym onboardingu nowej osoby.
Nie musisz wdrażać wszystkiego naraz. Zacznij od jednej rzeczy: nazwy testów, albo usunięcia magicznych liczb, albo rozbicia jednego dużego testu na mniejsze. Efekt zobaczysz przy następnym debugowaniu.
