Po co te wszystkie testy? Krótkie uporządkowanie tematu
Czym są testy jednostkowe w Pythonie i co realnie dają
Testy jednostkowe w Pythonie to krótkie fragmenty kodu, które sprawdzają pojedyncze, możliwie małe elementy aplikacji: funkcję, metodę, czasem niewielką klasę. Testy uruchamia się automatycznie i weryfikuje, czy dana jednostka zachowuje się zgodnie z oczekiwaniami w różnych scenariuszach.
Przy dobrze napisanych testach jednostkowych zyskujesz:
- Szybkie wykrywanie regresji – zmieniasz kawałek logiki i od razu widzisz, co się posypało.
- Bezpieczniejszą refaktoryzację – możesz przeorganizować kod bez strachu, że „coś się gdzieś zepsuje”.
- Dokumentację zachowania – testy jednostkowe python przykład często mówi więcej niż opis w README.
- Większą pewność przy nowych funkcjach – szczególnie przy pracy w zespole, gdzie ktoś inny opiera się na twoim kodzie.
Testy działają jak siatka bezpieczeństwa pod akrobatą – dopóki wszystko idzie dobrze, prawie jej nie widać, ale gdy coś pójdzie nie tak, nagle okazuje się bardzo potrzebna.
Testy jednostkowe, integracyjne i end-to-end – praktyczne porównanie
Różne typy testów sprawdzają różne poziomy systemu. Dobrze jest rozróżniać je w głowie, żeby nie oczekiwać od testów jednostkowych zbyt wiele albo zbyt mało.
| Rodzaj testu | Co testuje | Charakterystyka | Przykład w Pythonie |
|---|---|---|---|
| Jednostkowy | Pojedyncza funkcja/klasa | Bardzo szybki, silnie izolowany | Test funkcji obliczającej rabat |
| Integracyjny | Współpracę kilku modułów | Wolniejszy, dotyka więcej warstw | Wywołanie endpointu API z prawdziwą bazą testową |
| End-to-end | Całą aplikację | Najwolniejszy, najbardziej zbliżony do realnego użycia | Symulacja prac użytkownika w UI lub po HTTP |
Testy jednostkowe nie zastępują integracyjnych ani end-to-end, ale są ich fundamentem. Jeśli nie potrafisz przetestować funkcji w oderwaniu od reszty świata, to sygnał, że kod jest zbyt posklejany ze wszystkim dookoła.
Kiedy testy jednostkowe naprawdę się opłacają
Testy jednostkowe są szczególnie opłacalne, gdy:
- Projekt będzie rozwijany dłużej niż kilka dni (czyli większość komercyjnych aplikacji).
- Nad kodem pracuje więcej niż jedna osoba.
- Funkcjonalność ma skomplikowaną logikę (obliczenia, zasady biznesowe, reguły walidacji).
- Masz integracje z zewnętrznymi systemami, których zachowanie może się zmieniać.
Da się znaleźć też miejsce dla testów w „skryptach na szybko”. Jeśli skrypt będzie odpalany wielokrotnie, przetwarza ważne dane albo ma tendencję do rozrastania się – kilka prostych testów z pytest oszczędzi później nerwów. Najprostszy przykład: testowanie funkcji parsującej plik csv, zanim skrypt zacznie wypluwać błędne wyniki w środku nocy.
Testy a refaktoryzacja, tempo pracy i pewność przy zmianach
Bez testów refaktoryzacja przypomina wyciąganie kabli z serwera „bo chyba ten jest niepotrzebny”. Z testami jednostkowymi zyskujesz od razu informację, które zachowania kodu zostały złamane.
W dłuższej perspektywie, po krótkim okresie „spowolnienia” związanego z nauką pytest i mockowania, tempo pracy rośnie. Możesz:
- śmielej zmieniać strukturę kodu,
- szybciej reagować na błędy z produkcji (piszesz test, który odtwarza błąd, a potem go naprawiasz),
- łatwiej wdrażać nowych członków zespołu – testy działają jak żywy przykład użycia API twojego modułu.
Im wcześniej wprowadzisz testy jednostkowe w pythonie, tym mniej heroicznych „hotfixów” po nocy czeka cię w przyszłości.
Podstawy pytest – szybki start bez filozofii
Instalacja pytest i pierwsze uruchomienie testów
Pytest stał się de facto standardem testów jednostkowych w Pythonie. Instalacja jest prosta:
pip install pytest
Po instalacji najprostszy sposób uruchomienia testów:
pytest
Jeśli chcesz zobaczyć więcej szczegółów:
pytest -v
Przydatne flagi na start:
-k <substring>– uruchamia tylko testy, których nazwa zawiera podany fragment (np.pytest -k rabat).-q– tryb „cichy”, mniej szumu.--maxfail=1– zatrzymanie po pierwszej porażce.-x– to samo co--maxfail=1, wygodne przy debugowaniu.
Konwencje nazewnicze: pliki test_*, funkcje test_* i klasy Test*
Pytest sam odnajduje testy w projekcie, jeśli trzymasz się kilku prostych reguł:
- pliki z testami:
test_*.pylub*_test.py, - funkcje testowe: nazwy zaczynające się od
test_, - klasy testowe: nazwy zaczynające się od
Test(bez__init__).
Przykład najprostszej struktury:
project/
my_module.py
test_my_module.py
A w pliku test_my_module.py:
def test_simple_math():
assert 1 + 1 == 2
Po uruchomieniu pytest w katalogu project/ pytest sam znajdzie ten plik i wykona test.
Struktura testu: arrange–act–assert na przykładzie
Większość testów jednostkowych w Pythonie można zapisać jako prosty schemat:
- Arrange – przygotowanie danych i obiektów,
- Act – wykonanie testowanej operacji,
- Assert – sprawdzenie wyniku.
Prosty przykład – funkcja licząca podatek:
# my_module.py
def calculate_tax(amount, rate):
return round(amount * rate, 2)
Test z użyciem schematu arrange–act–assert:
# test_my_module.py
from my_module import calculate_tax
def test_calculate_tax_basic():
# arrange
amount = 100
rate = 0.23
# act
result = calculate_tax(amount, rate)
# assert
assert result == 23.0
Komentarze nie są obowiązkowe, ale na początku pomagają trzymać się porządku.
Gdzie trzymać testy w projekcie i jak pytest je znajduje
Najpopularniejszy układ katalogów:
project/
app/
__init__.py
core.py
api.py
tests/
__init__.py # opcjonalnie
test_core.py
test_api.py
Uruchamiasz wtedy:
cd project
pytest
Pytest rekurencyjnie przejdzie katalogi, znajdzie tests/, a w nim wszystkie pliki spełniające konwencję. Taka struktura ułatwia zarządzanie projektem w dłuższym okresie, szczególnie gdy dochodzą testy integracyjne, testy z bazą danych pytest albo osobne testy end-to-end.
Pierwszy zestaw testów funkcji biznesowych
Najlepiej zacząć od funkcji, które nie dotykają świata zewnętrznego: bez sieci, plików, bazy danych. Przykładowy moduł z prostą logiką biznesową:
# discounts.py
def calculate_discount(price, customer_type):
if price <= 0:
raise ValueError("Price must be positive")
if customer_type == "vip":
return price * 0.8
elif customer_type == "regular":
return price * 0.9
else:
return price
Testy jednostkowe python przykład:
# test_discounts.py
from discounts import calculate_discount
import pytest
def test_calculate_discount_for_vip():
result = calculate_discount(100, "vip")
assert result == 80
def test_calculate_discount_for_regular():
result = calculate_discount(100, "regular")
assert result == 90
def test_calculate_discount_for_unknown_type():
result = calculate_discount(100, "new")
assert result == 100
def test_calculate_discount_invalid_price():
with pytest.raises(ValueError):
calculate_discount(0, "vip")
Taki komplet testów pokrywa podstawowe ścieżki: typy klientów, przypadek błędu i zachowanie domyślne. W dalszych sekcjach pojawi się parametryzacja, która pozwoli ten kod uprościć i pozbyć się powtarzania.
Pisanie pierwszych testów w pytest – praktyczne przykłady
Testowanie prostej funkcji obliczeniowej
Funkcje czysto obliczeniowe, bez efektów ubocznych, to idealny materiał treningowy. Załóżmy funkcję, która liczy średnią ruchomą:
# stats.py
def moving_average(values, window):
if window <= 0:
raise ValueError("Window must be positive")
if len(values) < window:
return []
result = []
for i in range(len(values) - window + 1):
chunk = values[i : i + window]
result.append(sum(chunk) / window)
return result
Testy jednostkowe python przykład:
# test_stats.py
import pytest
from stats import moving_average
def test_moving_average_basic_case():
values = [1, 2, 3, 4]
result = moving_average(values, window=2)
assert result == [1.5, 2.5, 3.5]
def test_moving_average_window_equal_length():
values = [1, 2, 3]
result = moving_average(values, window=3)
assert result == [2]
def test_moving_average_window_larger_than_list():
values = [1, 2]
result = moving_average(values, window=3)
assert result == []
def test_moving_average_invalid_window():
with pytest.raises(ValueError):
moving_average([1, 2, 3], window=0)
Od razu pojawiają się przypadki brzegowe: pusta lista wynikowa, nieprawidłowe okno i różne relacje długości listy do okna. To dobry nawyk – przy nowych funkcjach od razu wypisać potencjalne „dziwne” wejścia.
Testowanie wyjątków z pytest.raises
Wyjątki testuje się w pytest za pomocą pytest.raises. Można go używać jako kontekstu (with), ale też jako funkcji. Przykład funkcji z walidacją:
# validators.py
def validate_age(age):
if not isinstance(age, int):
raise TypeError("Age must be int")
if age < 0:
raise ValueError("Age must be non-negative")
if age > 120:
raise ValueError("Age seems unrealistic")
return True
Testowanie wyjątków:
# test_validators.py
import pytest
from validators import validate_age
def test_validate_age_valid():
assert validate_age(30) is True
def test_validate_age_negative():
with pytest.raises(ValueError) as exc:
validate_age(-1)
assert "non-negative" in str(exc.value)
def test_validate_age_too_large():
with pytest.raises(ValueError) as exc:
validate_age(200)
assert "unrealistic" in str(exc.value)
def test_validate_age_wrong_type():
with pytest.raises(TypeError):
validate_age("18")
Użycie as exc pozwala sprawdzić komunikat błędu. Nie zawsze to konieczne, ale przy ważnych walidacjach potrafi wychwycić złe komunikaty, które później pojawiają się użytkownikom.
Testowanie funkcji zwracających listy i słowniki
Przy strukturach danych lepiej nie assercikować wszystkiego co do jednego klucza, zwłaszcza jeśli funkcja zwraca duże, zagnieżdżone struktury. Zwykle wystarczy kilka kluczowych fragmentów.
Przykład – formatowanie odpowiedzi API:
# api_formatters.py
def format_user_payload(user):
return {
"id": user["id"],
"full_name": f"{user['first_name']} {user['last_name']}",
"is_active": not user.get("disabled", False),
"tags": sorted(user.get("tags", [])),
}
Test:
# test_api_formatters.py
from api_formatters import format_user_payload
def test_format_user_payload_basic():
user = {
"id": 1,
"first_name": "Jan",
"last_name": "Kowalski",
"disabled": False,
"tags": ["b", "a"],
}
result = format_user_payload(user)
assert result["id"] == 1
assert result["full_name"] == "Jan Kowalski"
assert result["is_active"] is True
assert result["tags"] == ["a", "b"] # posortowane
def test_format_user_payload_defaults():
user = {
"id": 2,
"first_name": "Anna",
"last_name": "Nowak",
}
result = format_user_payload(user)
assert result["id"] == 2
assert result["is_active"] is True
assert result["tags"] == []
Zwrócenie uwagi na istotne elementy pozwala zachować testy czytelne i odporne na dopisanie mniej ważnych pól do odpowiedzi w
Testowanie funkcji współpracujących z plikami – pierwszy krok do izolacji
Pliki to już lekki kontakt ze „światem zewnętrznym”. Nadal da się to sensownie testować bez ciężkiego mockowania, jeśli operacje są proste.
# file_utils.py
def read_lines(path):
with open(path, "r", encoding="utf-8") as f:
return [line.rstrip("n") for line in f]
Najprostszy test można oprzeć na pliku pomocniczym w repozytorium tests/fixtures/, ale wtedy trzeba dbać o ścieżki i dodatkowe pliki. Pytest ma coś lepszego – wbudowaną fixture tmp_path, która tworzy tymczasowy katalog na czas testu:
# test_file_utils.py
from file_utils import read_lines
def test_read_lines_reads_all_lines(tmp_path):
file_path = tmp_path / "example.txt"
file_path.write_text("line1nline2n", encoding="utf-8")
result = read_lines(file_path)
assert result == ["line1", "line2"]
Żadnych ręcznych katalogów testowych, żadnego sprzątania – pytest po teście usuwa katalog tymczasowy. tmp_path jest świetnym przykładem, czym w praktyce są fixtures, więc czas je wreszcie omówić.
Fixtures w pytest – o co w tym chodzi i kiedy ich używać
Co to jest fixture w pytest i po co to komu
Fixture to po prostu funkcja, która przygotowuje jakiś „zasób” dla twoich testów: obiekt, konfigurację, katalog tymczasowy, połączenie do bazy, cokolwiek. Test deklaruje, że takiej fixture potrzebuje, dodając ją jako parametr funkcji testowej.
Minimalny przykład:
# test_example.py
import pytest
@pytest.fixture
def sample_list():
return [1, 2, 3]
def test_sum_sample_list(sample_list):
assert sum(sample_list) == 6
Pytest widzi, że test test_sum_sample_list ma parametr o nazwie sample_list, więc szuka fixture o takiej nazwie. Znajduje, wywołuje ją, bierze wynik i wstrzykuje do testu.
Kiedy fixture ma sens, a kiedy to przerost formy
Fixtures są szczególnie przydatne, gdy:
- kilka testów potrzebuje tego samego przygotowania danych,
- przygotowanie jest nieco rozbudowane (np. parę obiektów, katalog z plikami, konfiguracja środowiska),
- chcesz w jednym miejscu zmienić sposób tworzenia zasobu dla wielu testów.
Przykład zestawu testów na tym samym „kliencie” domenowym:
# customers.py
class Customer:
def __init__(self, name, vip=False):
self.name = name
self.vip = vip
self.points = 0
def add_points(self, value):
if value < 0:
raise ValueError("Points must be positive")
self.points += value
def is_eligible_for_discount(self):
return self.vip or self.points >= 100
# test_customers.py
import pytest
from customers import Customer
@pytest.fixture
def vip_customer():
customer = Customer("Jan", vip=True)
customer.add_points(50)
return customer
def test_vip_customer_has_discount(vip_customer):
assert vip_customer.is_eligible_for_discount() is True
def test_vip_customer_can_accumulate_points(vip_customer):
vip_customer.add_points(20)
assert vip_customer.points == 70
Bez fixture trzeba by w każdym teście powtarzać tworzenie obiektu, dodawanie punktów i ustawianie flagi. Dwa testy to pół biedy, ale przy dziesięciu powtarzalny kod zaczyna męczyć.
Wbudowane fixtures w pytest – nie zawsze trzeba pisać własne
Pytest ma kilka fixtures „z pudełka”, które szybko się przydają:
tmp_path– katalog tymczasowy jako obiektpathlib.Path,tmpdir– starsza wersja, oparta napy.path,capsys– przechwytywaniestdout/stderr,monkeypatch– tymczasowa zmiana atrybutów/modułów/zmiennych środowiskowych.
Przykład użycia capsys do testowania funkcji wypisującej na standardowe wyjście:
# cli.py
def greet(name):
print(f"Hello, {name}!")
# test_cli.py
from cli import greet
def test_greet_prints_message(capsys):
greet("Jan")
captured = capsys.readouterr()
assert captured.out.strip() == "Hello, Jan!"
Zero mocków, zero kombinowania z przekierowaniem sys.stdout. Kto pamięta ręczne podmienianie sys.stdout, ten doceni.

Bardziej zaawansowane fixtures – kontekst, teardown, parametryzacja
Fixture z yield – setup i sprzątanie w jednym miejscu
Czasami trzeba coś stworzyć przed testem, a po teście to posprzątać: zamknąć połączenie, usunąć pliki, przywrócić stan. W pytest można to zrobić przez yield w fixture.
# test_db.py
import pytest
class FakeDB:
def __init__(self):
self.connected = False
self.data = {}
def connect(self):
self.connected = True
def close(self):
self.connected = False
def insert(self, key, value):
if not self.connected:
raise RuntimeError("Not connected")
self.data[key] = value
@pytest.fixture
def db():
db = FakeDB()
db.connect()
yield db
db.close()
def test_db_insert(db):
db.insert("user:1", {"name": "Jan"})
assert db.data["user:1"]["name"] == "Jan"
Część przed yield to setup, część po yield to teardown. Dzięki temu nawet jeśli test rzuci wyjątek, kod po yield i tak się wykona.
Scope fixture – jak często ma się wykonywać
Domyślnie fixture ma scope "function", czyli wykonuje się osobno dla każdego testu. Można to zmienić, jeśli inicjalizacja jest droga i nie ma potrzeby powtarzania jej co chwilę.
function– osobno dla każdego testu (domyślnie),class– raz na klasę testową,module– raz na plik z testami,session– raz na całe uruchomienie pytesta.
@pytest.fixture(scope="module")
def shared_db():
db = FakeDB()
db.connect()
yield db
db.close()
Przy scope „module” wszystkie testy w danym pliku dostają tę samą instancję. Trzeba tylko pilnować, aby testy nie wchodziły sobie w paradę stanem – inaczej problemy są bardzo „kreatywne” i trudne do odtworzenia.
Parametryzowane fixtures – różne warianty tego samego zasobu
Fixture może zwracać różne warianty tego samego obiektu. Używa się do tego argumentu params:
@pytest.fixture(params=[1, 2, 3])
def number(request):
return request.param
def test_number_is_positive(number):
assert number > 0
Ten jeden test uruchomi się trzy razy – dla 1, 2 i 3. Działa to również dla bardziej złożonych obiektów, np. różnych konfiguracji klienta API.
@pytest.fixture(
params=[
{"vip": True, "points": 0},
{"vip": False, "points": 150},
]
)
def discount_eligible_customer(request):
from customers import Customer
cfg = request.param
c = Customer("Test", vip=cfg["vip"])
c.add_points(cfg["points"])
return c
def test_customer_is_eligible_for_discount(discount_eligible_customer):
assert discount_eligible_customer.is_eligible_for_discount() is True
Jeden test opisuje wspólne zachowanie, a parametry określają, skąd się bierze to samo zachowanie – inny zestaw własności obiektu, ale identyczny efekt końcowy.
Fixtures zależne od innych fixtures
Fixtures mogą używać innych fixtures tak samo jak testy – przez parametr funkcji. Dzięki temu można złożyć bardziej złożony setup z prostszych klocków.
@pytest.fixture
def base_dir(tmp_path):
config_dir = tmp_path / "config"
config_dir.mkdir()
return config_dir
@pytest.fixture
def app_config_file(base_dir):
config_file = base_dir / "app.conf"
config_file.write_text("DEBUG=1n", encoding="utf-8")
return config_file
def test_app_reads_config(app_config_file):
# przykład: funkcja, która czyta config z podanej ścieżki
from config import read_config
config = read_config(app_config_file)
assert config["DEBUG"] == "1"
Jeśli kiedyś zmieni się sposób tworzenia base_dir, wszystkie zależne fixtures i testy korzystają automatycznie z nowej wersji.
Organizacja fixtures w większym projekcie
conftest.py – centralne miejsce na współdzielone fixtures
Aby fixture była dostępna w wielu plikach z testami, umieszcza się ją w pliku conftest.py. Nie trzeba jej importować – pytest sam to ogarnia po nazwie.
project/
app/
__init__.py
core.py
api.py
tests/
conftest.py
test_core.py
test_api.py
# tests/conftest.py
import pytest
from app.core import create_app
@pytest.fixture(scope="session")
def app():
return create_app(testing=True)
@pytest.fixture
def client(app):
return app.test_client()
# tests/test_api.py
def test_healthcheck_endpoint(client):
response = client.get("/health")
assert response.status_code == 200
client i app są widoczne we wszystkich plikach w katalogu tests/, bez importów. Jeśli projekt rośnie, można mieć więcej niż jeden conftest.py w podkatalogach, co pozwala rozdzielić fixtures specyficzne dla danego modułu.
Modułowe conftest.py – porządek przy większej ilości testów
Przy większej bazie testów przydaje się podział na podkatalogi:
tests/
conftest.py # fixtures ogólne
unit/
conftest.py # fixtures dla testów jednostkowych
test_core.py
integration/
conftest.py # fixtures dla testów integracyjnych
test_api_integration.py
Pytest szuka conftest.py „w górę” drzewa katalogów. Dzięki temu test w tests/integration/ ma dostęp zarówno do fixtures z lokalnego conftest.py, jak i tego wyżej.
Nazywanie fixtures – czytelność ponad sprytne skróty
Nazwa fixture powinna mówić, co dostarcza. Krótkie, ale zrozumiałe nazwy:
db_sessionzamiastdbs,api_clientzamiastclient_v2,user_factoryzamiastfactory.
Jeśli w projekcie jest dużo podobnych fixtures, dobrym pomysłem jest prefiksowanie (np. anon_client, auth_client) zamiast jednej fixture przepakowanej parametrami, które i tak trzeba pamiętać w głowie.
Parametryzacja testów w pytest – więcej przypadków, mniej kopiuj-wklej
Podstawowa parametryzacja z @pytest.mark.parametrize
Najczęstszy powód kopiowania testów to „ten sam test, ale z innymi danymi wejściowymi”. Pytest rozwiązuje to dekoratorem parametrize.
# test_discounts_parametrized.py
import pytest
from discounts import calculate_discount
@pytest.mark.parametrize(
"price,customer_type,expected",
[
(100, "vip", 80),
(100, "regular", 90),
(100, "new", 100),
],
)
def test_calculate_discount(price, customer_type, expected):
assert calculate_discount(price, customer_type) == expected
Trzy przypadki testowe, jedna funkcja. Przy rozszerzaniu tabelki dopisujesz tylko nowy wiersz.
Nazywanie przypadków – parametr ids
Domyślnie pytest nazywa przypadki testowe według wartości parametrów, co czasem bywa mało czytelne. ids pozwala nadać imiona tym wariantom.
@pytest.mark.parametrize(
"price,customer_type,expected",
[
(100, "vip", 80),
(100, "regular", 90),
(100, "new", 100),
],
ids=["vip-20%", "regular-10%", "no-discount"],
)
def test_calculate_discount(price, customer_type, expected):
assert calculate_discount(price, customer_type) == expected
Na liście testów zobaczysz wtedy np. test_calculate_discount[vip-20%]. Przy długich listach przypadków znacznie łatwiej zorientować się, co padło.
Parametryzacja z obiektami i słownikami
Parametry nie muszą być prostymi typami. Można przekazywać słowniki, namedtuple, a nawet własne klasy. Przy bardziej złożonych danych często wygodniej jest parametryzować jednym argumentem – strukturą wejścia – i jednym oczekiwanym wynikiem.
import pytest
from api_formatters import format_user_payload
@pytest.mark.parametrize(
"user,expected_active",
[
({"id": 1, "first_name": "Jan", "last_name": "Kowalski"}, True),
({"id": 2, "first_name": "Anna", "last_name": "Nowak", "disabled": True}, False),
],
ids=["active-by-default", "explicitly-disabled"],
)
def test_format_user_payload_is_active(user, expected_active):
payload = format_user_payload(user)
assert payload["is_active"] is expected_active
Dane wejściowe rosną? Dokładasz kolejny słownik w tabelce, bez mnożenia funkcji testowych.
Łączenie parametryzacji testu z parametryzowanymi fixtures
Parametry można mieć jednocześnie w dekoratorze testu i w fixture. Pytest robi wtedy iloczyn kartezjański wszystkich kombinacji.
Łączenie wielu @pytest.mark.parametrize na jednym teście
Jeśli jeden test ma kilka niezależnych wymiarów danych, wygodniej rozbić je na kilka dekoratorów zamiast jednej gigantycznej tabelki.
import pytest
from shipping import calculate_shipping_cost
@pytest.mark.parametrize("country,base_cost", [
("PL", 10),
("DE", 15),
])
@pytest.mark.parametrize("weight", [0.5, 2.0, 10.0])
def test_calculate_shipping_cost(country, base_cost, weight):
cost = calculate_shipping_cost(country=country, weight=weight)
assert cost >= base_cost
Pytest złoży wszystkie kombinacje: 2 kraje × 3 wagi = 6 przypadków testowych. Logika biznesowa pozostaje jedna, a zestaw danych łatwo rozszerzać w każdym wymiarze niezależnie.
Parametryzacja tylko części argumentów – reszta z fixtures
Parametryzacja nie musi obejmować wszystkiego. Dodatkowe zależności spokojnie biorą się z fixtures.
import pytest
from discounts import calculate_discount_for_user
@pytest.fixture
def vip_user():
from users import User
return User(id=1, vip=True)
@pytest.mark.parametrize(
"price,expected",
[
(50, 40),
(100, 80),
],
)
def test_discount_for_vip_user(vip_user, price, expected):
assert calculate_discount_for_user(price, vip_user) == expected
Użytkownik z fixture, ceny z parametryzacji. Gdy zmieni się model użytkownika, dotykasz tylko fixture, a nie wszystkich testów.
Parametryzacja z pytest.param i oznaczaniem przypadków
Od czasu do czasu wśród „normalnych” danych trafia się przypadek specjalny: znany błąd, flaky test, coś platformowo-specyficznego. Do takich zastosowań przydaje się pytest.param.
import sys
import pytest
from taxes import calculate_tax
@pytest.mark.parametrize(
"amount,rate,expected",
[
(100, 0.23, 23),
(200, 0.08, 16),
pytest.param(
0,
0.23,
0,
marks=pytest.mark.xfail(reason="stary bug: dzielenie przez zero"),
),
pytest.param(
100,
0.23,
23,
marks=pytest.mark.skipif(
sys.platform == "win32",
reason="dziwne zaokrąglanie na Windows",
),
),
],
)
def test_calculate_tax(amount, rate, expected):
assert calculate_tax(amount, rate) == expected
Pojedyncze wiersze można oznaczyć jako oczekiwane niepowodzenie (xfail) albo pominąć (np. dla konkretnej platformy). Lista przypadków pozostaje w jednym miejscu, ale zachowanie każdego można subtelnie dopasować.
Parametryzacja na poziomie klasy testowej
Przy większych zestawach testów, które różnią się wyłącznie danymi lub konfiguracją, dobrze działa parametryzacja klasy.
import pytest
from payments import PaymentProcessor
@pytest.mark.parametrize(
"gateway_url,timeout",
[
("https://sandbox.example.com", 1),
("https://prod.example.com", 3),
],
)
class TestPaymentProcessor:
def test_init(self, gateway_url, timeout):
p = PaymentProcessor(gateway_url, timeout=timeout)
assert p.gateway_url.startswith("https")
def test_timeout(self, gateway_url, timeout):
p = PaymentProcessor(gateway_url, timeout=timeout)
assert p.timeout == timeout
Każda metoda w klasie zostanie uruchomiona dla wszystkich zestawów parametrów. Unika się dublowania tych samych dekoratorów nad każdą funkcją.
Parametryzacja na poziomie fixtures zamiast testów
Czasem zamiast parametryzować kilka testów tą samą tabelką wygodniej zrzucić to na fixture.
import pytest
from shipping import CarrierClient
@pytest.fixture(params=["dhl", "ups", "fedex"], ids=["DHL", "UPS", "FedEx"])
def carrier_client(request):
return CarrierClient.for_name(request.param)
def test_carrier_client_has_name(carrier_client):
assert carrier_client.name
def test_carrier_client_has_tracking(carrier_client):
assert callable(carrier_client.track_package)
Dwa testy, ale każdy odpali się trzykrotnie – dla wszystkich przewoźników. Tabelkę parametrów utrzymujesz w jednym miejscu, a testy czytają się jak zwykłe funkcje.
Łączenie parametryzacji fixtures i testu – uważaj na eksplozję kombinacji
Iloczyn kartezjański fixtures × parametry testu bywa zbawieniem, ale potrafi też wygenerować dziesiątki (albo setki) przypadków, których nikt nie będzie chciał oglądać w raporcie.
import pytest
@pytest.fixture(params=["PL", "DE"])
def country(request):
return request.param
@pytest.mark.parametrize("currency", ["PLN", "EUR"])
def test_country_currency_pair(country, currency):
# testujemy tylko, czy kombinacja jest wspierana
assert (country, currency) in {("PL", "PLN"), ("DE", "EUR")}
Tu mamy 2 kraje × 2 waluty = 4 kombinacje. Przy większej skali lepiej rozdzielić testy lub zawęzić zakres parametrów, zamiast uruchamiać testy dłużej niż build produkcyjny.
Wprowadzenie do mockowania – po co udawać, że coś działa
Dlaczego bez mocków testy zaczynają boleć
Test jednostkowy ma badać zachowanie kawałka kodu w izolacji. W praktyce ten kawałek zwykle:
- wywołuje zewnętrzne API,
- czyta/zapisuje pliki,
- strzela do bazy danych,
- używa zegara systemowego, generuje losowe wartości itd.
Jeśli test naprawdę zrobi request do prawdziwego API, zapisze coś na dysk i odczeka 5 sekund, żeby otrzymać odpowiedź, szybko zamieni się to w „test integracyjny przebrany za jednostkowy”. Stabilność spada, czas wykonania rośnie, a CI zaczyna się nudzić w kolejce.
Mockowanie rozwiązuje ten problem: zamiast prawdziwych zależności podstawia się obiekty udające je w kontrolowany sposób. Testuje się wtedy logikę: jak kod reaguje na odpowiedzi, błędy, timeouty – bez potrzeby faktycznego kontaktu z zewnętrznym światem.
Podstawowe narzędzie – unittest.mock
Python ma wbudowany moduł unittest.mock, który współpracuje z pytestem bez żadnych dodatków. Najczęściej używane elementy to:
Mock/MagicMock– obiekt, który przyjmuje dowolne wywołania,patch– czasowa podmiana obiektu w danym miejscu modułu,patch.object– podmiana atrybutu obiektu/klasy,side_effect– symulacja wyjątków, różnych zwrotek itd.
W duecie z pytestem najczęściej korzysta się z dekoratora @patch albo kontekstu with patch(...):.
Najważniejsza zasada: patchuj tam, gdzie obiekt jest używany
Klasyk początkujących: patchowanie miejsca importu zamiast miejsca użycia. Przykład prostego kodu:
# weather.py
import requests
def get_temperature(city: str) -> int:
response = requests.get(f"https://example.com/weather?city={city}")
response.raise_for_status()
data = response.json()
return data["temperature"]
Test, który chce uniknąć prawdziwego requesta:
from unittest.mock import patch, MagicMock
import pytest
from weather import get_temperature
@patch("weather.requests.get")
def test_get_temperature_uses_api(mock_get):
mock_response = MagicMock()
mock_response.json.return_value = {"temperature": 20}
mock_response.raise_for_status.return_value = None
mock_get.return_value = mock_response
temp = get_temperature("Warsaw")
mock_get.assert_called_once_with("https://example.com/weather?city=Warsaw")
assert temp == 20
Patch celuje w "weather.requests.get", bo to właśnie w module weather został zaimportowany requests. Gdyby w patchu użyć "requests.get", wywołanie w testowanej funkcji nadal korzystałoby z prawdziwego modułu.
Użycie kontekstu patch zamiast dekoratorów
Przy bardziej rozbudowanych setupach czy kilku patchach naraz czytelniej bywa korzystać z kontekstu.
from unittest.mock import patch, MagicMock
from weather import get_temperature
def test_get_temperature_raises_on_error():
with patch("weather.requests.get") as mock_get:
mock_response = MagicMock()
mock_response.raise_for_status.side_effect = Exception("HTTP error")
mock_get.return_value = mock_response
try:
get_temperature("Warsaw")
except Exception as exc:
assert "HTTP error" in str(exc)
else:
assert False, "expected exception"
Patch żyje tylko w obrębie bloku with. Po jego zakończeniu wszystko wraca do normy, bez ręcznego sprzątania.
Mockowanie funkcji czasowych i losowości
Kod zależny od czasu i losowości jest wyjątkowo irytujący do testowania – wyniki potrafią się zmieniać między uruchomieniami. Typowy przykład to generowanie tokenu z timestampem.
# tokens.py
import time
import secrets
def generate_token():
ts = int(time.time())
random_part = secrets.token_hex(8)
return f"{ts}-{random_part}"
Test może „zamrozić” czas i losowość:
from unittest.mock import patch
from tokens import generate_token
def test_generate_token_is_deterministic():
with patch("tokens.time.time", return_value=1234567890),
patch("tokens.secrets.token_hex", return_value="deadbeefdeadbeef"):
token = generate_token()
assert token == "1234567890-deadbeefdeadbeef"
Żadnych niespodzianek zależnych od zegara systemowego czy entropii – zawsze ten sam wynik, więc test jest powtarzalny.
Mockowanie klas i metod instancyjnych
Kiedy testowana funkcja tworzy obiekt jakiejś klasy, dobrze sprawdza się podmiana klasy na mock.
# mailer.py
from email_client import EmailClient
def send_welcome_email(user_email: str) -> bool:
client = EmailClient()
resp = client.send(to=user_email, subject="Welcome!", body="Hi!")
return resp.ok
from unittest.mock import patch, MagicMock
from mailer import send_welcome_email
@patch("mailer.EmailClient")
def test_send_welcome_email_uses_client(mock_email_client_cls):
mock_client = MagicMock()
mock_email_client_cls.return_value = mock_client
mock_client.send.return_value.ok = True
result = send_welcome_email("user@example.com")
mock_email_client_cls.assert_called_once_with()
mock_client.send.assert_called_once()
assert result is True
Podmiana dotyczy klasy EmailClient w module mailer. Zamiast prawdziwego połączenia SMTP używany jest prosty MagicMock, który można dowolnie „programować” w teście.
side_effect – symulowanie wyjątków i różnych odpowiedzi
side_effect przydaje się szczególnie przy scenariuszach błędowych oraz wtedy, gdy kolejne wywołania mają zwracać coś innego.
from unittest.mock import patch, MagicMock
import pytest
from payments import charge_card
@patch("payments.PaymentGateway.charge")
def test_charge_card_handles_temporary_error(mock_charge):
mock_charge.side_effect = [Exception("temporary error"), MagicMock(success=True)]
success = charge_card("4111...", 100)
# pierwsze wywołanie rzuca wyjątek, drugie się udaje
assert success is True
assert mock_charge.call_count == 2
Scenariusz: pierwszy request nie działa, więc logika w charge_card próbuje jeszcze raz. Dzięki side_effect można to odtworzyć bez faktycznych time-outów, przerw w sieci i innych atrakcji.
Mockowanie kontekstu – np. plików – zamiast prawdziwego I/O
Dla prostych operacji na plikach wygodny bywa mock_open z unittest.mock. Dobry przykład to kod wczytujący konfigurację tekstową.
# loader.py
def read_first_line(path: str) -> str:
with open(path, encoding="utf-8") as f:
return f.readline().strip()
from unittest.mock import mock_open, patch
from loader import read_first_line
def test_read_first_line_uses_open():
m = mock_open(read_data="hellonworldn")
with patch("loader.open", m):
line = read_first_line("dummy.txt")
assert line == "hello"
m.assert_called_once_with("dummy.txt", encoding="utf-8")
Zero prawdziwych plików, a zachowanie funkcji odtwarzane wiernie. Przy bardziej zaawansowanych scenariuszach (prawa dostępu, katalogi tymczasowe) nadal wygodne jest tmp_path / tmp_path_factory z pytesta, ale prostą konfigurację tekstową da się w pełni obsłużyć mockami.
Integracja mocków z fixtures w pytest
Powtarzające się patchowanie tego samego modułu lub klasy szybko zaczyna męczyć. Zamiast kopiować fragmenty z patch(...), można zawinąć je w fixture i używać jak każdego innego zasobu.
# tests/conftest.py
import pytest
from unittest.mock import patch, MagicMock
@pytest.fixture
def mocked_gateway():
with patch("payments.PaymentGateway") as gateway_cls:
instance = MagicMock()
instance.charge.return_value.success = True
gateway_cls.return_value = instance
yield instance
# tests/test_payments.py
from payments import charge_card
def test_charge_card_uses_gateway(mocked_gateway):
ok = charge_card("4111...", 100)
mocked_gateway.charge.assert_called_once()
assert ok is True
Fixture zajmuje się patchowaniem i konfiguracją mocka. Test widzi już tylko przygotowaną instancję, którą może asertywnie przepytywać.
Wykorzystanie wbudowanego fixture monkeypatch
Pytest oferuje własne narzędzie do podmiany atrybutów i zmiennych środowiskowych – fixture monkeypatch. Działa podobnie do patch, ale bywa wygodniejsze w prostych przypadkach.
Najczęściej zadawane pytania (FAQ)
Po co pisać testy jednostkowe w Pythonie, skoro „przecież działa”?
Testy jednostkowe wychwytują regresje, czyli sytuacje, gdy zmiana w jednym miejscu nieświadomie psuje coś w innym. Dzięki nim refaktoryzacja przestaje być grą w rosyjską ruletkę – zmieniasz kod, odpalasz pytest i od razu widzisz, czy coś pękło.
Dodatkowo testy działają jak żywa dokumentacja. Pokazują, jak używać funkcji i klas w praktyce oraz jakie przypadki brzegowe zostały przewidziane. Przy pracy zespołowej znacząco podnoszą zaufanie do zmian – zarówno własnych, jak i tych napisanych przez kolegów z zespołu.
Czym różnią się testy jednostkowe od integracyjnych i end-to-end?
Testy jednostkowe sprawdzają pojedynczą funkcję lub klasę w izolacji. Są bardzo szybkie, łatwe do uruchomienia i skupione na logice biznesowej bez dotykania bazy danych, sieci czy systemu plików.
Testy integracyjne weryfikują współpracę kilku modułów – np. logiki biznesowej z prawdziwą bazą testową albo wywołanie endpointu API. Są wolniejsze i bardziej złożone. Testy end-to-end idą najdalej: symulują rzeczywiste zachowanie użytkownika (np. kliknięcia w UI lub żądania HTTP) i obejmują całą aplikację, ale przez to są najwolniejsze i trudniejsze w utrzymaniu. Testy jednostkowe są fundamentem, na którym te dwa wyższe poziomy w ogóle mają sens.
Kiedy testy jednostkowe naprawdę się opłacają, a kiedy można sobie odpuścić?
Największy zwrot z inwestycji widać przy projektach rozwijanych dłużej niż kilka dni, w szczególności z wieloma współautorami i złożoną logiką biznesową (rabaty, podatki, walidacje, reguły domenowe). Jeśli aplikacja integruje się z wieloma zewnętrznymi systemami, testy jednostkowe pomagają ustabilizować to, na co masz wpływ – własny kod.
Przy jednorazowym, trzy-linijkowym skrypcie testy faktycznie mogą być przesadą. Ale jeśli skrypt ma być odpalany cyklicznie, dotyka ważnych danych albo zaczyna puchnąć – kilka testów z pytest (np. funkcji parsującej plik CSV) potrafi uchronić przed nocnym debuggingiem na produkcji.
Jak zacząć z pytest w Pythonie – co muszę zainstalować i jak uruchomić testy?
Podstawowy start wygląda tak: instalujesz bibliotekę pytest komendą pip install pytest, a następnie w katalogu projektu wywołujesz po prostu pytest. Narzędzie samo znajdzie testy zgodne z konwencją nazewniczą i je uruchomi.
Do codziennej pracy przydają się też dodatkowe flagi, np. pytest -v (więcej szczegółów), pytest -k rabat (uruchamia tylko testy, których nazwa zawiera „rabat”) czy pytest -x (zatrzymuje się po pierwszym błędzie, idealne przy debugowaniu). Po kilku dniach używania wchodzi to w krew szybciej niż skróty w IDE.
Jak nazwać i gdzie trzymać testy, żeby pytest je automatycznie znalazł?
Pytest kieruje się prostymi regułami. Pliki z testami powinny nazywać się test_*.py lub *_test.py. Funkcje z testami zaczynają się od test_, a klasy testowe od Test (bez definiowania __init__). Tyle wystarczy, żeby pytest je odnalazł bez dodatkowej konfiguracji.
Popularnym układem katalogów jest osobny folder tests/, w którym lądują wszystkie testy: jednostkowe, integracyjne, a potem ewentualnie E2E. Przykładowo: project/app/... dla kodu produkcyjnego oraz project/tests/test_core.py, project/tests/test_api.py dla testów. Wtedy wystarczy wejść do katalogu głównego projektu i uruchomić pytest.
Jak wygląda prosty test jednostkowy w pytest – przykładowy kod?
Większość testów można sprowadzić do schematu arrange–act–assert: najpierw przygotowujesz dane (arrange), potem wywołujesz testowaną funkcję (act), a na końcu sprawdzasz wynik (assert). Na przykład dla funkcji calculate_tax obliczającej podatek tworzysz kwotę i stawkę, wywołujesz funkcję i asercją weryfikujesz, że wynik jest taki, jak oczekiwany.
Podobnie działa to przy funkcjach biznesowych typu calculate_discount. Piszesz osobne testy dla różnych typów klientów (vip, regular, nieznany) oraz dla niepoprawnych danych wejściowych (np. zerowa cena z oczekiwanym wyjątkiem ValueError). Taki zestaw szybko pokazuje, czy zmiana logiki rabatów nie zepsuła któregoś ze scenariuszy.
Czy testy jednostkowe spowalniają pracę, czy w praktyce przyspieszają development?
Na początku trzeba liczyć się z lekkim spadkiem tempa – dochodzi nauka pytest, ogarnięcie mockowania i wyrobienie nawyku pisania testów obok kodu. Po tym etapie krzywa zwykle się odwraca: refaktoryzacje robi się śmielej, bo każdy „odważny” ruch można natychmiast zweryfikować testami.
Testy ułatwiają też reagowanie na bugi: najpierw piszesz test odtwarzający błąd, a dopiero potem poprawiasz implementację. Dzięki temu unikasz powrotu tego samego problemu za dwa sprinty. Dla zespołów dodatkową przewagą jest szybsze wdrażanie nowych osób – testy pokazują im, jak API modułów ma się zachowywać w konkretnych sytuacjach, bez wielogodzinnych opowieści „jak ten system działa od środka”.






