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żytkownikamizamówienie failuje gdy zapasy są niewystarczająceemail 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ź:
- Nazwy testów są opisowe — Czy mogę powiedzieć, co jest testowane bez czytania implementacji?
- Brak hardcodowanych danych — Czy używane są fabryki lub helpery dla danych testowych?
- Brak duplikacji — Jeśli logika setupu się powtarza, wyciągnij ją.
- Właściwa warstwa — Czy ten test jest na właściwym poziomie (unit/integration/E2E)?
- Stabilne selektory — Czy selektory używają
data-testid, roles lub labels (nie kruche CSS)? - Brak hardcodowanych waitów — Czy wywołania
waitForTimeoutsą zastąpione waitami opartymi na warunkach? - Sprzątanie uwzględnione — Czy test sprząta dane, które tworzy?
- 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
| Metryka | Cel | Co 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żkach | Pokrycie 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:
- Organizuj według feature’a, nie typu testu
- Używaj opisowego nazewnictwa — nazwy testów to dokumentacja
- Oddziel warstwy — nie mieszaj testów unit i E2E
- Wyciągaj reużywalne helpery — brak duplikacji
- Używaj Page Object Model dla testów E2E (gdy osiągniesz skalę)
- Przypisz ownership — każdy test potrzebuje opiekuna
- Reviewuj kod testowy — stosuj te same standardy co kod produkcyjny
- Ś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ę.