Gdy niewielka zmiana w funkcji potrafi zepsuć pół aplikacji, ręczne klikanie przestaje wystarczać. Testy jednostkowe w Pythonie pozwalają szybko sprawdzać pojedyncze funkcje, obsługiwać błędne dane i bezpiecznie rozwijać projekt. Pokażę, jak zacząć, kiedy wybrać pytest lub moduł unittest, jak izolować zależności oraz jak uruchamiać testy automatycznie.
Najważniejsze zasady skutecznych testów jednostkowych
- Testuj małe elementy, najlepiej jedną funkcję lub metodę naraz.
- Stosuj schemat Arrange, Act, Assert, czyli przygotuj dane, wykonaj kod i sprawdź wynik.
- pytest daje krótszą składnię, a unittest jest dostępny bez instalowania dodatkowych pakietów.
- Do odcinania API, bazy danych i innych usług używaj mocków, ale nie zastępuj nimi całej logiki.
- Uruchamiaj testy w CI, aby każda zmiana była sprawdzana przed połączeniem z główną gałęzią.

Po co pisać testy dla pojedynczych fragmentów kodu
Test jednostkowy sprawdza mały, możliwie niezależny fragment programu. Najczęściej jest to funkcja, metoda klasy albo prosty moduł, który dla określonych danych wejściowych powinien zwrócić konkretny wynik. Taki test nie ma odtwarzać całego działania aplikacji, tylko szybko odpowiedzieć na pytanie, czy dana jednostka zachowuje się zgodnie z założeniami.
Największa korzyść pojawia się przy refaktoryzacji. Mogę zmienić sposób obliczania rabatu, walidacji adresu e-mail czy filtrowania rekordów, a po uruchomieniu testów od razu widzę, czy zachowanie funkcji nadal jest poprawne. To znacznie tańsze niż odkrycie błędu dopiero po wdrożeniu.
Dobre testy pełnią też funkcję żywej dokumentacji. Zamiast opisywać ogólnie, że funkcja „obsługuje niepoprawne dane”, pokazują konkretnie, co dzieje się dla pustego tekstu, wartości ujemnej albo brakującego pola. Trzeba jednak pamiętać, że duża liczba testów nie gwarantuje jakości. Liczy się trafny dobór przypadków, czytelne asercje i sprawdzanie zachowania, które naprawdę ma znaczenie.
Pytest czy unittest dla projektu w Pythonie
Python ma wbudowany moduł unittest, więc można zacząć bez instalowania zewnętrznego frameworka. Testy opierają się tam zwykle na klasach dziedziczących po unittest.TestCase, metodach zaczynających się od test_ oraz asercjach takich jak assertEqual i assertRaises.
import unittest
def policz_rabat(cena, procent):
if cena < 0 or not 0 <= procent <= 100:
raise ValueError("Nieprawidłowe dane")
return cena * (1 - procent / 100)
class TestPoliczRabat(unittest.TestCase):
def test_oblicza_rabat(self):
self.assertEqual(policz_rabat(100, 20), 80)
def test_odrzuca_bledny_procent(self):
with self.assertRaises(ValueError):
policz_rabat(100, 120)
if __name__ == "__main__":
unittest.main()
pytest jest zewnętrznym narzędziem, ale jego składnia zwykle szybciej trafia do nowych projektów. Zamiast klas i rozbudowanych metod wystarczą funkcje oraz zwykłe assert. Framework oferuje też fixture, parametryzację, czytelniejsze komunikaty błędów i szeroki ekosystem dodatków.
import pytest
@pytest.mark.parametrize(
"cena, procent, oczekiwany_wynik",
[
(100, 20, 80),
(50, 10, 45),
(200, 0, 200),
],
)
def test_policz_rabat(cena, procent, oczekiwany_wynik):
assert policz_rabat(cena, procent) == oczekiwany_wynik
def test_policz_rabat_odrzuca_bledny_procent():
with pytest.raises(ValueError):
policz_rabat(100, 120)
Ja najczęściej wybieram pytest w nowych aplikacjach, ponieważ pozwala pisać krótsze i łatwiejsze do utrzymania testy. unittest ma jednak sens w starszych systemach, bibliotekach bez dodatkowych zależności i zespołach, które korzystają już z jego konwencji. Oba podejścia można też łączyć, ponieważ pytest potrafi uruchamiać testy zapisane w stylu unittest.
| Kryterium | unittest | pytest |
|---|---|---|
| Dostępność | W standardowej bibliotece Pythona | Wymaga instalacji pakietu |
| Składnia | Bardziej formalna, oparta na klasach | Zwięzła, często oparta na funkcjach |
| Fixture i przygotowanie danych | setUp, tearDown i klasy pomocnicze | Dedykowane fixture o różnych zakresach |
| Parametryzacja | Możliwa, ale mniej wygodna | Wbudowana przez parametrize
|
Jak napisać pierwszy dobry test
Najpierw wybieram funkcję, która ma jasny kontrakt. Załóżmy, że kod przelicza cenę brutto na netto. Zamiast testować od razu cały moduł sprzedażowy, sprawdzam poprawność obliczenia dla typowej wartości, zera oraz danych, które powinny zostać odrzucone.
def cena_netto(cena_brutto, vat):
if cena_brutto < 0 or vat < 0:
raise ValueError("Cena i VAT nie mogą być ujemne")
return round(cena_brutto / (1 + vat / 100), 2)
def test_cena_netto_dla_podstawowej_stawki():
assert cena_netto(123, 23) == 100
def test_cena_netto_dla_zerowej_stawki():
assert cena_netto(50, 0) == 50
def test_cena_netto_odrzuca_cene_ujemna():
with pytest.raises(ValueError):
cena_netto(-10, 23)
Każdy test powinien mieć jeden wyraźny cel. Technicznie można umieścić w jednej funkcji kilka niezależnych asercji, ale wtedy komunikat o błędzie staje się mniej precyzyjny. W praktyce pomaga mi schemat Arrange, Act, Assert: przygotowuję dane, wywołuję kod i dopiero potem sprawdzam rezultat.
Przypadki brzegowe są ważniejsze niż przypadkowe przykłady
Test dla wartości 100 często przejdzie nawet wtedy, gdy implementacja ma błąd. Więcej informacji dają przypadki graniczne, takie jak 0, wartość minimalna, wartość maksymalna, pusty tekst, brak elementów i niepoprawny typ. Nie chodzi o sprawdzanie każdej możliwej liczby, lecz o pokrycie miejsc, w których logika może zmienić zachowanie.
Nazwa testu powinna mówić, co jest sprawdzane. test_1 niczego nie wyjaśnia, natomiast test_cena_netto_odrzuca_cene_ujemna od razu pokazuje oczekiwany efekt. Taka nazwa oszczędza czas podczas analizy raportu i pomaga traktować testy jak dokumentację reguł biznesowych.
Fixture, mocki i izolowanie zależności
Test jednostkowy powinien być szybki i przewidywalny. Jeżeli funkcja wysyła żądanie HTTP, zapisuje dane w bazie albo odczytuje aktualny czas, wynik testu może zależeć od zewnętrznego środowiska. Wtedy izoluję zależność i podstawiam kontrolowany obiekt testowy.
W pytest służą do tego fixture. Fixture przygotowuje dane lub obiekt, a potem przekazuje go do testu jako argument. Można ustawić jej zakres na pojedynczy test, klasę, moduł albo całą sesję, ale zbyt szeroki zakres może powodować współdzielenie stanu i trudne do wykrycia błędy.
import pytest
@pytest.fixture
def koszyk():
return [
{"nazwa": "Klawiatura", "cena": 150},
{"nazwa": "Mysz", "cena": 80},
]
def suma_koszyka(koszyk):
return sum(element["cena"] for element in koszyk)
def test_suma_koszyka(koszyk):
assert suma_koszyka(koszyk) == 230
Do zastępowania usług używam unittest.mock. Mock zapisuje, jak został wywołany, więc można sprawdzić nie tylko zwróconą wartość, ale także argumenty i liczbę wywołań. Kluczowa zasada brzmi patchuj obiekt tam, gdzie jest używany, a niekoniecznie tam, gdzie został pierwotnie zdefiniowany.
from unittest.mock import Mock
def pobierz_status_klienta(api, klient_id):
odpowiedz = api.get_status(klient_id)
return odpowiedz["status"]
def test_pobierz_status_klienta():
api = Mock()
api.get_status.return_value = {"status": "aktywny"}
wynik = pobierz_status_klienta(api, 42)
assert wynik == "aktywny"
api.get_status.assert_called_once_with(42)
Mocki nie powinny zastępować całego świata. Jeżeli zamockuję każdy obiekt, test może przechodzić mimo błędnej integracji. Dlatego łączę szybkie testy jednostkowe z niewielką liczbą testów integracyjnych, które sprawdzają prawdziwe połączenie między modułami.
Automatyzacja uruchamiania testów
Sam zapisany test nie poprawia jeszcze procesu wytwarzania oprogramowania. Trzeba ustalić, jak go uruchamiać lokalnie i w jakim momencie ma blokować zmianę. W projekcie opartym na pytest podstawą jest polecenie pytest, które wyszukuje pliki zgodne z typowymi konwencjami, na przykład test_*.py i *_test.py.
python -m pip install pytest
pytest
pytest tests/test_cennik.py -q
pytest -k rabat
pytest --maxfail=1
Parametryzacja pozwala uruchomić ten sam scenariusz dla wielu zestawów danych bez kopiowania funkcji testowej. To szczególnie przydatne przy walidacji, konwersjach i obliczeniach. Trzeba tylko uważać na mutowalne dane, ponieważ pytest przekazuje wartości parametrów bez tworzenia ich kopii.
W CI, czyli środowisku ciągłej integracji, testy uruchamiają się po wysłaniu zmian do repozytorium albo przed scaleniem gałęzi. Minimalny proces powinien instalować zależności, uruchamiać testy i kończyć się błędem, gdy choć jeden przypadek nie przejdzie. Raport pokrycia kodu może pomóc znaleźć nieprzetestowane miejsca, ale sam procent pokrycia nie mówi, czy testy sprawdzają właściwe zachowanie.
Przeczytaj również: Selenium w praktyce - jak tworzyć stabilne testy
Jak ułożyć strukturę katalogów
Prosty układ wystarcza w większości małych i średnich projektów. Kod produkcyjny trzymam w pakiecie aplikacji, a testy w osobnym katalogu, zachowując podobne nazwy modułów.
projekt/
├── app/
│ ├── __init__.py
│ └── cennik.py
├── tests/
│ ├── test_cennik.py
│ └── conftest.py
└── pyproject.toml
Plik conftest.py może zawierać wspólne fixture, ale nie powinien stać się przypadkowym magazynem logiki. Gdy testy zaczynają wymagać wielu globalnych przygotowań, zwykle jest to sygnał, że kod produkcyjny albo same testy mają zbyt silne powiązania.
Błędy, które osłabiają wartość testów
Najczęstszy problem to testowanie szczegółów implementacji zamiast efektu działania. Jeżeli test wymaga znajomości każdej prywatnej metody i kolejności wewnętrznych wywołań, refaktoryzacja staje się bolesna, nawet gdy zachowanie programu się nie zmienia. Ja sprawdzam przede wszystkim publiczny kontrakt funkcji, a szczegóły kontroluję tylko wtedy, gdy są istotne dla skutku ubocznego.
Drugim błędem są testy zależne od siebie. Test nie powinien wymagać, aby wcześniej uruchomił się inny test ani korzystać ze wspólnego, zmienianego globalnie słownika. Każdy przypadek powinien przygotować własny stan i móc zostać uruchomiony osobno.
Niebezpieczne są również zbyt ogólne asercje. Sprawdzenie, że wynik „nie jest pusty”, może przepuścić błędną odpowiedź. Lepiej określić oczekiwany typ, strukturę i konkretne wartości, a przy wyjątkach sprawdzić także rodzaj błędu oraz, jeśli ma to znaczenie, treść komunikatu.
Unikam też testów, które powielają implementację. Jeżeli funkcja i test używają dokładnie tego samego wzoru obliczeniowego, mogą popełnić ten sam błąd. Czasem lepiej przygotować kilka ręcznie policzonych przykładów albo użyć niezależnego źródła oczekiwanych danych.
| Problem | Skutek | Lepsze rozwiązanie |
|---|---|---|
| Zależność od bazy lub sieci | Testy są wolne i niestabilne | Fixture, mock albo osobny test integracyjny |
| Wspólny, zmieniany stan | Wynik zależy od kolejności uruchamiania | Izolowane dane dla każdego testu |
| Sprawdzanie prywatnych szczegółów | Refaktoryzacja łamie testy | Testowanie publicznego zachowania |
| Pokrycie za wszelką cenę | Dużo testów, mało realnej ochrony | Scenariusze biznesowe i przypadki brzegowe |
Jak zbudować rozsądny standard w zespole
Na początku ustaliłbym kilka prostych reguł. Testy powinny mieć czytelne nazwy, nie korzystać z przypadkowego stanu globalnego, kończyć się jednoznaczną asercją i działać szybko. Jeżeli pojedynczy test trwa kilka sekund, warto sprawdzić, czy nie testuje już integracji zamiast jednej jednostki.
- Każda nowa reguła biznesowa dostaje test dla poprawnego działania i co najmniej jeden test błędnego wejścia.
- Naprawa błędu zaczyna się od testu regresyjnego, który wcześniej odtwarza problem.
- Mock jest używany do izolowania kosztownej lub zewnętrznej zależności, a nie do ukrywania braku testu integracyjnego.
- Testy uruchamiają się lokalnie jednym poleceniem oraz automatycznie w CI.
- Przy zmianie zachowania aktualizuje się zarówno kod, jak i test opisujący kontrakt.
Nie próbowałbym od razu pokrywać całego starego systemu. Lepszą strategią jest testowanie nowego kodu oraz obszaru, który właśnie zmieniam. Z czasem powstaje warstwa ochronna tam, gdzie ryzyko biznesowe jest największe, zamiast pozornie imponującego wyniku pokrycia bez praktycznej wartości.
Mały zestaw decyzji, który dobrze działa w praktyce
Jeżeli zaczynasz projekt od zera, wybierz pytest, trzymaj testy blisko modułów, używaj fixture do przygotowania danych i parametryzuj powtarzalne przypadki. Dla prostych funkcji wystarczy kilka precyzyjnych asercji, a dla zależności zewnętrznych potrzebujesz kontrolowanej izolacji.
Jeżeli pracujesz z istniejącym kodem, nie przepisuj od razu całego zestawu testów. Najpierw zabezpiecz zachowanie, które musi pozostać niezmienne, dodaj test dla znalezionego błędu i dopiero potem upraszczaj implementację. Najlepszy test to nie ten najdłuższy, lecz taki, który szybko ujawnia realną regresję.
Właśnie dlatego automatyzacja testów jednostkowych jest czymś więcej niż dodatkowym etapem pracy. Dobrze zaprojektowany zestaw daje zespołowi szybki sygnał, ogranicza koszt pomyłek i pozwala rozwijać aplikację z większym spokojem, bez zamieniania każdej zmiany w ręczną inspekcję całego systemu.