Projektowanie utrzymywalnych suite'ów

Kod testowy to kod. Naucz się strukturyzować, nazywać, reviewować i posiadać suite'y testów automatycznych, aby pozostały użyteczne zamiast stawać się długiem technicznym.

Ten wpis jest częścią serii Strategia automatyzacji. Jeśli przegapiłeś poprzedni wpis, przeczytaj najpierw Część 4 — Strategie danych testowych.


Suite testowy, którego pisanie zajęło trzy miesiące, staje się nieutrzymywalny w sześć.

Testy jasne gdy pisane są niezrozumiałe sześć miesięcy później. Struktury folderów mające sens dla 20 testów zapadają się pod 200. Konwencje nazewnictwa dryfują. Nikt nie wie, kto posiada niestabilny test, który failuje od dwóch tygodni.

Kod testowy to kod. Wymaga tej samej dyscypliny co kod produkcyjny: jasna struktura, konsekwentne nazewnictwo, ownership i review. Bez tej dyscypliny twój suite testowy staje się długiem technicznym — wolny, kruchy i ignorowany.

W tym wpisie omówimy, jak projektować suite’y testowe pozostające utrzymywalne gdy rosną: struktura folderów, konwencje nazewnictwa, warstwy, ownership i praktyki code review dla testów.


Zasada #1: organizuj według feature’a, nie według typu testu

Większość zespołów zaczyna od struktury organizowanej według typu testu:

tests/
├── unit/
├── integration/
└── e2e/

To się rozpada gdy codebase rośnie. Gdy feature się zmienia, musisz przeszukać trzy foldery, aby znaleźć wszystkie powiązane testy. Gdy developer pracuje nad checkout, nie wie, czy testy istnieją w unit/, integration/ czy e2e/.

Lepiej: organizuj według feature’a

tests/
├── authentication/
│   ├── login.spec.ts          (E2E)
│   ├── password-reset.spec.ts (E2E)
│   ├── jwt-validation.test.ts (unit)
│   └── session-api.test.ts    (integration)
├── checkout/
│   ├── place-order.spec.ts
│   ├── discount-logic.test.ts
│   └── payment-gateway.test.ts
└── user-profile/
    ├── update-profile.spec.ts
    └── profile-validation.test.ts

Teraz gdy logika checkout się zmienia, wszystkie powiązane testy są w jednym miejscu. Developerzy wiedzą, gdzie szukać. Ownership testów staje się jasny.

:::tip[Lustrzane odbicie struktury codebase’a] Jeśli twoja aplikacja ma src/features/checkout/, stwórz tests/checkout/. To czyni testy wykrywalnymi i wiąże je z kodem, który weryfikują. :::

Obsługa cross-cutting concerns

Niektóre testy nie mapują się do pojedynczego feature’a (np. testy wydajnościowe, testy bezpieczeństwa, smoke testy). Używaj folderów najwyższego poziomu dla nich:

tests/
├── authentication/
├── checkout/
├── smoke/           ← Testy krytycznej ścieżki uruchamiane przy każdym wdrożeniu
├── performance/     ← Testy obciążenia/stresu
└── security/        ← Obejście auth, injection, testy CSRF

Zasada #2: używaj konsekwentnego, opisowego nazewnictwa

Nazwy testów to dokumentacja. Dobra nazwa testu mówi ci:

  • Co jest testowane
  • Jaki scenariusz lub warunek
  • Jaki jest oczekiwany rezultat

Złe nazwy testów

test('test1', async () => { /* ... */ });
test('checkout działa', async () => { /* ... */ });
test('naprawa błędu', async () => { /* ... */ });

Te nazwy nie mówią ci nic. Gdy failują, nie masz pojęcia, co się zepsuło bez czytania implementacji.

Dobre nazwy testów

test('użytkownik-gość może ukończyć checkout z płatnością kartą', async () => { /* ... */ });
test('checkout failuje gdy płatność jest odrzucona', async () => { /* ... */ });
test('kod rabatowy jest zastosowany przed obliczeniem podatku', async () => { /* ... */ });

Teraz gdy test failuje, nazwa daje ci natychmiastowy kontekst.

Konwencja nazewnictwa

Używaj tego wzorca:

[Aktor] [Akcja] [Kontekst/Warunek] → [Oczekiwany rezultat]

Przykłady:

  • admin może usunąć użytkownika ze strony zarządzania użytkownikami
  • zamówienie failuje gdy zapasy są niewystarczające
  • email resetowania hasła jest wysłany w ciągu 60 sekund

Nazewnictwo plików

Bądź konsekwentny:

  • Testy E2E: *.spec.ts (konwencja Playwright)
  • Testy unit/integration: *.test.ts
  • Helpery testowe: *.helpers.ts

To ułatwia uruchamianie konkretnych typów testów w CI:

# Uruchom tylko testy E2E
npx playwright test **/*.spec.ts

# Uruchom tylko testy unit/integration
npm test -- **/*.test.ts

Zasada #3: oddziel warstwy — nie mieszaj testów E2E i unit

Mieszanie typów testów w tym samym pliku lub folderze prowadzi do zamieszania i wolnych uruchomień testów.

Źle: mieszane warstwy

// checkout.test.ts — mieszanie unit i E2E
test('calculateDiscount zwraca poprawną wartość', () => {
  expect(calculateDiscount(100, 0.1)).toBe(10); // Test jednostkowy
});

test('użytkownik może zastosować rabat przy checkout', async ({ page }) => {
  // Test E2E
  await page.goto('/checkout');
  // ...
});

Ten plik miesza szybkie testy jednostkowe (milisekundy) z wolnymi testami E2E (sekundy). Uruchamianie pliku trwa sekundy mimo że większość mogłaby działać w milisekundach.

Dobrze: oddzielone warstwy

tests/checkout/
├── discount-logic.test.ts       ← Testy jednostkowe (szybkie)
├── checkout-api.test.ts         ← Testy integracyjne (średnie)
└── place-order.spec.ts          ← Testy E2E (wolne)

Teraz możesz uruchamiać tylko szybkie testy podczas rozwoju i rezerwować E2E dla CI.


Zasada #4: wyciągnij reużywalne helpery, nie kopiuj-wklej

Duplikacja w testach jest równie zła jak duplikacja w kodzie produkcyjnym. Gdy login flow się zmienia, nie powinieneś aktualizować 50 testów.

Źle: duplikowany setup

test('użytkownik może zobaczyć historię zamówień', async ({ page }) => {
  await page.goto('/login');
  await page.fill('[name=email]', 'test@example.com');
  await page.fill('[name=password]', 'Test1234!');
  await page.click('button[type=submit]');
  await expect(page).toHaveURL('/dashboard');
  // ...logika testu
});

test('użytkownik może zaktualizować profil', async ({ page }) => {
  await page.goto('/login');
  await page.fill('[name=email]', 'test@example.com');
  await page.fill('[name=password]', 'Test1234!');
  await page.click('button[type=submit]');
  await expect(page).toHaveURL('/dashboard');
  // ...logika testu
});

Teraz jeśli formularz logowania się zmienia (np. dodaje CAPTCHA), musisz zaktualizować oba testy.

Dobrze: wyciągnij helper

// helpers/auth.helpers.ts
export async function loginAs(page: Page, email: string, password: string) {
  await page.goto('/login');
  await page.fill('[name=email]', email);
  await page.fill('[name=password]', password);
  await page.click('button[type=submit]');
  await expect(page).toHaveURL('/dashboard');
}

// Testy
test('użytkownik może zobaczyć historię zamówień', async ({ page }) => {
  await loginAs(page, 'test@example.com', 'Test1234!');
  // ...logika testu
});

test('użytkownik może zaktualizować profil', async ({ page }) => {
  await loginAs(page, 'test@example.com', 'Test1234!');
  // ...logika testu
});

Teraz gdy login flow się zmienia, aktualizuj jeden helper, nie 50 testów.

Struktura folderów dla helperów

tests/
├── helpers/
│   ├── auth.helpers.ts
│   ├── data.helpers.ts      ← fabryki createUser, createOrder
│   └── assertions.helpers.ts
├── authentication/
│   └── login.spec.ts
└── checkout/
    └── place-order.spec.ts

Zasada #5: używaj Page Object Model (POM) dla testów E2E

Page Object Model abstrahuje selektory UI i interakcje do reużywalnych klas. Gdy UI się zmienia, aktualizujesz jeden page object, nie dziesiątki testów.

Bez POM

test('użytkownik może dodać produkt do koszyka', async ({ page }) => {
  await page.goto(`/products/${productId}`);
  await page.click('button[data-testid="add-to-cart"]');
  await expect(page.locator('.cart-count')).toHaveText('1');
});

Jeśli selektor .cart-count zmienia się na .shopping-cart-badge, ten test się łamie.

Z POM

// pages/product.page.ts
export class ProductPage {
  constructor(private page: Page) {}

  async goto(productId: string) {
    await this.page.goto(`/products/${productId}`);
  }

  async addToCart() {
    await this.page.click('button[data-testid="add-to-cart"]');
  }

  async getCartCount() {
    return this.page.locator('.cart-count');
  }
}

// Test
test('użytkownik może dodać produkt do koszyka', async ({ page }) => {
  const productPage = new ProductPage(page);
  await productPage.goto(productId);
  await productPage.addToCart();
  await expect(productPage.getCartCount()).toHaveText('1');
});

Teraz gdy .cart-count się zmienia, aktualizuj ProductPage.getCartCount() raz. Wszystkie testy go używające pozostają niezmienione.

:::info[POM jest opcjonalne dla małych suite’ów] Jeśli masz < 20 testów E2E, POM może być przesadą. Używaj helperów zamiast. Gdy osiągniesz 50+ testów dotykających tych samych stron, POM staje się wartościowe. :::


Zasada #6: przypisz ownership — nie pozwól testom stać się osierocone

Każdy suite testowy potrzebuje właściciela. Bez ownership testy degenerują:

  • Niestabilne testy są ignorowane zamiast naprawiane
  • Nikt nie refaktoryzuje kruchych selektorów
  • Nowe testy są dodawane bez review

Jak przypisać ownership

Opcja 1: Ownership zespołowy

Testy mapują się do zespołów feature’owych. Zespół checkout posiada tests/checkout/. Zespół auth posiada tests/authentication/.

Opcja 2: Nazwany właściciel w metadanych

/**
 * @owner team-checkout
 * @contact checkout-team@company.com
 */
describe('Checkout flow', () => {
  test('użytkownik-gość może ukończyć checkout', async () => { /* ... */ });
});

Gdy test failuje, CI może tagować posiadający zespół automatycznie.

Opcja 3: Plik CODEOWNERS

GitHub/GitLab wspierają CODEOWNERS dla automatycznego przypisywania review PR:

# .github/CODEOWNERS
tests/checkout/**       @checkout-team
tests/authentication/** @auth-team

Teraz gdy ktoś modyfikuje testy w tests/checkout/, zespół checkout jest automatycznie requestowany do review.


Zasada #7: reviewuj kod testowy jak kod produkcyjny

Kod testowy to kod. Powinien przechodzić przez ten sam proces review co kod produkcyjny.

Checklista code review dla testów

Gdy reviewujesz PR ze zmianami testów, sprawdź:

  1. Nazwy testów są opisowe — Czy mogę powiedzieć, co jest testowane bez czytania implementacji?
  2. Brak hardcodowanych danych — Czy używane są fabryki lub helpery dla danych testowych?
  3. Brak duplikacji — Jeśli logika setupu się powtarza, wyciągnij ją.
  4. Właściwa warstwa — Czy ten test jest na właściwym poziomie (unit/integration/E2E)?
  5. Stabilne selektory — Czy selektory używają data-testid, roles lub labels (nie kruche CSS)?
  6. Brak hardcodowanych waitów — Czy wywołania waitForTimeout są zastąpione waitami opartymi na warunkach?
  7. Sprzątanie uwzględnione — Czy test sprząta dane, które tworzy?
  8. Test faktycznie coś weryfikuje — Czy test ma asercje? Czy failuje gdy feature się łamie?

:::warning[Nie merguj failujących testów] Jeśli test jest niestabilny lub zepsuty, napraw go przed mergowaniem. Nie merguj z komentarzami “TODO: napraw niestabilny test”. Niestabilne testy erodują zaufanie natychmiast. :::


Zasada #8: śledź metryki zdrowia testów

Nie możesz zarządzać tym, czego nie mierzysz. Śledź kluczowe metryki, aby złapać degenerację, zanim będzie za późno.

Kluczowe metryki

MetrykaCelCo mierzy
Wskaźnik niestabilności< 2%% uruchomień testów z co najmniej jednym niestabilnym testem
Średni czas wykonania< 10 min (E2E), < 2 min (unit+integration)Szybkość feedbacku
Pokrycie testowe> 80% na krytycznych ścieżkachPokrycie ryzyka
Czas badania failur< 10 min na failuręJak łatwo debugować faile

Jak śledzić

  • Wskaźnik niestabilności: Większość systemów CI może to śledzić. Playwright ma wbudowaną detekcję niestabilnych testów.
  • Runtime: Czas trwania joba CI. Grafuj go w czasie, aby wykryć spowolnienie.
  • Pokrycie: Używaj narzędzi pokrycia (Istanbul, Coverlet), ale mierz pokrycie ryzyka (czy krytyczne ścieżki są testowane?), nie pokrycie linii.
  • Czas badania: Manualna obserwacja. Jeśli testy często failują z niejasnych powodów, struktura wymaga poprawy.

Rekomendowana struktura folderów

Składając to wszystko razem:

tests/
├── helpers/
│   ├── auth.helpers.ts          ← Helpery login, logout, session
│   ├── data.helpers.ts          ← Fabryki dla users, orders, products
│   └── assertions.helpers.ts    ← Niestandardowe asercje
├── pages/                       ← Page Object Model (tylko E2E)
│   ├── login.page.ts
│   ├── product.page.ts
│   └── checkout.page.ts
├── authentication/
│   ├── login.spec.ts            ← E2E
│   ├── password-reset.spec.ts
│   ├── jwt-validation.test.ts   ← Unit
│   └── session-api.test.ts      ← Integration
├── checkout/
│   ├── place-order.spec.ts
│   ├── discount-logic.test.ts
│   └── payment-gateway.test.ts
├── smoke/                       ← Testy krytycznej ścieżki
│   └── critical-paths.spec.ts
└── playwright.config.ts         ← Konfiguracja testów

Anty-wzorce do unikania

Anty-wzorzec #1: Testy testujące framework testowy

// Źle: testowanie Playwright, nie twojej aplikacji
test('strona się ładuje', async ({ page }) => {
  await page.goto('/');
  expect(page).toBeTruthy();
});

To testuje, że Playwright może załadować stronę, nie że twoja aplikacja działa.

Anty-wzorzec #2: Testy bez asercji

// Źle: brak walidacji
test('użytkownik może przesłać formularz', async ({ page }) => {
  await page.goto('/contact');
  await page.fill('[name=email]', 'test@example.com');
  await page.click('button[type=submit]');
  // Brak asercji — czy zadziałało?
});

Testy bez asercji to szum. Zawsze waliduj oczekiwany rezultat.

Anty-wzorzec #3: Bóg-testy (jeden test robi wszystko)

// Źle: 200-liniowy test pokrywający login, aktualizację profilu, checkout, logout
test('kompletne user journey', async ({ page }) => {
  // 200 linii kodu testowego
});

Gdy ten test failuje, nie masz pojęcia, która część się zepsuła. Podziel na skupione testy.

Anty-wzorzec #4: Zakomentowane testy

// test('admin może usunąć użytkownika', async () => {
//   // TODO: napraw niestabilny test
// });

Zakomentowane testy gniją. Albo je napraw, albo usuń. Nie pozwól im tkwić.


Podsumowanie

Suite’y testowe stają się nieutrzymywalne tak samo jak codebase’y: słaba struktura, niejasne nazewnictwo, brak ownership i brak dyscypliny review.

Naprawą jest traktowanie kodu testowego z takim samym szacunkiem jak kodu produkcyjnego:

  1. Organizuj według feature’a, nie typu testu
  2. Używaj opisowego nazewnictwa — nazwy testów to dokumentacja
  3. Oddziel warstwy — nie mieszaj testów unit i E2E
  4. Wyciągaj reużywalne helpery — brak duplikacji
  5. Używaj Page Object Model dla testów E2E (gdy osiągniesz skalę)
  6. Przypisz ownership — każdy test potrzebuje opiekuna
  7. Reviewuj kod testowy — stosuj te same standardy co kod produkcyjny
  8. Śledź metryki zdrowia — wskaźnik niestabilności, runtime, pokrycie

Zastosuj te zasady od pierwszego dnia, a twój suite testowy pozostanie użyteczny zamiast stawać się długiem technicznym.

W następnym wpisie zajmiemy się niestabilnymi testami dogłębnie — taksonomia źródeł problemu, systematyczne naprawy i jak zbudować proces zespołowy dla zapobiegania i triażu niestabilności.


Zadanie na ten tydzień: Wybierz jeden folder w swoim suite testowym ze słabą strukturą lub nazewnictwem. Refaktoryzuj go: zmień nazwy plików opisowo, wyciągnij duplikowany setup do helperów i dodaj wpis CODEOWNERS dla ownership. Uruchom testy, aby potwierdzić, że nic się nie łamie. Commituj refaktor jako samodzielną zmianę.