Dlaczego testy automatyczne zawodzą — i jak temu zapobiec

Niestabilne testy, złe selektory, zależności środowiskowe, brak strategii — poznaj prawdziwe przyczyny niepowodzeń automatyzacji i konkretne rozwiązania.

“Po prostu uruchom pipeline jeszcze raz.”

Jeśli to zdanie jest znajome w Twoim zespole, masz problem z automatyzacją testów. Konkretnie — masz testy, które kłamią. Testy, które nie przechodzą z powodów niezwiązanych z tym, czy Twój kod działa. Nazywa się je flaky tests i są bardziej niebezpieczne niż brak jakichkolwiek testów.

Gdy zestaw testów staje się zawodny, inżynierowie przestają mu ufać. Gdy przestają mu ufać, przestają reagować na błędy. Gdy przestają reagować na błędy, prawdziwe bugi przechodzą dalej. Zestaw testów staje się biurokratycznym checkboxem: czymś, co uruchamiasz, bo CI tego wymaga, a nie dlatego że wykrywa prawdziwe problemy.

W tym artykule omówię pięć najczęstszych przyczyn niepowodzeń automatyzacji i dam Ci konkretne, praktyczne rozwiązania dla każdej z nich.

Dlaczego niestabilne testy są gorsze niż brak testów

Przechodząca suita testów zawierająca flaky tests daje Ci fałszywe poczucie bezpieczeństwa. Widzisz zielony kolor i myślisz, że wszystko jest w porządku — ale nie wiesz, czy zielony oznacza “kod działa” czy “niestabilne testy akurat przeszły tym razem.”

Obowiązuje prosta zasada: test, który niezawodnie nie mówi Ci, kiedy coś jest zepsute, jest gorszy niż brak testu. Pochłania czas utrzymania, spowalnia CI i niszczy zaufanie do całej suity.

Celem automatyzacji testów jest szybki, niezawodny feedback. Każdy flaky test jest atakiem na ten cel.

Przyczyna #1 — Problemy z czasem i operacjami asynchronicznymi

To zdecydowanie najczęstsza przyczyna niestabilnych testów E2E i integracyjnych.

Problem

Testy używające hardkodowanych oczekiwań:

// Źle: kruche i zależne od platformy
await page.waitForTimeout(2000);
await expect(page.getByText('Zamówienie potwierdzone')).toBeVisible();

Jeśli strona ładuje się w 1,8 sekundy lokalnie i w 2,3 sekundy na wolnym runnerze GitHub Actions, ten test będzie niestabilny.

Rozwiązanie

Zawsze czekaj na konkretny warunek, nigdy na stały czas:

// Dobrze: czeka, aż warunek będzie spełniony, do limitu czasowego
await expect(page.getByText('Zamówienie potwierdzone')).toBeVisible();

// Dobrze: czekaj na brak aktywności sieciowej przy ładowaniu danych
await page.waitForLoadState('networkidle');

// Dobrze: czekaj na konkretny stan elementu
await page.getByRole('button', { name: 'Wyślij' }).waitFor({ state: 'enabled' });

Wbudowane automatyczne oczekiwanie Playwright obsługuje większość przypadków automatycznie — ale tylko jeśli używasz właściwych asercji. expect(locator).toBeVisible() ponawia próby automatycznie. locator.isVisible() — nie.

Zasada: Jeśli w Twoich testach jest waitForTimeout, traktuj to jako błąd do naprawienia.

Przyczyna #2 — Złe selektory

Problem

Selektory ściśle powiązane z detalami implementacji psują się za każdym razem, gdy programista refaktoryzuje UI — nawet jeśli funkcjonalność pozostaje niezmieniona.

// Źle: kruche selektory
await page.click('div > div:nth-child(3) > button');
await page.click('#app > main > form > div.btn-container > button[type="submit"]');
await page.click('.MuiButtonBase-root-5');  // wygenerowana nazwa klasy

Te selektory psują się przy:

  • Jakiejkolwiek zmianie struktury DOM
  • Zmianie nazw klas CSS
  • Aktualizacjach biblioteki komponentów
  • CSS-in-JS generującym nowe nazwy klas

Rozwiązanie — zalecana kolejność priorytetów w Playwright

// 1. Oparty na roli (najlepszy) — odzwierciedla jak użytkownicy i screen readery widzą stronę
await page.getByRole('button', { name: 'Złóż zamówienie' }).click();
await page.getByRole('textbox', { name: 'Adres email' }).fill('test@example.com');

// 2. Oparty na etykiecie — dla pól formularza
await page.getByLabel('Hasło').fill('s3cr3t');

// 3. Oparty na placeholderze
await page.getByPlaceholder('Szukaj artykułów...').fill('playwright');

// 4. Oparty na tekście — dla linków i treści
await page.getByText('Zobacz wszystkie zamówienia').click();

// 5. Test ID — jawny znacznik, stabilny przy refaktorach
await page.getByTestId('submit-order-btn').click();

Dodawaj atrybuty data-testid do elementów wymagających stabilnych selektorów. Są niewidoczne dla użytkowników, ignorowane przez style i przeżywają refaktory:

<button data-testid="submit-order-btn" type="submit">Złóż zamówienie</button>

Zasada: Jeśli selektor zawiera liczby, .nth-child, lub wygenerowane nazwy klas — to jest błąd.

Przyczyna #3 — Brak izolacji danych testowych

Problem

Testy zależące od danych istniejących w środowisku — zamiast danych, które same tworzą — są kruche na wiele sposobów:

// Źle: zakłada istnienie użytkownika w bazie danych
await loginAs('testuser@example.com', 'password');
await expect(page.getByText('Witaj, Testowy Użytkownik')).toBeVisible();

Co się stanie, gdy:

  • Ktoś usunie tego użytkownika podczas debugowania?
  • Testy uruchomią się równolegle i dwa testy zmodyfikują tego samego użytkownika?
  • Baza testowa zostanie wyczyszczona i odtworzona inaczej?

Rozwiązanie

Każdy test tworzy i posiada własne dane:

// Dobrze: test tworzy potrzebnego użytkownika przez API
test.beforeEach(async ({ request }) => {
  const response = await request.post('/api/users', {
    data: { email: 'test-unique@example.com', password: 'Test1234!' }
  });
  userId = (await response.json()).id;
});

test.afterEach(async ({ request }) => {
  await request.delete(`/api/users/${userId}`);
});

Dla testów z intensywnym użyciem bazy — TestContainers:

// Test integracyjny .NET — uruchamia prawdziwą instancję PostgreSQL dla każdego testu
await using var container = new PostgreSqlBuilder()
    .WithImage("postgres:16")
    .Build();
await container.StartAsync();

var connectionString = container.GetConnectionString();
// uruchom testy z pełną izolacją

Zasada: Żaden test nie powinien zależeć od danych, których nie stworzył. Żaden test nie powinien zostawiać danych po sobie.

Przyczyna #4 — Zależność od środowiska

Problem

“U mnie działa” to klasyczny objaw testów zależnych od środowiska.

Typowe przyczyny:

  • Testy używają hardkodowanej ścieżki (C:\temp\upload\), która nie istnieje w CI
  • Testy zależą od lokalnej strefy czasowej (DateTime.Now daje różne wyniki)
  • Testy używają lokalnego serwisu na localhost:5432 (niedostępnego w CI)
  • Testy zakładają konkretne ustawienia lokalne dla formatowania liczb/dat

Rozwiązanie

Skonteneryzuj wszystko:

# docker-compose.test.yml — CI dostaje to samo środowisko co lokalne
services:
  app:
    build: .
    environment:
      - TZ=UTC
      - LANG=pl_PL.UTF-8
  db:
    image: postgres:16
    environment:
      - POSTGRES_PASSWORD=test
  playwright:
    image: mcr.microsoft.com/playwright:v1.45.0-jammy
    depends_on: [app]

Zablokuj zachowania wrażliwe na czas:

// W testach — wstrzyknij stały zegar zamiast DateTime.Now
var fixedClock = new FixedClock(new DateTime(2025, 1, 1, 12, 0, 0, DateTimeKind.Utc));
var service = new OrderService(fixedClock);

Zasada: Testy powinny przechodzić w każdym środowisku, gdzie działa kontener. Jeśli test przechodzi tylko na Twojej maszynie — jest zepsuty.

Przyczyna #5 — Brak strategii automatyzacji

Problem

Najbardziej podstępny tryb awarii nie jest techniczny — jest strategiczny. Zespoły piszą automatyczne testy bez strategii, co zazwyczaj oznacza:

  • Automatyzowanie wszystkiego: Każdy manualny przypadek testowy dostaje skrypt Playwright. Kończysz z 800 testami E2E, których uruchomienie zajmuje 4 godziny.
  • Automatyzacja checklisty: Manualne przypadki testowe są przepisywane 1:1 do automatyzacji. Testy weryfikują kroki, a nie zachowanie.
  • Brak priorytetyzacji ryzyka: Czas poświęca się na automatyzowanie nisko-ryzykownych, rzadko zmieniających się ścieżek, podczas gdy krytyczne, ryzykowne funkcje nie mają żadnej automatyzacji.

Rozwiązanie

Zdefiniuj strategię automatyzacji przed napisaniem pierwszego testu:

  1. Jaki jest cel tej suity testów? Szybki feedback (jednostkowe), bezpieczeństwo regresji (integracyjne), pewność co do podróży użytkownika (E2E)?
  2. Które obszary są najbardziej ryzykowne? Checkout, uwierzytelnianie, płatności, migracje danych?
  3. Czego NIE należy automatyzować? Projekt wizualny, subiektywny UX, jednorazowe migracje danych.

Zastosuj sprawdzenie ROI dla każdego testu:

PytanieDobra odpowiedźZła odpowiedź
Jaki błąd to ostatnio wykryło?”Obliczenie sumy koszyka zepsuło się dwa razy w zeszłym kwartale""Żadnego, o którym wiem”
Ile czasu zajmuje utrzymanie na sprint?< 30 minut> 2 godziny
Czy nie przechodzi z właściwych powodów?Tylko gdy zmienia się zachowanie koduRównież przy problemach sieciowych, refaktorach UI

Usuwaj testy, które nie zasługują na swoje miejsce. Test, który przez pół roku nie wykrył żadnego prawdziwego błędu i regularnie nie przechodzi, powinien zostać usunięty, a nie naprawiony. To szum.

Checklista zdrowia automatyzacji

Użyj tego do audytu swojej obecnej suity testów:

☐ Brak hardkodowanych oczekiwań (waitForTimeout, Thread.Sleep, time.sleep)
☐ Selektory używają roli/etykiety/test-id, nie struktury DOM
☐ Każdy test tworzy i czyści własne dane
☐ Testy przechodzą konsekwentnie w CI/CD (środowisko Docker)
☐ Każdy automatyczny test ma udokumentowany cel
☐ Pełna suita działa w mniej niż 10 minut na CI
☐ Wskaźnik niestabilności poniżej 2% (śledzony w dashboardzie CI)
☐ Testy są przeglądane w code review, nie tylko dodawane
☐ Istnieje pisemna strategia automatyzacji, nie tylko folder z testami

Podsumowanie

Automatyczne testy, które kłamią, nie są aktywem — są zobowiązaniem. Pochłaniają czas inżynierów, spowalniają pipeline i tworzą fałszywe poczucie bezpieczeństwa.

Dobra wiadomość: większość tych problemów ma dobrze znane rozwiązania. Wyzwaniem jest ich priorytetyzacja. Wybierz jedną przyczynę z tej listy, znajdź najbardziej bolesny test w swojej suicie, który ją wykazuje, i napraw go w tym tygodniu. Potem następny.

Czyste suity testów nie powstają przez przypadek. Są wynikiem traktowania kodu testowego z tą samą dyscypliną inżynierską co kod produkcyjny.


To jest czwarty artykuł z serii Seria 1 — QA Ogólne.