Przejdź do treści
    Jak pisać testy, które zrozumieją inni

    Jak pisać testy, które zrozumieją inni

    Marzec 12, 2026
    10 min czytania

    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.

    TYPESCRIPT
    // Złe nazwy - mówią co, ale nie warunki ani oczekiwanie
    test('login test', async () => { ... });
    test('form', async () => { ... });
    test('booking', async () => { ... });
    // Dobre nazwy - opisują scenariusz, warunek i oczekiwanie
    test('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.

    TYPESCRIPT
    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:

    TYPESCRIPT
    test('formularz - brakujący email - błąd walidacji', async ({ page }) => {
    // Arrange
    await 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ń email
    await page.getByLabel('Imię i nazwisko').fill('Jan Kowalski');
    await page.getByRole('button', { name: 'Zarezerwuj' }).click();
    // Assert
    await 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ęść:

    TYPESCRIPT
    // Bez test.step() - długi test bez struktury
    test('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('Adres e-mail').fill('[email protected]');
    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('Adres e-mail').fill('[email protected]');
    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

    TYPESCRIPT
    // 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 intencja
    const 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

    TYPESCRIPT
    // 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 asercja
    await expect(page.getByRole('listitem').first()).toContainText('Sci-Fi');

    Zagnieżdżone opisy bez wartości

    TYPESCRIPT
    // Złe - zbyt głębokie zagnieżdżenie bez sensu
    describe('strona', () => {
    describe('formularz', () => {
    describe('walidacja', () => {
    describe('email', () => {
    test('błąd', async () => { ... });
    });
    });
    });
    });
    // Dobre - dwupoziomowa hierarchia wystarczy
    describe('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

    TYPESCRIPT
    // Złe - test 2 zależy od stanu po teście 1
    let 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 undefined
    await 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?

    TYPESCRIPT
    // Złe - jeden test sprawdza wszystko
    test('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ów
    test('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.

    TYPESCRIPT
    // Złe - logika warunkowa ukrywa dwa scenariusze
    test('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 liniowy
    test('rezerwacja - miejsce dostępne - można wybrać', async ({ page }) => {
    // Arrange: setup z dostępnym miejscem
    await page.goto('https://kinotest.howtest.it');
    const seat = page.getByTestId('seat-D7');
    // Act
    await seat.click();
    // Assert
    await expect(page.getByText('Miejsce wybrane')).toBeVisible();
    });
    test('rezerwacja - miejsce zajęte - wyświetla komunikat', async ({ page }) => {
    // Arrange: setup z zajętym miejscem
    await page.goto('https://kinotest.howtest.it/sold-out-screening');
    const seat = page.getByTestId('seat-D7');
    // Act
    await seat.click();
    // Assert
    await expect(page.getByText('Miejsce niedostępne')).toBeVisible();
    });

    To samo dotyczy try/catch:

    TYPESCRIPT
    // Złe - try/catch ukrywa prawdziwy błąd
    test('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łąd
    test('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.

    TYPESCRIPT
    // Złe - selektor CSS mówi o implementacji, nie o interfejsie
    await page.locator('.btn-primary.mt-4').click();
    await page.locator('#email-input').fill('[email protected]');
    await page.locator('div.alert.alert-danger > span').isVisible();
    // Dobre - lokator opisuje co użytkownik widzi
    await page.getByRole('button', { name: 'Zarezerwuj' }).click();
    await page.getByLabel('Adres e-mail').fill('[email protected]');
    await expect(page.getByText('E-mail jest wymagany')).toBeVisible();

    Hierarchia czytelności lokatorów w Playwright:

    1. getByRole - najczytelniejszy, opisuje rolę elementu (button, heading, link)
    2. getByLabel - świetny do formularzy, czytasz jak etykietę pola
    3. getByText - szukasz po tekście widocznym dla użytkownika
    4. getByTestId - gdy nie ma lepszej opcji, ale przynajmniej nadaj mu sensowną nazwę (booking-number, nie div-42)
    5. 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.

    TYPESCRIPT
    // Złe - dane nic nie mówią
    await page.getByLabel('Imię i nazwisko').fill('aaa bbb');
    await page.getByLabel('Adres e-mail').fill('[email protected]');
    await page.getByLabel('Telefon').fill('123');
    // Dobre - dane opowiadają historię scenariusza
    await page.getByLabel('Imię i nazwisko').fill('Jan Kowalski');
    await page.getByLabel('Adres e-mail').fill('[email protected]');
    await page.getByLabel('Telefon').fill('+48 600 100 200');

    A kiedy testujesz walidację - dane powinny jasno pokazywać co jest nie tak:

    TYPESCRIPT
    // 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 tak
    test('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.

    Checklist czytelnego testu

    Naming

    Nazwa testu opisuje scenariusz, warunek i oczekiwany wynik

    Struktura

    Test ma wyraźne sekcje Arrange / Act / Assert
    Długie testy e2e mają kroki opisane przez test.step()
    Brak if/else i try/catch - test jest liniowy od góry do dołu

    Czytelność

    Brak magicznych liczb - stałe mają nazwy opisujące intencję
    Lokatory opisują co użytkownik widzi, nie implementację (getByRole > CSS)
    Dane testowe mają intencję - widać sens scenariusza bez czytania nazwy testu

    Asercje

    Asercje są proste i bezpośrednie - brak sprytnych transformacji

    Zakres

    Jeden test = jeden scenariusz

    Izolacja

    Brak współdzielonego stanu między testami w zmiennych globalnych

    Podobał Ci się wpis?

    Zapisz się do newslettera, a dam Ci znać o nowych wpisach i narzędziach QA.

    Zapisz się do newslettera