Dlaczego testy są fundamentem dojrzałego kodu
Gdy piszesz program, który sprawdzasz tylko ręcznie, każda zmiana staje się loterią. Refaktor, który wydawał się niewinny, potrafi wywalić funkcję zgłoszoną kilka tygodni wcześniej. Testy automatyczne przekształcają tę niepewność w natychmiastową odpowiedź — wystarczy jedno polecenie, żeby dowiedzieć się, czy najnowsza zmiana coś popsuła.
Dobrze napisany zestaw testów daje ci coś więcej niż tylko bezpieczeństwo regresji. Testy są wykonywalną dokumentacją: pokazują, jakie zachowanie jest oczekiwane, jakie przypadki brzegowe zostały przemyślane i jak moduły mają ze sobą współpracować. Gdy przychodzisz do projektu po pół roku, to właśnie testy najszybciej przypominają, co ten kod w ogóle robi.
W tym artykule przejdziemy przez piramidę testową, różne rodzaje testów, TDD, test doubles, code coverage oraz najczęstsze pułapki. Skupimy się na ekosystemie Pythona (pytest, unittest.mock, Hypothesis) i narzędziach uniwersalnych (Playwright, Testcontainers, GitHub Actions).
Piramida testowa: jak układać warstwy
Klasyczna piramida Mike’a Cohna mówi, że powinieneś mieć dużo testów jednostkowych, mniej integracyjnych i zupełnie niewiele testów end-to-end. Powód jest prosty: koszt utrzymania i czas wykonania rosną dramatycznie z każdym piętrem.
Testy jednostkowe
Testy jednostkowe weryfikują pojedynczą funkcję lub klasę w izolacji. Powinny wykonywać się w milisekundach, nie wymagać bazy danych ani sieci. To tutaj spędzisz większość czasu — zdrowy projekt ma ich tysiące.
Testy integracyjne
Testy integracyjne sprawdzają, jak twoje moduły współpracują z prawdziwymi zależnościami: bazą danych, kolejką wiadomości, zewnętrznym API. Są wolniejsze (setki milisekund do sekund), ale łapią błędy, których mocki nigdy nie wykryją — literówki w SQL, błędne migracje, niezgodność serializacji JSON.
Testy end-to-end
Testy E2E uruchamiają całą aplikację jak prawdziwy użytkownik: klikają w przeglądarce, wypełniają formularze, sprawdzają efekty. Są potężne, ale powolne i kruche — jeden dodany div potrafi rozwalić cały scenariusz. Trzymaj je dla kluczowych ścieżek biznesowych: logowanie, zakup, rejestracja.
Rozkład ok. 70% jednostkowe / 20% integracyjne / 10% E2E to dobry punkt startowy. Jeśli twój CI trwa godzinę i większość tego czasu zjadają testy E2E, masz odwróconą piramidę (tzw. ice cream cone) i czas to naprawić.
Pytest w praktyce: fixtures i parametrize
Pytest to de facto standard testowania w Pythonie. Jest prostszy od wbudowanego unittest, ma świetne komunikaty błędów i ogromny ekosystem pluginów. Zacznij od zwykłych asercji — żadne self.assertEqual nie jest potrzebne.
import pytest
from sklep import Koszyk, Produkt
# Fixture - przygotowuje dane dla testów
@pytest.fixture
def pusty_koszyk():
return Koszyk()
@pytest.fixture
def produkt_ksiazka():
return Produkt(nazwa="Clean Code", cena=89.90)
def test_dodanie_produktu_zwieksza_sume(pusty_koszyk, produkt_ksiazka):
pusty_koszyk.dodaj(produkt_ksiazka)
assert pusty_koszyk.suma == 89.90
assert len(pusty_koszyk.pozycje) == 1
Fixture to funkcja, która przygotowuje stan dla testu — obiekt, połączenie, plik tymczasowy. Pytest wstrzykuje ją automatycznie, gdy test poprosi o nią w argumentach. Fixtures mogą mieć różne zasięgi: function, class, module, session.
Dekorator @pytest.mark.parametrize pozwala uruchomić ten sam test z wieloma zestawami danych. To ratuje od copy-paste i wymusza myślenie o przypadkach brzegowych.
import pytest
from podatki import oblicz_vat
@pytest.mark.parametrize("netto, stawka, brutto", [
(100.00, 0.23, 123.00),
(50.00, 0.08, 54.00),
(0.00, 0.23, 0.00),
(999.99, 0.00, 999.99),
])
def test_oblicz_vat(netto, stawka, brutto):
assert oblicz_vat(netto, stawka) == pytest.approx(brutto)
def test_ujemna_kwota_rzuca_wyjatek():
with pytest.raises(ValueError, match="Kwota nie może być ujemna"):
oblicz_vat(-10, 0.23)
TDD: czerwony, zielony, refaktor
Test-Driven Development to dyscyplina, w której najpierw piszesz test, a dopiero potem kod, który ten test spełnia. Cykl ma trzy fazy: red (test się nie kompiluje lub nie przechodzi), green (najprostsza implementacja, która zielenieje test) oraz refactor (sprzątasz kod, nie zmieniając zachowania).
Dla wielu programistów TDD brzmi nienaturalnie — jak pisać test dla czegoś, co jeszcze nie istnieje? Chodzi właśnie o to: test zmusza cię do zdefiniowania kontraktu funkcji zanim zaczniesz myśleć o implementacji. Dostajesz też szybką pętlę zwrotną, bo małe zielone kroki budują pewność siebie.
Test doubles: mocki, stuby, fejki, spies
Gdy testujesz kod, który rozmawia z zewnętrznym światem (API, baza, zegar systemowy), nie chcesz za każdym razem uruchamiać prawdziwych zależności. Test doubles to ogólna nazwa dla obiektów zastępczych, a Gerard Meszaros wyróżnił kilka ich rodzajów:
- Stub — zwraca z góry ustalone wartości, nie weryfikuje wywołań.
- Mock — ma zaprogramowane oczekiwania, test przechodzi tylko jeśli zostaną spełnione.
- Fake — działająca uproszczona implementacja (np. baza danych w pamięci zamiast PostgreSQL).
- Spy — zapisuje wywołania do późniejszej inspekcji, ale nie zmienia zachowania.
from unittest.mock import Mock, patch
from mailer import wyslij_potwierdzenie
def test_wysyla_email_po_zamowieniu():
fake_smtp = Mock()
zamowienie = {"email": "anna@example.com", "numer": "ZAM-042"}
wyslij_potwierdzenie(zamowienie, smtp=fake_smtp)
fake_smtp.send_message.assert_called_once()
args = fake_smtp.send_message.call_args[0][0]
assert args["To"] == "anna@example.com"
assert "ZAM-042" in args.get_payload()
@patch("mailer.datetime")
def test_dodaje_aktualna_date(mock_dt):
mock_dt.now.return_value = "2026-06-08T10:00:00"
# ... reszta testu
Zbyt dużo mockowania to jeden z najczęstszych anty-wzorców. Jeśli twój test mockuje pięć zależności i weryfikuje tylko sekwencję wywołań, to tak naprawdę testuje implementację, a nie zachowanie. Taki test przestanie działać przy pierwszym refaktorze.
Testy integracyjne i E2E
Do testów integracyjnych polecam Testcontainers — bibliotekę, która podnosi prawdziwe kontenery Dockera (PostgreSQL, Redis, Kafka) na czas testu i gasi je po zakończeniu. Dzięki temu sprawdzasz zapytania SQL na tej samej wersji bazy, którą masz na produkcji, bez hackerskich makiet.
Dla aplikacji webowych testy E2E najlepiej pisać w Playwright (multiprzeglądarkowy, wspiera Pythona i TypeScript) lub Cypress (tylko Chromium i Firefox, ale bardzo przyjazny developer experience). Oba narzędzia automatycznie czekają na elementy, co eliminuje flaky testy znane z Selenium.
from playwright.sync_api import Page, expect
def test_logowanie_poprawnymi_danymi(page: Page):
page.goto("https://app.test.local/login")
page.get_by_label("Email").fill("anna@example.com")
page.get_by_label("Hasło").fill("TajneHaslo123!")
page.get_by_role("button", name="Zaloguj").click()
expect(page).to_have_url("https://app.test.local/dashboard")
expect(page.get_by_text("Witaj, Anna")).to_be_visible()
Contract testing dla mikroserwisów
W architekturze mikroserwisowej klasyczne E2E szybko stają się koszmarem. Rozwiązanie: testy kontraktowe z biblioteką Pact. Konsument definiuje oczekiwany kontrakt (jakie pola i statusy zwróci API), a producent weryfikuje, że jego implementacja ten kontrakt spełnia. Dzięki temu możesz deployować serwisy niezależnie, nie bojąc się zerwania integracji.
Property-based testing i mutation testing
Zamiast wpisywać konkretne przypadki, możesz poprosić bibliotekę Hypothesis, żeby sama wygenerowała setki wariantów wejścia. Jeśli funkcja ma jakąś niezmienniczość (np. reverse(reverse(x)) == x), Hypothesis znajdzie kontrprzykłady, których nigdy sam byś nie wymyślił.
from hypothesis import given, strategies as st
from utils import koduj_base64, dekoduj_base64
@given(st.binary())
def test_kodowanie_jest_odwracalne(dane):
assert dekoduj_base64(koduj_base64(dane)) == dane
@given(
cena=st.decimals(min_value=0, max_value=1000000, places=2),
rabat=st.integers(min_value=0, max_value=100),
)
def test_rabat_nigdy_nie_daje_ujemnej_ceny(cena, rabat):
wynik = zastosuj_rabat(cena, rabat)
assert wynik >= 0
assert wynik <= cena
Mutation testing (np. mutmut, cosmic-ray) sprawdza jakość twoich testów w inny sposób — celowo psuje kod produkcyjny (zmienia > na >=, and na or) i patrzy, czy testy to wychwycą. Jeśli mutacja „przeżywa”, masz lukę w pokryciu logicznym, nawet gdy coverage.py pokazuje 100%.
Pokrycie kodu: liczba, która może kłamać
Code coverage mierzy, ile linii zostało wykonanych podczas testów. Narzędzia takie jak coverage.py czy pytest-cov generują raporty HTML z kolorowaniem. To przydatne, ale miara łatwa do oszukania — test, który wywołuje funkcję bez żadnej asercji, podnosi pokrycie do 100% i niczego nie sprawdza.
Traktuj coverage jako detektor luk, nie cel sam w sobie. 80% z sensownymi asercjami bije 100% z pustymi wywołaniami. Menedżerów, którzy wymagają sztywnego progu, lepiej uzupełniać mutation testingiem.
Testy w CI: GitHub Actions i fail-fast
Każdy commit powinien przechodzić przez CI, zanim trafi do main. GitHub Actions pozwala zdefiniować matrix — zestaw kombinacji wersji Pythona i systemu operacyjnego, które mają być przetestowane równolegle.
name: Tests
on: [push, pull_request]
jobs:
test:
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest]
python-version: ["3.11", "3.12", "3.13"]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- run: pip install -e ".[test]"
- run: pytest --cov=src --cov-report=xml
- uses: codecov/codecov-action@v4
Ustawienie fail-fast: false warto rozważyć — domyślnie GitHub przerywa wszystkie zadania po pierwszej porażce, co ukrywa informację, że błąd dotyczy tylko macOS albo tylko Pythona 3.13. Do lokalnego testowania wielu wersji sprawdza się tox lub jego szybszy kuzyn nox.
Jeśli twój zestaw testów wykonuje się dłużej niż 5 minut, programiści przestaną go uruchamiać lokalnie. Profiluj wolne testy (pytest --durations=10), wydzielaj integracyjne do osobnego workflow i używaj pytest-xdist do równoległego uruchamiania.
Najczęstsze anty-wzorce
Po latach pisania testów pewne błędy powtarzają się w każdym projekcie. Oto te, które kosztują najwięcej:
- Testowanie implementacji zamiast zachowania — test, który sprawdza, że funkcja wywołała prywatną metodę w określonej kolejności, łamie się przy każdym refaktorze.
- Over-mocking — gdy mockujesz własny kod, testujesz mocki, nie aplikację. Mockuj granice systemu (sieć, dysk, zegar), nie własne klasy domenowe.
- Flaky testy — przechodzą losowo raz tak, raz nie. Zwykle przez sleep-y, zależność od czasu systemowego albo wspólny stan między testami. Napraw albo usuń.
- Olbrzymie fixtures — fixture, która przygotowuje pół bazy danych, ukrywa co faktycznie jest potrzebne do testu. Buduj minimalne przykłady z pomocą builderów.
- Brak izolacji — jeśli testy muszą uruchamiać się w określonej kolejności, masz ukryte zależności stanu. Każdy test powinien móc wystartować sam.
Chcesz wiedzieć, jakie narzędzia stosujemy na co dzień w naszej redakcji? Zajrzyj na stronę o nas — opisujemy tam, jak pracujemy z kodem i skąd czerpiemy praktyki prezentowane na tym blogu.
Dobre testy są szybkie, izolowane, powtarzalne i czytelne. Zacznij od solidnej bazy jednostkowej w pytest, dołóż testy integracyjne z Testcontainers tam, gdzie integrujesz się z bazą, a E2E trzymaj dla kluczowych ścieżek. Property-based i mutation testing to narzędzia dojrzałych zespołów — warto po nie sięgnąć, gdy podstawa działa.

