Testy jednostkowe w Pythonie praktyczny tutorial pytest fixtures i mockowanie

0
45
4.5/5 - (4 votes)

Nawigacja:

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 testuCo testujeCharakterystykaPrzykład w Pythonie
JednostkowyPojedyncza funkcja/klasaBardzo szybki, silnie izolowanyTest funkcji obliczającej rabat
IntegracyjnyWspółpracę kilku modułówWolniejszy, dotyka więcej warstwWywołanie endpointu API z prawdziwą bazą testową
End-to-endCałą aplikacjęNajwolniejszy, najbardziej zbliżony do realnego użyciaSymulacja 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_*.py lub *_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 obiekt pathlib.Path,
  • tmpdir – starsza wersja, oparta na py.path,
  • capsys – przechwytywanie stdout/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.

Smartfon z wyświetlonym kodem Pythona podczas pracy nad testami
Źródło: Pexels | Autor: _Karub_ ‎

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_session zamiast dbs,
  • api_client zamiast client_v2,
  • user_factory zamiast factory.

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”.

Poprzedni artykułInteligentna opaska czy smartwatch: co lepiej mierzy sen i stres?
Następny artykułWeekend w Neapolu: co zobaczyć, gdzie nocować i co zjeść w 3 dni
Beata Woźniak
Beata Woźniak pisze o testowaniu oprogramowania i jakości procesów wytwarzania. Łączy perspektywę QA z praktyką pracy w zespołach produktowych: opisuje strategie testów, automatyzację, raportowanie błędów i budowanie sensownych metryk. W tekstach stawia na konkretne przykłady, checklisty i narzędzia, ale zawsze podkreśla rolę komunikacji i ryzyka biznesowego. Na AptekaPrima24h.pl dba o rzetelność porad, rozróżniając testy funkcjonalne, wydajnościowe i bezpieczeństwa, oraz pokazuje, jak unikać fałszywego poczucia „pokrycia”.