Testowanie kodu w Pythonie

Materiał przygotowany z pomocą AI

Wymagania wstępne

Wpis zakłada znajomość podstaw Pythona — patrz Python - podstawy. Sekcje od „Co testujemy w rastrach” w dół odnoszą się do kodu i pojęć z Praca z rastrami w Pythonie (DEM, transformacja afiniczna, CRS) — warto najpierw przeczytać tamten wpis lub mieć go pod ręką.

Środowisko do uruchomienia

Bloki kodu zostały zweryfikowane w Pythonie 3.12.12 z pytest 9.0.3, pytest-cov 7.1.0, coverage 7.14.0, numpy 2.4.4, rasterio 1.5.0. Pełna instrukcja odtworzenia środowiska: Środowisko Python dla przykładów.

Testowanie kodu to dyscyplina, której nie da się ominąć przy poważnym projekcie. Bez testów każda zmiana to ruch w ciemno — działa coś jeszcze, czy popsuło się dwie funkcje wcześniej? Dla narzędzi obliczeniowych (jak Hydrolog czy IMGWTools) ryzyko jest dwojakie: błąd merytoryczny (zły wzór) i błąd techniczny (zła implementacja poprawnego wzoru). Testy chronią przed tym drugim, a dobrze zaprojektowane chronią też przed regresjami pierwszego typu.

Ten wpis zaczyna od ogólnych podstaw (po co, jak, czym), prowadzi przez pytest w pełni — fixtures, parametryzacja, mockowanie — i kończy w specjalizacji: testowaniu kodu rastrowego. Przykłady przechodzą od jednolinijkowych funkcji do pełnego zestawu testów dla skryptu zlewni z Praca z rastrami w Pythonie.

Po co testować

Każdy fragment kodu, którego nie przetestowałeś, ma jakieś założenia. Część z nich będzie spełniona zawsze, część przy “normalnych” danych, a część rozsypie się przy pierwszym pliku, który ma w nazwie polski znak. Test to zapis jednego konkretnego założenia — wprost, w kodzie, w sposób uruchamiany jednym poleceniem.

Co dają testy

  • Wczesne wykrycie błędu — taniej naprawić błąd, gdy świeżo go popełniłeś, niż dwa tygodnie później, kiedy nie pamiętasz kontekstu
  • Pewność przy refaktorze — masz testy → możesz przepisywać implementację. Nie masz testów → każda zmiana to hazard
  • Dokumentacja zachowania — test pokazuje, jak funkcja powinna być wywoływana i co zwraca. Często jest czytelniejszy niż docstring
  • Decyzje projektowe — kod, który trudno przetestować, zwykle ma wadliwą strukturę (za duża funkcja, niejasne zależności, ukryty stan). Pisanie testu wymusza refaktor

Piramida testów

Klasyczna klasyfikacja (Mike Cohn, 2009):

PoziomCo testujeSzybkośćLiczba
Jednostkowe (unit)jedna funkcja / klasa, w izolacjimilisekundybardzo dużo
Integracyjne (integration)współpraca kilku komponentówsekundywiele
Systemowe / e2e (end-to-end)cały pipeline, jak użytkownikminutykilka

Piramida ma kształt piramidy nie bez powodu: testy jednostkowe są szybkie i tanie, więc piszemy ich dużo. Testy e2e są wolne i kruche, więc piszemy ich kilka — pokrywają tylko najważniejsze ścieżki. Odwrócona piramida (“lodowy róg”) — dużo e2e i mało unitów — to anty-pattern: testy są wolne, awarie trudne do diagnozy, a coverage iluzoryczny.

Co testujemy w narzędziach hydrologicznych

Dla projektów typu IMGWTools czy Kartograf typowy podział to:

  • Unit: funkcje obliczające parametry (np. srednia_arytmetyczna, przelicz_jednostki, interpoluj_brakujace)
  • Integration: parsowanie pliku → DataFrame → walidacja → zapis (kilka kroków razem, ale bez sieci)
  • E2E: pobranie z API IMGW → przetworzenie → zapis CSV (rzadziej, mockowane w testach)

W tym wpisie skupiamy się głównie na unit testach. Integration i e2e omawiamy w sekcjach o mockowaniu i pełnym przykładzie.

TDD — cykl Red, Green, Refactor

Test-Driven Development to technika pisania kodu zaczynająca się od testu. Wymyślona przez Kenta Becka pod koniec lat 90., TDD wprowadza krótki cykl trzech faz:

  1. Red — napisz test, który nie przechodzi (bo funkcji jeszcze nie ma albo jest błędna)
  2. Green — napisz najprostszą możliwą implementację, która ten test przejdzie
  3. Refactor — wyczyść kod (lub strukturę), zachowując zielony status testów

Cykl jest krótki. Robisz mały krok, sprawdzasz czy działa, robisz kolejny mały krok.

Co daje TDD

  • Projektujesz API funkcji od strony użytkownika — pierwszy klient funkcji to test. Jeśli sygnatura jest niewygodna do wywołania w teście, będzie też niewygodna w prawdziwym kodzie
  • Niski koszt zmiany kierunku — każdy zielony stan jest punktem zapisu; w razie wątpliwości wracasz do ostatniego “zielonego”
  • Naturalny coverage — kod istnieje, bo test go wymusił. Każda linia ma jakąś asercję, która ją uzasadnia

Praktyka — funkcja policz_charakterystyki

Napiszemy w TDD funkcję obliczającą trzy statystyki przepływu: minimum, maksimum, średnią. Plik docelowy: hydro.py. Plik testów: test_hydro.py.

Krok 1 — Red: pierwszy test, brak implementacji

# test_hydro.py
from hydro import policz_charakterystyki
 
 
def test_zwraca_dict_z_trzema_kluczami():
    wynik = policz_charakterystyki([10.0, 20.0, 30.0])
    assert set(wynik.keys()) == {"min", "max", "srednia"}

Uruchomienie: pytest test_hydro.py. Wynik:

ModuleNotFoundError: No module named 'hydro'

Red. Test nie przeszedł, bo modułu hydro nie ma. To jest sens fazy Red — najpierw upewniam się, że test rzeczywiście testuje, czyli że zaczyna od porażki. Test, który przechodzi natychmiast po napisaniu, jest podejrzany.

Krok 2 — Green: minimalna implementacja

# hydro.py
def policz_charakterystyki(przeplyw):
    return {"min": 0, "max": 0, "srednia": 0}

Tak — implementacja jest fałszywa, zwraca same zera. Ale test sprawdzał tylko klucze, nie wartości. Zielony status osiągnięty:

test_hydro.py::test_zwraca_dict_z_trzema_kluczami PASSED

Kent Beck nazywa to “fake it till you make it” — nie pisz więcej, niż test wymaga. Kolejny test wymusi prawdziwą logikę.

Krok 3 — Red: drugi test, sprawdza wartości

def test_oblicza_min_max_srednia_dla_trzech_wartosci():
    wynik = policz_charakterystyki([10.0, 20.0, 30.0])
    assert wynik["min"] == 10.0
    assert wynik["max"] == 30.0
    assert wynik["srednia"] == 20.0

Uruchomienie: pytest test_hydro.py. Pierwszy test dalej przechodzi, drugi:

FAILED test_hydro.py::test_oblicza_min_max_srednia_dla_trzech_wartosci
AssertionError: assert 0 == 10.0

Red. Czas na prawdziwą implementację.

Krok 4 — Green: prawdziwa logika

def policz_charakterystyki(przeplyw):
    return {
        "min": min(przeplyw),
        "max": max(przeplyw),
        "srednia": sum(przeplyw) / len(przeplyw),
    }

Oba testy zielone:

test_hydro.py::test_zwraca_dict_z_trzema_kluczami PASSED
test_hydro.py::test_oblicza_min_max_srednia_dla_trzech_wartosci PASSED

Krok 5 — Red: edge case, pusta lista

import pytest
 
 
def test_pusta_lista_rzuca_wyjatek_z_komunikatem():
    with pytest.raises(ValueError, match="nie moze byc pusty"):
        policz_charakterystyki([])

Niuans, na który warto zwrócić uwagę: min([]) w Pythonie sam rzuca ValueError z komunikatem min() iterable argument is empty. Gdybyśmy sprawdzili tylko typ wyjątku (pytest.raises(ValueError)), test przypadkowo by przeszedł — ale komunikat byłby cudzy, nie z naszej funkcji. Dlatego wzmacniamy asercję parametrem match (regex po treści wyjątku). Aktualna implementacja z kroku 4 nie pasuje (min()...empty nie zawiera nie moze byc pusty), więc test failuje. Czas dopisać własną walidację z czytelnym komunikatem.

Krok 6 — Green: walidacja wejścia

def policz_charakterystyki(przeplyw):
    if not przeplyw:
        raise ValueError("przeplyw nie moze byc pusty")
    return {
        "min": min(przeplyw),
        "max": max(przeplyw),
        "srednia": sum(przeplyw) / len(przeplyw),
    }

Wszystkie testy zielone.

Krok 7 — Refactor: docstring + type hints

Mając zielone testy, mogę bezpiecznie zmieniać implementację. Dodajemy typy i docstring w stylu NumPy (zgodnie z konwencją z Python - podstawy):

def policz_charakterystyki(przeplyw: list[float]) -> dict[str, float]:
    """Oblicza min, max i srednia z listy przeplywow.
 
    Parameters
    ----------
    przeplyw : list[float]
        Lista wartosci przeplywu w m^3/s.
 
    Returns
    -------
    dict
        Slownik z kluczami `min`, `max`, `srednia`.
 
    Raises
    ------
    ValueError
        Gdy lista przeplywow jest pusta.
 
    Examples
    --------
    >>> policz_charakterystyki([10.0, 20.0, 30.0])
    {'min': 10.0, 'max': 30.0, 'srednia': 20.0}
    """
    if not przeplyw:
        raise ValueError("przeplyw nie moze byc pusty")
    return {
        "min": min(przeplyw),
        "max": max(przeplyw),
        "srednia": sum(przeplyw) / len(przeplyw),
    }

Testy są dokładnie tymi samymi testami, które już były. Po refaktorze nadal zielone — to potwierdza, że nie zmieniliśmy zachowania, tylko czytelność.

Kiedy TDD się opłaca

Najlepiej działa w przypadkach:

  • Logiki obliczeniowej — funkcje czyste, dające się sprawdzać prostymi asercjami
  • Parsowania i walidacji — wiele edge case’ów, łatwo zapomnieć
  • Naprawy bugów — pisz test odtwarzający buga (Red), potem napraw (Green)

Słabiej w:

  • UI — trudne do testowania automatycznie
  • Eksploracji — gdy nie wiesz jeszcze, jaki ma być wynik (np. wstępna analiza danych)
  • Prototypów — kod-szkic, który i tak wyrzucisz

unittest vs pytest

W Pythonie są dwie główne biblioteki testowe.

unittest — biblioteka standardowa

unittest (znany też jako PyUnit) jest częścią biblioteki standardowej — nie wymaga instalacji. Bazuje na klasach dziedziczących z unittest.TestCase, metody testowe muszą zaczynać się od test_.

import unittest
 
 
class TestCharakterystyki(unittest.TestCase):
    def test_min_max_srednia(self):
        from hydro import policz_charakterystyki
        wynik = policz_charakterystyki([10.0, 20.0, 30.0])
        self.assertEqual(wynik["min"], 10.0)
        self.assertEqual(wynik["max"], 30.0)
        self.assertEqual(wynik["srednia"], 20.0)
 
 
if __name__ == "__main__":
    unittest.main()

Uruchomienie: python -m unittest test_hydro.py.

pytest — de facto standard

pytest to zewnętrzna biblioteka, ale w nowych projektach Pythonowych używana zdecydowanie częściej niż unittest. Ten sam test:

from hydro import policz_charakterystyki
 
 
def test_min_max_srednia():
    wynik = policz_charakterystyki([10.0, 20.0, 30.0])
    assert wynik["min"] == 10.0
    assert wynik["max"] == 30.0
    assert wynik["srednia"] == 20.0

Różnice:

  • Brak klasy TestCase — testem jest dowolna funkcja zaczynająca się od test_
  • Wbudowane assert zamiast self.assertEqual — pytest robi introspekcję asercji i pokazuje czytelne komunikaty błędu
  • Fixtures zamiast setUp/tearDown — bardziej elastyczne, można je współdzielić między plikami
  • Parametryzacja — jeden test z wieloma zestawami danych
  • Pluginy — pytest-cov, pytest-xdist, pytest-mock, hypothesis

Resztę wpisu omawiamy w pyteście.

Pierwszy test

Minimalny przykład — plik test_arytmetyka.py:

def dodaj(a, b):
    return a + b
 
 
def test_dodawanie_dwoch_dodatnich():
    assert dodaj(2, 3) == 5
 
 
def test_dodawanie_z_zerem():
    assert dodaj(0, 7) == 7
 
 
def test_dodawanie_ujemnych():
    assert dodaj(-1, -4) == -5

Uruchomienie z katalogu, w którym leży plik:

$ pytest test_arytmetyka.py
========== test session starts ==========
collected 3 items
 
test_arytmetyka.py ...                                                 [100%]
 
========== 3 passed in 0.01s ==========

Trzy kropki to trzy zielone testy. F oznaczałoby fail, E error, s skip, x xfail.

Struktura katalogu

Najczęstsze układy:

projekt/
├── hydro/                 # paczka źródłowa
│   ├── __init__.py
│   └── policz.py
└── tests/                 # testy w osobnym katalogu
    ├── __init__.py        # opcjonalny
    ├── conftest.py        # fixtures współdzielone
    ├── test_policz.py
    └── test_io.py

Alternatywnie testy mogą leżeć obok kodu źródłowego (hydro/test_policz.py) — to też działa, ale typowo separuje się je do tests/. pytest znajdzie testy w obu układach.

Asercje w pytest

Pytest używa zwykłej instrukcji assert, tej samej, co w Pythonie. Różnica polega na rewriter-ze asercji: pytest przed uruchomieniem przepisuje AST testu tak, żeby przy nieudanej asercji pokazać wartości obu stron porównania.

def test_introspekcja_asercji():
    a = [1, 2, 3]
    b = [1, 2, 4]
    assert a == b

Pytest pokaże:

>       assert a == b
E       assert [1, 2, 3] == [1, 2, 4]
E         At index 2 diff: 3 != 4

Bez specjalnych metod assertEqual / assertListEqual jak w unittest. Dla wszystkiego — od liczb po obiekty — wystarczy zwykły assert.

pytest.approx — porównanie zmiennoprzecinkowe

Liczby zmiennoprzecinkowe nie są dokładne. 0.1 + 0.2 == 0.3 daje False (wynik to 0.30000000000000004). Dla porównań floatów używaj pytest.approx:

import pytest
 
 
def test_porownanie_floatow():
    assert 0.1 + 0.2 == pytest.approx(0.3)
 
 
def test_porownanie_z_tolerancja():
    # tolerancja wzgledna (rel=) lub bezwzgledna (abs=)
    assert 100.001 == pytest.approx(100, rel=1e-4)
    assert 100.001 == pytest.approx(100, abs=0.01)
 
 
def test_porownanie_list_floatow():
    assert [0.1 + 0.2, 0.2 + 0.1] == pytest.approx([0.3, 0.3])

Domyślnie approx używa tolerancji rel=1e-6 i abs=1e-12. Dla danych naukowych zwykle wystarcza domyślna, ale przy obliczeniach numerycznych (np. gradient z floatów) czasem trzeba poluzować.

Asercje na zachowanie

Asercje mogą sprawdzać dowolny warunek logiczny, nie tylko równość:

def test_zachowanie_listy():
    przeplywy = [10.0, 20.0, 30.0]
    assert len(przeplywy) == 3
    assert 20.0 in przeplywy
    assert min(przeplywy) < max(przeplywy)
    assert all(p > 0 for p in przeplywy)

Uruchamianie pytest

Pytest ma rozbudowane CLI. Najczęstsze flagi:

pytest                              # uruchom wszystkie testy w biezacym katalogu
pytest test_hydro.py                # tylko jeden plik
pytest test_hydro.py::test_srednia  # tylko jeden test
pytest -v                           # verbose - pokaz nazwy wszystkich testow
pytest -q                           # quiet - minimalny output
pytest -x                           # zatrzymaj sie przy pierwszym FAIL
pytest --maxfail=3                  # zatrzymaj sie po 3 FAILach
pytest -k "srednia and not pusta"   # filtr po nazwie testu
pytest -m slow                      # tylko testy z markerem @pytest.mark.slow
pytest --lf                         # last failed - tylko testy ktore poprzednio failowaly
pytest --ff                         # failed first - failujace przed reszta
pytest -s                           # nie przechwytuj stdout (pokaz print-y)
pytest --tb=short                   # krotki format traceback (long/short/line/no)
pytest --collect-only               # nie uruchamiaj, tylko pokaz co znalazl

Discovery — jak pytest znajduje testy

Domyślnie pytest szuka:

  • Pliki: test_*.py lub *_test.py
  • Klasy: Test* (bez __init__)
  • Funkcje / metody: test_*

Reguły można nadpisać w pytest.ini lub pyproject.toml. W stosowanej tutaj konwencji pliki testowe nazywamy test_X.py, funkcje test_X.

Konfiguracja projektu

Konfigurację pytest umieszczamy zwykle w pyproject.toml:

[tool.pytest.ini_options]
minversion = "8.0"
testpaths = ["tests"]
python_files = "test_*.py"
markers = [
    "slow: testy trwajace > 1s",
    "integration: testy integracyjne (wymagaja sieci)",
]
addopts = "-ra --strict-markers"
  • testpaths — gdzie szukać testów
  • markers — deklaracja własnych markerów (z --strict-markers brak deklaracji powoduje błąd, co chroni przed literówkami)
  • addopts — domyślne flagi (-ra pokazuje summary fail/error, --strict-markers jak wyżej)

Fixtures

Fixture to funkcja, która przygotowuje dane (lub stan) dla testu i — opcjonalnie — sprząta po teście. Pytest “wstrzykuje” fixture do testu, gdy widzi w sygnaturze parametr o tej samej nazwie.

import pytest
 
 
@pytest.fixture
def przykladowe_przeplywy():
    return [10.0, 20.0, 30.0, 40.0, 50.0]
 
 
def test_uzywa_fixture(przykladowe_przeplywy):
    assert len(przykladowe_przeplywy) == 5
    assert sum(przykladowe_przeplywy) == 150.0
 
 
def test_tez_uzywa(przykladowe_przeplywy):
    assert min(przykladowe_przeplywy) == 10.0

Każde wywołanie testu dostaje świeży obiekt z fixture’a — pytest wywołuje funkcję przykladowe_przeplywy osobno przed każdym testem.

Scope fixture’a

Domyślnie fixture jest tworzony przed każdym testem (scope="function"). To bezpieczne, ale powolne dla dużych obiektów (np. wczytanie dużego pliku). Inne zakresy:

ScopeTworzony…Typowe użycie
"function"przed każdym testem (default)dane mutowane, świeży stan
"class"raz na klasę testowąrzadko używane
"module"raz na plik testowywczytanie pliku, kompilacja regexpa
"package"raz na pakietrzadko
"session"raz na całą sesję pytestbardzo drogie zasoby (DB, model ML)
@pytest.fixture(scope="module")
def duza_lista():
    # tworzony raz na plik - wszystkie testy w pliku dziela ten sam obiekt
    return list(range(1_000_000))

Współdzielenie obiektu między testami

Fixture z większym scope’em zwraca ten sam obiekt wielu testom. Jeśli któryś go zmutuje (np. lista.append(...)), następny test zobaczy zmodyfikowany stan. Albo używaj scope="function", albo zwracaj kopię, albo — najlepiej — projektuj fixture jako immutable (tuple zamiast listy, frozenset zamiast setu).

yield — setup i teardown

Gdy fixture musi sprzątnąć po teście (zamknąć plik, połączenie, usunąć katalog), używamy yield:

import tempfile
from pathlib import Path
 
 
@pytest.fixture
def tymczasowy_plik():
    sciezka = Path(tempfile.mkstemp(suffix=".csv")[1])
    sciezka.write_text("kolumna_1,kolumna_2\n1,2\n")
    yield sciezka          # tu test sie wykonuje
    sciezka.unlink()       # cleanup po tescie

Wszystko przed yield to setup, wszystko po — teardown. Działa też przy wyjątku w teście (jak try/finally).

Wbudowane fixtures

Pytest dostarcza kilka fixtures bez deklaracji. Najczęściej używane:

tmp_path — tymczasowy katalog

def test_zapis_do_pliku(tmp_path):
    plik = tmp_path / "wynik.txt"
    plik.write_text("dane testowe")
    assert plik.read_text() == "dane testowe"
    # po tescie pytest sam usuwa katalog

tmp_path to obiekt pathlib.Path wskazujący na unikalny katalog tymczasowy. Pytest sprząta go automatycznie (zachowuje 3 ostatnie sesje do debugowania).

capsys — przechwytywanie stdout/stderr

def funkcja_z_printem():
    print("Witaj")
    return 42
 
 
def test_print(capsys):
    wynik = funkcja_z_printem()
    captured = capsys.readouterr()
    assert captured.out == "Witaj\n"
    assert wynik == 42

monkeypatch — tymczasowa zmiana env, atrybutów, sys.path

import os
 
 
def test_zmienna_srodowiskowa(monkeypatch):
    monkeypatch.setenv("DEBEVER_TRYB", "test")
    assert os.environ["DEBEVER_TRYB"] == "test"
    # po tescie zmienna znika automatycznie

Pełne omówienie monkeypatch w sekcji Mockowanie.

Fixture z parametrem

Fixture może przyjąć parametr i zwrócić różne wartości w zależności od niego:

@pytest.fixture(params=[1, 10, 100])
def liczba(request):
    return request.param
 
 
def test_liczba_dodatnia(liczba):
    # ten test uruchomi sie 3 razy: dla 1, 10, 100
    assert liczba > 0

Pytest wygeneruje trzy testy: test_liczba_dodatnia[1], test_liczba_dodatnia[10], test_liczba_dodatnia[100].

conftest.py — fixtures współdzielone

Plik conftest.py to specjalny plik pytest. Wszystkie fixtures w nim zdefiniowane są automatycznie dostępne dla testów w tym katalogu i podkatalogach — bez importu.

tests/
├── conftest.py            # fixtures wspolne dla calego tests/
├── test_charakterystyki.py
└── rastry/
    ├── conftest.py        # fixtures wspolne tylko dla tests/rastry/
    └── test_dem.py

tests/test_charakterystyki.py zobaczy fixtures z tests/conftest.py. tests/rastry/test_dem.py zobaczy fixtures z tests/conftest.py oraz tests/rastry/conftest.py — z hierarchicznym priorytetem (bliższy plik wygrywa).

Przykład

tests/conftest.py:

import pytest
 
 
@pytest.fixture
def przykladowe_przeplywy():
    return [10.0, 20.0, 30.0, 40.0, 50.0]
 
 
@pytest.fixture
def pusta_lista():
    return []

tests/test_charakterystyki.py:

# uwaga: brak importu fixtures - pytest znajduje je automatycznie
from hydro import policz_charakterystyki
 
 
def test_dla_listy_pieciu(przykladowe_przeplywy):
    wynik = policz_charakterystyki(przykladowe_przeplywy)
    assert wynik["srednia"] == 30.0
 
 
def test_dla_pustej(pusta_lista):
    import pytest
    with pytest.raises(ValueError):
        policz_charakterystyki(pusta_lista)

conftest.py zawiera też hooki pytest, niestandardowe markery, konfigurację — ale fixtures to najczęstsze użycie.

Parametryzacja

Parametryzacja to sposób uruchomienia tego samego testu z wieloma zestawami danych. Dekorator @pytest.mark.parametrize:

import pytest
from hydro import policz_charakterystyki
 
 
@pytest.mark.parametrize("dane, oczekiwana_srednia", [
    ([10.0, 20.0, 30.0], 20.0),
    ([1.0], 1.0),
    ([0.0, 100.0], 50.0),
    ([-10.0, 10.0], 0.0),
])
def test_srednia(dane, oczekiwana_srednia):
    assert policz_charakterystyki(dane)["srednia"] == pytest.approx(oczekiwana_srednia)

Pytest wygeneruje cztery testy. Output:

test_hydro.py::test_srednia[dane0-20.0] PASSED
test_hydro.py::test_srednia[dane1-1.0] PASSED
test_hydro.py::test_srednia[dane2-50.0] PASSED
test_hydro.py::test_srednia[dane3-0.0] PASSED

Identyfikatory (ids)

Domyślne identyfikatory (dane0, dane1…) bywają niezrozumiałe. Z ids=... dajemy własne etykiety:

@pytest.mark.parametrize("dane, oczekiwana", [
    ([10.0, 20.0, 30.0], 20.0),
    ([1.0], 1.0),
    ([0.0, 100.0], 50.0),
], ids=["trzy_wartosci", "jedna_wartosc", "dwie_wartosci"])
def test_srednia_z_id(dane, oczekiwana):
    assert policz_charakterystyki(dane)["srednia"] == oczekiwana

Output:

test_hydro.py::test_srednia_z_id[trzy_wartosci] PASSED
test_hydro.py::test_srednia_z_id[jedna_wartosc] PASSED
test_hydro.py::test_srednia_z_id[dwie_wartosci] PASSED

Iloczyn kartezjański parametrów

Można nałożyć wiele parametrize na jeden test — wynikiem jest iloczyn kombinacji:

@pytest.mark.parametrize("a", [1, 2, 3])
@pytest.mark.parametrize("b", [10, 20])
def test_kombinacja(a, b):
    assert a + b > 0

Wygenerowane: 3 × 2 = 6 testów: [10-1], [10-2], [10-3], [20-1], [20-2], [20-3].

Marki i pomijanie

Markery (marks) to etykiety na testach. Służą do dwóch rzeczy: pomijania (skip / xfail) oraz organizacji (uruchamiaj tylko testy z markerem X).

Wbudowane: skip, skipif, xfail

import sys
import pytest
 
 
@pytest.mark.skip(reason="niezaimplementowane")
def test_jeszcze_nie_napisany():
    raise NotImplementedError
 
 
@pytest.mark.skipif(sys.platform == "win32", reason="nie dziala na Windows")
def test_tylko_unix():
    assert "/" in "/tmp/x"
 
 
@pytest.mark.xfail(reason="bug #42 - w trakcie naprawy")
def test_znany_bug():
    assert 1 == 2
  • skip — test nigdy się nie uruchomi, oznaczony jako s
  • skipif(warunek) — uruchom tylko, gdy warunek nie jest spełniony
  • xfail — expected failure. Jeśli test failuje, pytest pokazuje x (nie błąd). Jeśli niespodziewanie przejdzie, pokazuje X (xpassed) — sygnał, że można zdjąć marker

Własne markery

@pytest.mark.slow
def test_kosztowny():
    # symulacja wolnego testu
    import time
    time.sleep(2)
    assert True

Aby pytest -m slow filtrował testy z markerem slow, marker musi być zadeklarowany w konfiguracji:

[tool.pytest.ini_options]
markers = [
    "slow: testy trwajace > 1s",
]

Dzięki --strict-markers w addopts literówka (@pytest.mark.solw) skutkuje błędem, a nie cichym pominięciem.

Filtrowanie po markerach

pytest -m slow                     # tylko testy z @pytest.mark.slow
pytest -m "not slow"               # wszystko OPROCZ slow
pytest -m "slow and integration"   # przeciecie
pytest -m "slow or integration"    # suma

Testowanie wyjątków

Funkcja, która ma rzucać wyjątek w określonej sytuacji, też wymaga testu. pytest.raises to context manager przechwytujący wyjątek:

import pytest
from hydro import policz_charakterystyki
 
 
def test_pusta_lista_rzuca_value_error():
    with pytest.raises(ValueError):
        policz_charakterystyki([])

Jeśli policz_charakterystyki([]) nie rzuci ValueError, test failuje. Jeśli rzuci inny wyjątek (np. TypeError), też failuje — pytest.raises jest precyzyjny.

Kontrola treści komunikatu

def test_komunikat_wyjatku():
    with pytest.raises(ValueError, match="nie moze byc pusty"):
        policz_charakterystyki([])

match to regex dopasowywany do treści wyjątku. Pasuje, jeśli regex znajdzie się w komunikacie (nie musi go w pełni pokrywać).

Dostęp do obiektu wyjątku

def test_dostep_do_wyjatku():
    with pytest.raises(ValueError) as exc_info:
        policz_charakterystyki([])
    assert "pusty" in str(exc_info.value)
    assert exc_info.type is ValueError

exc_info.value to instancja wyjątku, exc_info.type to klasa, exc_info.traceback to traceback.

Asercja, że wyjątek nie jest rzucany

Brak pytest.raises w teście — sama instrukcja wywołania jest asercją, że nie rzuca:

def test_normalna_lista_nie_rzuca():
    policz_charakterystyki([1.0, 2.0, 3.0])  # gdyby rzucilo, test by failowal

Mockowanie

Mockowanie to zastępowanie rzeczywistych obiektów (funkcji, klas, modułów, zasobów zewnętrznych) ich „atrapami” — obiektami kontrolowanymi przez test. To jedna z najważniejszych technik dla testów jednostkowych: pozwala odizolować testowaną funkcję od reszty świata.

Po co mockować

Wyobraź sobie funkcję pobierającą listę stacji z API IMGW:

import requests
 
 
def pobierz_stacje():
    response = requests.get(
        "https://hydro-back.imgw.pl/map/stations/hydrologic?onlyMainStations=false",
        headers={"User-Agent": "debever.pl/1.0"},
    )
    response.raise_for_status()
    return [s["id"] for s in response.json()["stations"]]

Test bez mockowania uruchomiłby prawdziwe żądanie HTTP. To źle z czterech powodów:

  • Wolny — kilkaset ms na żądanie sieciowe
  • Niedeterministyczny — czasem 500, czasem timeout, czasem zmiana danych
  • Wymaga sieci — testy nie zadziałają offline ani w CI bez internetu
  • Obciąża cudzy serwer — niegrzeczne, jeśli testy uruchamiamy często

Rozwiązanie: w teście podstawiamy pod requests.get mock, który zwraca zaplanowaną odpowiedź. Funkcja pobierz_stacje nie wie, że ma do czynienia z mockiem — wywołuje go tak samo, jak wywołałaby prawdziwy requests.get.

monkeypatch — pytestowy sposób

monkeypatch to wbudowany fixture, który tymczasowo (na czas jednego testu) zmienia coś w środowisku: zmienną środowiskową, atrybut obiektu, element słownika, sys.path. Po teście pytest automatycznie cofa zmianę.

Zmienne środowiskowe

import os
 
 
def funkcja_czytajaca_env():
    return os.environ.get("DEBEVER_TRYB", "produkcja")
 
 
def test_zmiana_env(monkeypatch):
    monkeypatch.setenv("DEBEVER_TRYB", "test")
    assert funkcja_czytajaca_env() == "test"
 
 
def test_usuwanie_env(monkeypatch):
    monkeypatch.setenv("DEBEVER_TRYB", "test")
    monkeypatch.delenv("DEBEVER_TRYB")
    assert funkcja_czytajaca_env() == "produkcja"

Atrybuty obiektów

class Konfig:
    timeout = 30
 
 
def test_zmiana_atrybutu(monkeypatch):
    monkeypatch.setattr(Konfig, "timeout", 5)
    assert Konfig.timeout == 5
    # po tescie timeout wraca do 30

Można też ustawić atrybut na obiekcie, klasie, module — wszędzie tam, gdzie setattr działa w Pythonie.

Elementy słowników

def test_zmiana_dict(monkeypatch):
    settings = {"tryb": "produkcja", "debug": False}
    monkeypatch.setitem(settings, "tryb", "test")
    assert settings == {"tryb": "test", "debug": False}

Podmiana funkcji

To najczęstsze użycie. Zastępujemy funkcję inną funkcją (typowo lambda lub funkcją wewnątrz testu):

import requests
 
 
def pobierz_stacje():
    response = requests.get("https://example.com/api/stations")
    response.raise_for_status()
    return [s["kod"] for s in response.json()]
 
 
def test_pobierz_stacje(monkeypatch):
    class FakeResponse:
        def json(self):
            return [{"kod": "152160140"}, {"kod": "152210170"}]
 
        def raise_for_status(self):
            pass
 
    monkeypatch.setattr(requests, "get", lambda url: FakeResponse())
 
    wynik = pobierz_stacje()
    assert wynik == ["152160140", "152210170"]

Funkcja pobierz_stacje nie wie, że requests.get to lambda — wywołuje ją normalnie, dostaje FakeResponse, korzysta z .json() i .raise_for_status(). Po teście requests.get wraca do oryginału.

unittest.mock — biblioteka standardowa

unittest.mock (dostępne też jako from unittest.mock import Mock) to bogatsza biblioteka do mockowania, część stdlib. Jest niezależna od pytest — działa w unittest, pytest, w dowolnym kodzie.

Trzy główne klasy:

  • Mock — obiekt, którego dowolny atrybut i wywołanie zwraca kolejne Mock-i. Pamięta wszystkie wywołania
  • MagicMock — Mock + obsługa magicznych metod (__len__, __iter__, __getitem__ itd.)
  • patch — funkcja/dekorator/context manager, który zastępuje obiekt mock’iem na czas bloku

Mock — podstawy

from unittest.mock import Mock
 
 
def test_mock_podstawy():
    m = Mock()
 
    # dowolny atrybut zwraca kolejny Mock
    print(m.cokolwiek)            # <Mock id='...'>
    print(m.dowolny.lancuch)      # tez Mock
 
    # wywolanie tez zwraca Mock (lub okreslone return_value)
    print(m())                    # <Mock id='...'>
    print(m.foo(1, 2, x=3))       # tez Mock
 
    # mock pamieta wszystkie wywolania
    assert m.foo.called           # True - bylo wywolane
    assert m.foo.call_count == 1
    m.foo.assert_called_once_with(1, 2, x=3)

return_value — co mock zwraca

def test_return_value():
    m = Mock()
    m.return_value = 42
 
    assert m() == 42
    assert m(jakikolwiek=True) == 42
 
    # mozna tez ustawic dla atrybutu (ktory jest mock-iem)
    m.podaj_nazwe.return_value = "Warta"
    assert m.podaj_nazwe() == "Warta"

side_effect — różne zachowania

side_effect jest bogatszy niż return_value:

  • jeśli to funkcja, mock wywołuje ją z otrzymanymi argumentami i zwraca jej wynik
  • jeśli to iterowalny (lista), każde wywołanie zwraca kolejny element
  • jeśli to wyjątek (klasa lub instancja), mock go rzuca
def test_side_effect_funkcja():
    m = Mock()
    m.side_effect = lambda x: x * 2
 
    assert m(5) == 10
    assert m(7) == 14
 
 
def test_side_effect_lista():
    m = Mock()
    m.side_effect = [1, 2, 3]
 
    assert m() == 1
    assert m() == 2
    assert m() == 3
 
 
def test_side_effect_wyjatek():
    import pytest
    m = Mock()
    m.side_effect = ValueError("blad")
 
    with pytest.raises(ValueError, match="blad"):
        m()

side_effect ma pierwszeństwo nad return_value. Można je łączyć — funkcja w side_effect może zwrócić DEFAULT z unittest.mock, wtedy zwracana jest wartość z return_value.

MagicMock — magiczne metody

Mock nie obsługuje __len__, __iter__, __getitem__ itp. — dla nich jest MagicMock:

from unittest.mock import MagicMock
 
 
def test_magic_mock():
    m = MagicMock()
    m.__len__.return_value = 3
    m.__iter__.return_value = iter([10, 20, 30])
    m.__getitem__.return_value = "x"
 
    assert len(m) == 3
    assert list(m) == [10, 20, 30]
    assert m[0] == "x"

W praktyce MagicMock to “Mock plus” — jeśli nie masz pewności którego użyć, weź MagicMock. Niewielki narzut, większa elastyczność.

patch — podmiana w module

patch to “broń ciężka” do mockowania. Trzy formy:

Context manager

from unittest.mock import patch
 
 
def funkcja_uzywajaca_get():
    import requests
    response = requests.get("https://example.com/x")
    return response.status_code
 
 
def test_patch_context_manager():
    with patch("requests.get") as mock_get:
        mock_get.return_value.status_code = 200
        assert funkcja_uzywajaca_get() == 200
        mock_get.assert_called_once_with("https://example.com/x")

W bloku with requests.get jest mock-iem. Po wyjściu z bloku wraca do oryginału.

Dekorator

@patch("requests.get")
def test_patch_dekorator(mock_get):
    mock_get.return_value.status_code = 200
    assert funkcja_uzywajaca_get() == 200

Mock jest przekazywany jako pierwszy argument funkcji testowej. Wiele patch można nakładać warstwowo (kolejność argumentów jest odwrotna do kolejności dekoratorów — najbliższy dekorator daje pierwszy argument).

patch.object — patch konkretnego atrybutu

import requests
 
 
def test_patch_object():
    with patch.object(requests, "get") as mock_get:
        mock_get.return_value.status_code = 200
        assert funkcja_uzywajaca_get() == 200

patch.object(obj, "atrybut") jest bardziej eksplicytne niż patch("ścieżka.jako.string") — IDE / linter widzą, co jest patchowane. Polecane, gdy obiekt jest już zaimportowany w teście.

patch.dict — patch słownika

import os
 
 
def test_patch_dict():
    with patch.dict(os.environ, {"DEBEVER_TRYB": "test"}, clear=False):
        assert os.environ["DEBEVER_TRYB"] == "test"

Z clear=True całe os.environ jest na czas bloku puste (tylko DEBEVER_TRYB). Bez clear — pozostałe klucze zachowane.

Gdzie patchować — najczęstsza pułapka

Reguła: patchuj tam, gdzie obiekt jest używany, nie tam, gdzie jest zdefiniowany.

To źródło wielu problemów z mockowaniem. Załóżmy:

# moj_modul.py
import requests
 
 
def pobierz():
    return requests.get("https://example.com").text
# test_moj_modul.py
from unittest.mock import patch
from moj_modul import pobierz
 
 
# zle - patch w miejscu definicji
def test_zle_patchowanie():
    with patch("requests.get") as mock:
        mock.return_value.text = "fake"
        # to zadziala, ale tylko bo requests jest wspoldzielony
        assert pobierz() == "fake"
 
 
# dobrze - patch w miejscu uzycia
def test_dobre_patchowanie():
    with patch("moj_modul.requests") as mock_requests:
        mock_requests.get.return_value.text = "fake"
        assert pobierz() == "fake"

Dlaczego? Bo moj_modul.py zrobił import requests — w jego namespace requests to referencja do modułu. Gdy pobierz() wywołuje requests.get, czyta z moj_modul.requests (nie z globalnego requests). Patchując moj_modul.requests przekierowujesz dokładnie tę referencję.

Jeszcze gorzej z from:

# moj_modul.py
from requests import get
 
 
def pobierz():
    return get("https://example.com").text

Tu moj_modul.get jest kopią referencji do requests.get. patch("requests.get") jej nie ruszy. Trzeba: patch("moj_modul.get").

Reguła kciuka

Patchuj string opisujący ścieżkę jak widzi obiekt funkcja testowana, nie ścieżkę kanoniczną. Jeśli funkcja jest w hydro.io i robi import requests, patchuj "hydro.io.requests" (lub "hydro.io.requests.get" jeśli chcesz tylko get).

spec i spec_set — bezpieczne mocki

Domyślny Mock ma jedną wadę: każdy atrybut, o który go zapytasz, zwraca kolejny mock. Literówka w teście (mock.gett(...) zamiast mock.get(...)) przechodzi bez błędu — bo mock.gett to też mock.

spec ogranicza mock do interfejsu prawdziwego obiektu:

import requests
from unittest.mock import Mock
 
 
def test_spec():
    import pytest
    m = Mock(spec=requests)
    m.get.return_value.status_code = 200      # OK - requests.get istnieje
    assert m.get("https://x").status_code == 200
 
    with pytest.raises(AttributeError):
        m.gett("https://x")                    # literowka - Mock z spec rzuca AttributeError

spec_set jest jeszcze ostrzejszy — blokuje też ustawianie nowych atrybutów (m.nowy = 5 rzuca błąd).

W praktyce: dla mocków obiektów ze znanego API (np. requests.Response) warto używać spec — wykrywa literówki natychmiast.

Sprawdzanie wywołań mocka

Mock pamięta każde wywołanie. Sprawdzasz je metodami assert_*:

from unittest.mock import Mock
 
 
def test_sprawdzanie_wywolan():
    import pytest
    m = Mock()
    m(1, 2, x=3)
    m(4, 5, x=6)
 
    # czy bylo wywolane co najmniej raz
    assert m.called
    assert m.call_count == 2
 
    # ostatnie wywolanie
    m.assert_called_with(4, 5, x=6)
 
    # czy KTORES wywolanie pasuje
    m.assert_any_call(1, 2, x=3)
 
    # czy bylo tylko jedno wywolanie z konkretnymi argumentami (tu failuje)
    with pytest.raises(AssertionError):
        m.assert_called_once_with(1, 2, x=3)
 
    # nie bylo wywolane z (99, 99)
    with pytest.raises(AssertionError):
        m.assert_called_with(99, 99)
 
 
def test_call_args():
    m = Mock()
    m(1, 2, x=3)
 
    # ostatnie wywolanie
    args, kwargs = m.call_args
    assert args == (1, 2)
    assert kwargs == {"x": 3}
 
    # historia wszystkich wywolan
    m(4, 5)
    assert len(m.call_args_list) == 2

Najczęściej używane:

MetodaZnaczenie
assert_called()wywołane co najmniej raz
assert_called_once()wywołane dokładnie raz
assert_called_with(...)ostatnie wywołanie miało te argumenty
assert_called_once_with(...)wywołane raz, z tymi argumentami
assert_any_call(...)któreś wywołanie miało te argumenty
assert_not_called()nigdy nie wywołane
call_countliczba wywołań (int)
call_argsargumenty ostatniego wywołania jako (args, kwargs)
call_args_listlista wszystkich (args, kwargs)

Taksonomia podwójników testowych

Gerard Meszaros (xUnit Test Patterns, 2007) wprowadził rozróżnienie pięciu typów “atrap”:

TypCo robiPrzykład w Pythonie
Dummyprzekazywany, nigdy nie używanyNone, dowolny placeholder
Stubzwraca z góry ustalone wartości, brak weryfikacji wywołańMock(return_value=42) bez asercji
Fakedziałająca, uproszczona implementacja (in-memory DB, fake API)sqlite3 :memory: zamiast prawdziwej DB
Spyzapisuje wywołania, oryginalna funkcja działa normalniewraps= w Mock
Mockzwraca ustalone wartości + sprawdza, czy był wywołany właściwieMock z assert_called_with(...)

W praktyce w Pythonie wszystko najczęściej nazywa się “mock” (bo unittest.mock). Ale warto rozróżniać:

  • Stub — nie obchodzi nas, czy funkcja była wywołana, tylko że gdy była — zwróciła X
  • Mock — sprawdzamy wywołanie (asercje na nim)
  • Spy — chcemy widzieć wywołania, ale funkcja ma działać naprawdę

Spy — wraps

from unittest.mock import Mock
 
 
def srednia(lista):
    return sum(lista) / len(lista)
 
 
def test_spy_z_wraps():
    spy = Mock(wraps=srednia)
 
    wynik = spy([10.0, 20.0, 30.0])
 
    assert wynik == 20.0           # funkcja zadzialala normalnie
    spy.assert_called_once_with([10.0, 20.0, 30.0])   # i spy zarejestrowal wywolanie

Spy są bezcenne, gdy chcesz wykryć czy funkcja została wywołana (np. dla optymalizacji cache’u), ale nie chcesz tracić jej prawdziwego działania.

Praktyka: mockowanie HTTP do API

Przykład realistyczny — funkcja pobierająca aktualny stan wody dla wybranej stacji z API IMGW. API zwraca jednym strzałem listę wszystkich stacji hydrologicznych z bieżącymi pomiarami; filtrujemy po stronie klienta po polu id. Realna struktura pojedynczej stacji w odpowiedzi (sprawdzone na żywym endpointzie):

{
  "s": "low", "p": "30", "d": "3064", "c": "185", "o": "POZN",
  "id": "152160140", "n": "POZNAŃ-MOST ROCHA",
  "lo": 16.945, "la": 52.402
}

Pole c to aktualny stan wody w centymetrach — uwaga: API zwraca go jako string, a nie liczbę, więc konwersja na float jest niezbędna. Pole s to status (np. low, medium, high, unknown, no-water-state-data). Funkcja:

# hydro_api.py
import requests
 
 
API_URL = "https://hydro-back.imgw.pl/map/stations/hydrologic?onlyMainStations=false"
HEADERS = {"User-Agent": "debever.pl/1.0"}
 
 
class HydroAPIError(Exception):
    pass
 
 
def pobierz_stan_wody(kod_stacji: str, timeout: float = 30.0) -> float:
    """Pobiera aktualny stan wody [cm] z API IMGW dla stacji o danym kodzie.
 
    Parameters
    ----------
    kod_stacji : str
        Identyfikator stacji IMGW (np. "152160140" dla Poznan-Most Rocha).
    timeout : float, optional
        Timeout HTTP w sekundach (domyslnie 30.0).
 
    Returns
    -------
    float
        Stan wody w centymetrach.
 
    Raises
    ------
    HydroAPIError
        Gdy zadanie HTTP zawiedzie, stacja nie istnieje w odpowiedzi
        lub brakuje pola z pomiarem.
    """
    try:
        response = requests.get(API_URL, timeout=timeout, headers=HEADERS)
        response.raise_for_status()
    except requests.RequestException as exc:
        raise HydroAPIError(f"Blad pobierania stacji {kod_stacji}: {exc}") from exc
 
    dane = response.json()
    stacja = next((s for s in dane.get("stations", []) if s["id"] == kod_stacji), None)
    if stacja is None:
        raise HydroAPIError(f"Stacja {kod_stacji} nie znaleziona w API IMGW")
    if stacja.get("c") is None:
        raise HydroAPIError(f"Brak pomiaru (pole 'c') dla stacji {kod_stacji}")
 
    return float(stacja["c"])  # API zwraca pole `c` jako string

Test bez sieci — z mockowaniem requests.get w module hydro_api:

# test_hydro_api.py
import pytest
from unittest.mock import patch, MagicMock
from hydro_api import pobierz_stan_wody, HydroAPIError, API_URL, HEADERS
 
 
def _fake_response(payload, *, side_effect=None):
    fake = MagicMock()
    fake.json.return_value = payload
    if side_effect is None:
        fake.raise_for_status.return_value = None
    else:
        fake.raise_for_status.side_effect = side_effect
    return fake
 
 
def test_pobierz_zwraca_float():
    payload = {
        "stations": [
            {"id": "152160140", "n": "POZNAŃ-MOST ROCHA", "c": "185", "s": "low"},
            {"id": "152210170", "n": "WARSZAWA-BULWARY", "c": "25",  "s": "low"},
        ]
    }
    with patch("hydro_api.requests.get", return_value=_fake_response(payload)) as mock_get:
        wynik = pobierz_stan_wody("152160140")
 
    assert wynik == 185.0
    assert isinstance(wynik, float)
    mock_get.assert_called_once_with(API_URL, timeout=30.0, headers=HEADERS)
 
 
def test_pobierz_zglasza_blad_dla_500():
    import requests as _r
    fake = _fake_response({}, side_effect=_r.HTTPError("500 Server Error"))
 
    with patch("hydro_api.requests.get", return_value=fake):
        with pytest.raises(HydroAPIError, match="Blad pobierania"):
            pobierz_stan_wody("152160140")
 
 
def test_pobierz_zglasza_blad_dla_brakujacej_stacji():
    payload = {"stations": [{"id": "150210210", "n": "RADOMYŚL", "c": "229", "s": "low"}]}
 
    with patch("hydro_api.requests.get", return_value=_fake_response(payload)):
        with pytest.raises(HydroAPIError, match="nie znaleziona"):
            pobierz_stan_wody("152160140")
 
 
def test_pobierz_zglasza_blad_dla_pustego_pomiaru():
    payload = {"stations": [{"id": "152160140", "c": None, "s": "no-water-state-data"}]}
 
    with patch("hydro_api.requests.get", return_value=_fake_response(payload)):
        with pytest.raises(HydroAPIError, match="Brak pomiaru"):
            pobierz_stan_wody("152160140")
 
 
def test_pobierz_przekazuje_custom_timeout():
    payload = {"stations": [{"id": "152160140", "c": "185", "s": "low"}]}
 
    with patch("hydro_api.requests.get", return_value=_fake_response(payload)) as mock_get:
        pobierz_stan_wody("152160140", timeout=5.0)
 
    # sprawdzamy ze nasz timeout zostal przekazany do requests.get
    assert mock_get.call_args.kwargs["timeout"] == 5.0

Każdy z tych testów:

  • jest szybki — wszystko w pamięci, brak sieci
  • jest deterministyczny — dokładnie wiemy, co API “zwraca”
  • testuje konkretną ścieżkę — happy path, błąd HTTP, brak stacji w odpowiedzi, brak pomiaru, custom timeout

To pełny test “po zewnętrznej stronie” funkcji pobierz_stan_wody. Niczego nie wiemy o wewnętrznej implementacji — tylko o tym, co dzieje się na granicy z requests. Pomocnik _fake_response (prefiks _ — konwencja “prywatne”) ogranicza powtórzenia w konfiguracji mocka.

Kiedy nie mockować

Mockowanie nie jest darmowe. Ma trzy główne problemy:

  • Sprzężenie testu z implementacją — mock.assert_called_with(...) zaszywa w teście wiedzę, jak funkcja jest zaimplementowana. Refactor (np. zmiana sygnatury wewnętrznej) wymusza zmianę testu, mimo że zachowanie się nie zmieniło
  • Iluzja pokrycia — test, w którym wszystko jest mock-iem, sprawdza, czy mock-i są poprawnie wywoływane. Nie sprawdza, czy prawdziwa funkcja działa. Może być 100% coverage i 0% realnego testowania
  • Kruchość — mock-i bez spec przyjmują dowolne wywołanie. Literówka w teście (lub w nazwie atrybutu) cicho przechodzi

Anty-pattern: “testowanie mocka”

# zle: test, ktory tylko opisuje implementacje
def test_zle(monkeypatch):
    m = Mock()
    monkeypatch.setattr("hydro.get_data", m)
    moja_funkcja()
    m.assert_called_once()   # tylko sprawdza ze cos zostalo wywolane

Reguły kciuka:

  • Nie mockuj logiki — funkcji obliczających coś, czyste (deterministyczne, bez I/O). Wywołaj je naprawdę
  • Mockuj brzeg systemu — sieć, dysk, bazę, czas, losowość, zewnętrzne API
  • Preferuj fake nad mock dla integracji — sqlite3 :memory: jako fake DB jest realniejszy niż mock SQLAlchemy
  • Patchuj jak najniżej — patch requests.get, nie pobierz_stan_wody. Test ma weryfikować logikę pobierania, nie tylko że funkcja zwraca to, co jej powiedziano

Asercje dla NumPy

Testy kodu numerycznego (np. obliczenia rastrowe) wymagają osobnego zestawu asercji — assert np.allclose(...) nie wystarcza, bo nie pokazuje pierwszego różniącego się elementu.

Biblioteka numpy.testing dostarcza zestaw asercji z czytelnymi błędami:

import numpy as np
import numpy.testing as npt
 
 
def test_equal_array():
    a = np.array([1, 2, 3])
    b = np.array([1, 2, 3])
    npt.assert_array_equal(a, b)
 
 
def test_allclose_floats():
    a = np.array([0.1 + 0.2, 1.0])
    b = np.array([0.3, 1.0])
    npt.assert_allclose(a, b, rtol=1e-7)
 
 
def test_allclose_z_nan():
    # w assert_allclose bez equal_nan nan != nan, test by failowal
    a = np.array([1.0, np.nan, 3.0])
    b = np.array([1.0, np.nan, 3.0])
    npt.assert_allclose(a, b, equal_nan=True)

NaN-y w assert_array_equal od NumPy 2.0

assert_array_equal od NumPy 2.0 traktuje NaN-y na tych samych pozycjach jako równe domyślnie — bez argumentu equal_nan (którego ta funkcja nie ma). Parametr equal_nan istnieje w assert_allclose, gdzie jest potrzebny: bez niego porównanie z NaNem failuje.

Najczęściej używane:

AsercjaSprawdza
assert_array_equal(a, b)każdy element identyczny (NaN-y w tych samych pozycjach równe od NumPy 2.0)
assert_array_almost_equal(a, b, d)równość do d miejsc dziesiętnych
assert_allclose(a, b, rtol, atol)tolerancja względna i bezwzględna
assert_array_less(a, b)każdy element a < b
assert_allclose(a, b, equal_nan=True)tolerancja + NaN-y w tych samych pozycjach jako równe

Dla pojedynczych liczb dalej można używać pytest.approx. Dla tablic — numpy.testing.

Komunikaty błędu

W przeciwieństwie do assert (a == b).all(), asercje NumPy pokazują gdzie tablice się różnią:

import numpy as np
import numpy.testing as npt
 
 
# ten test failuje - pokazuje gdzie
def test_pokazuje_roznice():
    a = np.array([[1, 2], [3, 4]])
    b = np.array([[1, 2], [3, 5]])
    npt.assert_array_equal(a, b)

Komunikat (NumPy 2.x):

Arrays are not equal
Mismatched elements: 1 / 4 (25%)
Max absolute difference among violations: 1
Max relative difference among violations: 0.2
 ACTUAL: array([[1, 2],
       [3, 4]])
 DESIRED: array([[1, 2],
       [3, 5]])

Co testujemy w rastrach

Raster to dwa składniki: macierz danych (NumPy ndarray) i metadane przestrzenne (transformacja afiniczna, CRS, NoData). Testy rastrów dzielą się na trzy kategorie:

1. Metadane

Sprawdzenie, że plik ma poprawną geometrię — niezależnie od wartości pikseli:

  • shape (rzędy × kolumny) zgodne z oczekiwaniem
  • dtype zgodny z deklarowanym (np. DEM jako float32, klasyfikacja jako uint8)
  • crs to ten układ, którego się spodziewamy
  • transform ma właściwy rozmiar piksela i origin
  • nodata ustawione i zgodne z konwencją

To są często najszybsze i najbardziej wartościowe testy. Wykrywają literówki i zamiany typów bez wczytywania całych danych.

2. Wartości pikseli (niezmienniki)

Sprawdzenie reguł, które muszą być spełnione przez każdy wynik funkcji — niezależnie od konkretnych wartości wejściowych:

  • klasy w klasyfikacji są zawsze z zakresu {1, 2, 3, 4} (a nie np. 0 lub 5)
  • nachylenie jest zawsze
  • maska binarna ma tylko 0 i 1
  • DEM dla syntetycznej zlewni z V-doliną ma minimum w środku każdego rzędu
  • liczba pikseli w maskach pokrycia terenu zsumowana = width × height

Niezmienniki są mocniejsze niż “test wartości X w pikselu (3, 5)” — chronią przed regresjami przy zmianie danych testowych.

3. Wartości konkretne (gdy znane)

Dla małych, deterministycznych rastrów (jak nasz syntetyczny DEM 10×10) możemy sprawdzić wprost konkretne piksele. Dla rzeczywistych rastrów (ortofoto, NMT z GUGiK) rzadko ma to sens — zamiast tego sprawdzamy niezmienniki.

Schemat testów rastrowych

Dla każdej funkcji rastrowej zwykle piszemy testy w trzech warstwach:

test_funkcji_rastrowej:
├── test_shape_i_dtype           # metadane
├── test_zakres_wartosci         # niezmienniki min/max
├── test_konkretna_wartosc       # punktowa weryfikacja (mały raster)
└── test_round_trip              # zapis i odczyt nie zmienia danych (tylko I/O)

Testy funkcji rastrowych in-memory

Funkcje budujące rastry w pamięci (bez I/O) są najprostsze do testowania — czyste, deterministyczne, bez efektów ubocznych. Zaczynamy od refactoru zlewnia.py z Praca z rastrami w Pythonie, żeby był lepiej testowalny.

Refactor zlewnia.py pod testowalność

Oryginalny main() (z poprzedniego wpisu) ma hardkodowane stałe:

# zlewnia.py (oryginał, fragment)
def main() -> None:
    pixel_size = 100.0
    crs = "EPSG:2180"
    transform = from_origin(west=500_000, north=400_000,
                            xsize=pixel_size, ysize=pixel_size)
    # ... save_raster(dem, "/tmp/dem.tif", ...)

Problemy:

  • Ścieżka /tmp/dem.tif jest wewnątrz funkcji — w teście nie damy tmp_path
  • pixel_size, crs, origin są stałymi — w teście nie zmienimy
  • main() robi wszystko: budowanie + zapisywanie. Nie da się przetestować osobno

Wersja testowalna:

# zlewnia.py (refactor)
from pathlib import Path
import numpy as np
import rasterio
from rasterio.transform import from_origin
 
 
def build_dem(rows: int = 10, cols: int = 10) -> np.ndarray:
    """Zwraca syntetyczny DEM jako float32.
 
    Regula: dem(i, j) = 195 - 10*i + 8*|j - srodek|, srodek = cols // 2.
    """
    srodek = cols // 2
    i, j = np.meshgrid(np.arange(rows), np.arange(cols), indexing="ij")
    return (195.0 - 10.0 * i + 8.0 * np.abs(j - srodek)).astype(np.float32)
 
 
def classify_landcover(dem: np.ndarray) -> np.ndarray:
    """Reklasyfikuje DEM na klasy pokrycia terenu (uint8, klasy 1-4)."""
    return np.where(dem < 130, 1,
             np.where(dem < 170, 2,
               np.where(dem < 210, 3, 4))).astype(np.uint8)
 
 
def compute_slope_deg(dem: np.ndarray, pixel_size: float) -> np.ndarray:
    """Oblicza nachylenie w stopniach z gradientu DEM."""
    gy, gx = np.gradient(dem, pixel_size)
    slope_tan = np.sqrt(gy**2 + gx**2)
    return np.degrees(np.arctan(slope_tan)).astype(np.float32)
 
 
def detect_stream(dem: np.ndarray) -> np.ndarray:
    """Maska cieku: najnizsza komorka w kazdym wierszu (uint8, 0/1)."""
    stream_col = dem.argmin(axis=1)
    mask = np.zeros_like(dem, dtype=np.uint8)
    mask[np.arange(dem.shape[0]), stream_col] = 1
    return mask
 
 
def save_raster(data: np.ndarray, path: Path, transform, crs: str, nodata) -> None:
    """Zapisuje 1-pasmowy raster jako GeoTIFF z kompresja LZW."""
    with rasterio.open(
        path, "w",
        driver="GTiff",
        height=data.shape[0],
        width=data.shape[1],
        count=1,
        dtype=data.dtype,
        crs=crs,
        transform=transform,
        nodata=nodata,
        compress="lzw",
    ) as dst:
        dst.write(data, 1)
 
 
def run(
    output_dir: Path,
    pixel_size: float = 100.0,
    crs: str = "EPSG:2180",
    origin: tuple[float, float] = (500_000.0, 400_000.0),
) -> dict[str, Path]:
    """Generuje DEM + 3 mapy pochodne, zapisuje do output_dir."""
    output_dir = Path(output_dir)
    output_dir.mkdir(parents=True, exist_ok=True)
 
    transform = from_origin(west=origin[0], north=origin[1],
                            xsize=pixel_size, ysize=pixel_size)
 
    dem = build_dem()
    landcover = classify_landcover(dem)
    slope = compute_slope_deg(dem, pixel_size)
    stream = detect_stream(dem)
 
    paths = {
        "dem":       output_dir / "dem.tif",
        "landcover": output_dir / "landcover.tif",
        "slope":     output_dir / "slope.tif",
        "stream":    output_dir / "stream.tif",
    }
    save_raster(dem,       paths["dem"],       transform, crs, nodata=-9999.0)
    save_raster(landcover, paths["landcover"], transform, crs, nodata=0)
    save_raster(slope,     paths["slope"],     transform, crs, nodata=-9999.0)
    save_raster(stream,    paths["stream"],    transform, crs, nodata=255)
 
    return paths
 
 
if __name__ == "__main__":
    run(Path("/tmp"))

Zmiany:

  • build_dem przyjmuje rows, cols — można generować mniejsze rastry w testach
  • run(output_dir, pixel_size=, crs=, origin=) jest “mainem” sparametryzowanym
  • run zwraca dict[str, Path] z mapowaniem nazwa → ścieżka — test wie, gdzie szukać plików
  • save_raster przyjmuje Path zamiast str — typowanie

Każda zmiana jest mała i nie zmienia zachowania — można ją zrobić bezpiecznie, mając testy.

Testy funkcji rastrowych

# test_zlewnia_inmemory.py
import numpy as np
import numpy.testing as npt
from zlewnia import build_dem, classify_landcover, compute_slope_deg, detect_stream
 
 
def test_build_dem_shape_i_dtype():
    dem = build_dem()
    assert dem.shape == (10, 10)
    assert dem.dtype == np.float32
 
 
def test_build_dem_zakres_wartosci():
    dem = build_dem()
    assert dem.min() == 105.0
    assert dem.max() == 235.0
 
 
def test_build_dem_minimum_w_kolumnie_srodkowej():
    """Niezmiennik: dolina V - najnizsza komorka w kazdym rzedzie to kolumna srodkowa."""
    dem = build_dem()
    for rzad in range(10):
        assert dem[rzad].argmin() == 5  # cols // 2 = 5
 
 
def test_build_dem_spadek_polnoc_poludnie():
    """Niezmiennik: kazdy wiersz nizej o stalą roznice w kazdej kolumnie."""
    dem = build_dem()
    for kolumna in range(10):
        roznice = np.diff(dem[:, kolumna])
        npt.assert_allclose(roznice, -10.0)  # kazda komorka nizej o 10 m
 
 
def test_build_dem_wymiar_konfigurowalny():
    dem_5x5 = build_dem(rows=5, cols=5)
    assert dem_5x5.shape == (5, 5)
    assert dem_5x5.dtype == np.float32
 
 
def test_classify_landcover_dtype_uint8():
    dem = build_dem()
    landcover = classify_landcover(dem)
    assert landcover.dtype == np.uint8
 
 
def test_classify_landcover_klasy_w_zakresie():
    """Niezmiennik: tylko klasy 1, 2, 3, 4."""
    dem = build_dem()
    landcover = classify_landcover(dem)
    unikalne = set(np.unique(landcover).tolist())
    assert unikalne.issubset({1, 2, 3, 4})
 
 
def test_classify_landcover_rozklad_dla_syntetycznej_zlewni():
    dem = build_dem()
    landcover = classify_landcover(dem)
    klasy, liczby = np.unique(landcover, return_counts=True)
    rozklad = dict(zip(klasy.tolist(), liczby.tolist()))
    assert rozklad == {1: 11, 2: 39, 3: 39, 4: 11}
 
 
def test_classify_landcover_suma_pikseli():
    """Niezmiennik: kazdy piksel nalezy do dokladnie jednej klasy."""
    dem = build_dem()
    landcover = classify_landcover(dem)
    assert landcover.size == 100  # 10x10
    _, liczby = np.unique(landcover, return_counts=True)
    assert liczby.sum() == 100
 
 
def test_compute_slope_deg_dtype_i_shape():
    dem = build_dem()
    slope = compute_slope_deg(dem, pixel_size=100.0)
    assert slope.shape == dem.shape
    assert slope.dtype == np.float32
 
 
def test_compute_slope_deg_nieujemne():
    """Niezmiennik: nachylenie zawsze >= 0."""
    dem = build_dem()
    slope = compute_slope_deg(dem, pixel_size=100.0)
    assert (slope >= 0).all()
 
 
def test_compute_slope_deg_rozsadny_zakres():
    """Niezmiennik: dla syntetycznej zlewni nachylenie miesci sie w 5-10 stopni."""
    dem = build_dem()
    slope = compute_slope_deg(dem, pixel_size=100.0)
    assert 5.0 < slope.min() < 10.0
    assert 5.0 < slope.max() < 10.0
 
 
def test_compute_slope_deg_pixel_size_wplywa_na_wynik():
    """Im wiekszy piksel, tym lagodniejsze nachylenie (te same wysokosci na wiekszym dystansie)."""
    dem = build_dem()
    slope_100 = compute_slope_deg(dem, pixel_size=100.0)
    slope_200 = compute_slope_deg(dem, pixel_size=200.0)
    # przy 2x wiekszym pikselu nachylenie powinno byc okolo 2x mniejsze
    npt.assert_allclose(slope_200.mean(), slope_100.mean() / 2.0, rtol=0.01)
 
 
def test_detect_stream_dtype_uint8():
    dem = build_dem()
    stream = detect_stream(dem)
    assert stream.dtype == np.uint8
 
 
def test_detect_stream_binarna():
    """Niezmiennik: tylko 0 lub 1."""
    dem = build_dem()
    stream = detect_stream(dem)
    unikalne = set(np.unique(stream).tolist())
    assert unikalne.issubset({0, 1})
 
 
def test_detect_stream_jeden_piksel_na_wiersz():
    """Niezmiennik: ciek to dokladnie jeden piksel w kazdym wierszu."""
    dem = build_dem()
    stream = detect_stream(dem)
    suma_w_wierszach = stream.sum(axis=1)
    npt.assert_array_equal(suma_w_wierszach, np.ones(10, dtype=np.uint8))
 
 
def test_detect_stream_w_kolumnie_srodkowej():
    """Dla syntetycznej zlewni V-doliny ciek lezy w kolumnie 5."""
    dem = build_dem()
    stream = detect_stream(dem)
    npt.assert_array_equal(stream[:, 5], np.ones(10, dtype=np.uint8))
    # poza kolumna 5 same zera
    pozostale = np.delete(stream, 5, axis=1)
    assert (pozostale == 0).all()

To 18 testów dla 4 funkcji. Każdy test sprawdza jeden konkretny aspekt — nazwa testu opisuje co jest weryfikowane.

Uruchomienie: pytest test_zlewnia_inmemory.py -v powinno dać 18 passed.

Fixtures rastrowe

Powyższe testy mają jedno powtórzenie: każdy zaczyna od dem = build_dem(). Wyciągnijmy to do fixture’a.

conftest.py z fixturem DEM

# tests/conftest.py
import pytest
from zlewnia import build_dem
 
 
@pytest.fixture
def syntetyczny_dem():
    return build_dem()
 
 
@pytest.fixture
def maly_dem():
    return build_dem(rows=5, cols=5)

Testy z poprzedniej sekcji upraszczają się do:

def test_build_dem_shape_i_dtype(syntetyczny_dem):
    assert syntetyczny_dem.shape == (10, 10)
    assert syntetyczny_dem.dtype == np.float32
 
 
def test_classify_landcover_klasy_w_zakresie(syntetyczny_dem):
    landcover = classify_landcover(syntetyczny_dem)
    unikalne = set(np.unique(landcover).tolist())
    assert unikalne.issubset({1, 2, 3, 4})

Fixture z plikiem GeoTIFF

Do testów I/O potrzebujemy pliku na dysku. tmp_path daje katalog tymczasowy, a fixture wewnątrz niego zapisuje raster:

# tests/conftest.py
import pytest
from pathlib import Path
from rasterio.transform import from_origin
from zlewnia import build_dem, save_raster
 
 
@pytest.fixture
def syntetyczny_dem():
    return build_dem()
 
 
@pytest.fixture
def geotiff_dem(tmp_path, syntetyczny_dem) -> Path:
    """Zwraca sciezke do GeoTIFF z syntetycznym DEM (w tmp_path)."""
    path = tmp_path / "dem.tif"
    transform = from_origin(west=500_000, north=400_000, xsize=100.0, ysize=100.0)
    save_raster(syntetyczny_dem, path, transform, crs="EPSG:2180", nodata=-9999.0)
    return path

Test wykorzystujący ten fixture:

def test_geotiff_ma_poprawny_crs(geotiff_dem):
    import rasterio
    with rasterio.open(geotiff_dem) as src:
        assert src.crs.to_string() == "EPSG:2180"
        assert src.count == 1
        assert src.width == 10
        assert src.height == 10

Pytest sam zorganizuje kolejność: tmp_path → syntetyczny_dem → geotiff_dem → test_*. Po teście usunie katalog.

Fixture o szerszym scope dla drogich obiektów

Generowanie DEM jest tanie, ale dla większych obiektów (wczytanie pliku 100 MB) zysk z scope="session" jest znaczny:

@pytest.fixture(scope="session")
def duzy_raster_w_pamieci():
    # generowane raz na cala sesje pytest
    import numpy as np
    return np.random.RandomState(42).rand(1000, 1000).astype(np.float32)

Uwaga: testy nie mogą mutować takiego obiektu — bo zobaczą zmiany od innych testów. Dla mutowalnych danych zostaw scope="function" (default).

Round-trip I/O rastra

Round-trip test — zapis i odczyt nie zmienia danych ani metadanych. To krytyczny test dla każdej funkcji I/O.

import numpy as np
import numpy.testing as npt
import rasterio
from rasterio.transform import from_origin
from zlewnia import save_raster
 
 
def test_round_trip_zachowuje_dane(tmp_path, syntetyczny_dem):
    path = tmp_path / "test.tif"
    transform = from_origin(500_000, 400_000, 100.0, 100.0)
    save_raster(syntetyczny_dem, path, transform, crs="EPSG:2180", nodata=-9999.0)
 
    with rasterio.open(path) as src:
        odczytane = src.read(1)
 
    npt.assert_array_equal(odczytane, syntetyczny_dem)
 
 
def test_round_trip_zachowuje_dtype(tmp_path, syntetyczny_dem):
    path = tmp_path / "test.tif"
    transform = from_origin(500_000, 400_000, 100.0, 100.0)
    save_raster(syntetyczny_dem, path, transform, crs="EPSG:2180", nodata=-9999.0)
 
    with rasterio.open(path) as src:
        assert src.dtypes[0] == "float32"
        odczytane = src.read(1)
    assert odczytane.dtype == np.float32
 
 
def test_round_trip_zachowuje_transform(tmp_path, syntetyczny_dem):
    path = tmp_path / "test.tif"
    transform = from_origin(500_000, 400_000, 100.0, 100.0)
    save_raster(syntetyczny_dem, path, transform, crs="EPSG:2180", nodata=-9999.0)
 
    with rasterio.open(path) as src:
        # transform odczytany jest taki sam jak zapisany
        assert src.transform.a == 100.0     # pixel size X
        assert src.transform.e == -100.0    # pixel size Y (ujemne - Y rosnie ku polnocy)
        assert src.transform.c == 500_000   # left
        assert src.transform.f == 400_000   # top
 
 
def test_round_trip_zachowuje_nodata(tmp_path, syntetyczny_dem):
    path = tmp_path / "test.tif"
    transform = from_origin(500_000, 400_000, 100.0, 100.0)
    save_raster(syntetyczny_dem, path, transform, crs="EPSG:2180", nodata=-9999.0)
 
    with rasterio.open(path) as src:
        assert src.nodata == -9999.0
 
 
def test_round_trip_zachowuje_crs(tmp_path, syntetyczny_dem):
    path = tmp_path / "test.tif"
    transform = from_origin(500_000, 400_000, 100.0, 100.0)
    save_raster(syntetyczny_dem, path, transform, crs="EPSG:2180", nodata=-9999.0)
 
    with rasterio.open(path) as src:
        assert src.crs.to_epsg() == 2180
 
 
def test_round_trip_zachowuje_bounds(tmp_path, syntetyczny_dem):
    path = tmp_path / "test.tif"
    transform = from_origin(500_000, 400_000, 100.0, 100.0)
    save_raster(syntetyczny_dem, path, transform, crs="EPSG:2180", nodata=-9999.0)
 
    with rasterio.open(path) as src:
        assert src.bounds.left == 500_000.0
        assert src.bounds.top == 400_000.0
        assert src.bounds.right == 501_000.0    # 500_000 + 10 * 100
        assert src.bounds.bottom == 399_000.0   # 400_000 - 10 * 100

Każdy test sprawdza jeden aspekt round-tripa. Razem stanowią pełną gwarancję, że save_raster zapisuje to, co dostał.

Parametryzacja round-tripa

Sześć testów wyżej można skondensować do jednego sparametryzowanego, ale czytelność na tym traci. Jeśli zmienia się tylko pole metadanej, parametryzacja jest dobra. Jeśli zmienia się logika asercji — osobne testy.

Pełny przykład: test_zlewnia.py

Łączymy wszystko: testy in-memory, fixtures, I/O round-trip, parametryzację, mockowanie błędu zapisu. Plik testowy dla zrefaktorowanego zlewnia.py:

# test_zlewnia.py
"""Testy dla zlewnia.py - pelne pokrycie funkcji rastrowych."""
 
import numpy as np
import numpy.testing as npt
import pytest
import rasterio
from pathlib import Path
from rasterio.transform import from_origin
from unittest.mock import patch
 
from zlewnia import (
    build_dem,
    classify_landcover,
    compute_slope_deg,
    detect_stream,
    save_raster,
    run,
)
 
 
# ============================================================================
# Fixtures
# ============================================================================
 
@pytest.fixture
def syntetyczny_dem():
    return build_dem()
 
 
@pytest.fixture
def standardowy_transform():
    return from_origin(west=500_000, north=400_000, xsize=100.0, ysize=100.0)
 
 
# ============================================================================
# build_dem - niezmienniki geometrii
# ============================================================================
 
class TestBuildDem:
    def test_shape_default(self, syntetyczny_dem):
        assert syntetyczny_dem.shape == (10, 10)
 
    def test_dtype_float32(self, syntetyczny_dem):
        assert syntetyczny_dem.dtype == np.float32
 
    def test_zakres_wartosci(self, syntetyczny_dem):
        assert syntetyczny_dem.min() == 105.0
        assert syntetyczny_dem.max() == 235.0
 
    def test_konfigurowalny_rozmiar(self):
        dem = build_dem(rows=4, cols=6)
        assert dem.shape == (4, 6)
 
    @pytest.mark.parametrize("rows, cols", [(5, 5), (10, 10), (20, 8), (3, 11)])
    def test_dowolny_rozmiar(self, rows, cols):
        dem = build_dem(rows=rows, cols=cols)
        assert dem.shape == (rows, cols)
        assert dem.dtype == np.float32
 
 
# ============================================================================
# classify_landcover - reklasyfikacja DEM → klasy
# ============================================================================
 
class TestClassifyLandcover:
    def test_dtype_uint8(self, syntetyczny_dem):
        landcover = classify_landcover(syntetyczny_dem)
        assert landcover.dtype == np.uint8
 
    def test_shape_zgodny_z_dem(self, syntetyczny_dem):
        landcover = classify_landcover(syntetyczny_dem)
        assert landcover.shape == syntetyczny_dem.shape
 
    def test_klasy_w_zbiorze_1_4(self, syntetyczny_dem):
        landcover = classify_landcover(syntetyczny_dem)
        assert set(np.unique(landcover).tolist()).issubset({1, 2, 3, 4})
 
    @pytest.mark.parametrize("wysokosc, oczekiwana_klasa", [
        (100.0, 1),    # h < 130
        (129.9, 1),
        (130.0, 2),    # 130 <= h < 170
        (169.9, 2),
        (170.0, 3),    # 170 <= h < 210
        (209.9, 3),
        (210.0, 4),    # h >= 210
        (300.0, 4),
    ])
    def test_progi(self, wysokosc, oczekiwana_klasa):
        dem = np.array([[wysokosc]], dtype=np.float32)
        assert classify_landcover(dem)[0, 0] == oczekiwana_klasa
 
 
# ============================================================================
# compute_slope_deg - gradient
# ============================================================================
 
class TestComputeSlopeDeg:
    def test_dtype_i_shape(self, syntetyczny_dem):
        slope = compute_slope_deg(syntetyczny_dem, 100.0)
        assert slope.shape == syntetyczny_dem.shape
        assert slope.dtype == np.float32
 
    def test_zawsze_nieujemne(self, syntetyczny_dem):
        slope = compute_slope_deg(syntetyczny_dem, 100.0)
        assert (slope >= 0).all()
 
    def test_flat_dem_ma_zero_nachylenia(self):
        flat = np.full((5, 5), 100.0, dtype=np.float32)
        slope = compute_slope_deg(flat, 100.0)
        npt.assert_allclose(slope, 0.0)
 
    def test_pixel_size_wplywa_odwrotnie_proporcjonalnie(self, syntetyczny_dem):
        slope_100 = compute_slope_deg(syntetyczny_dem, 100.0)
        slope_200 = compute_slope_deg(syntetyczny_dem, 200.0)
        npt.assert_allclose(slope_200.mean(), slope_100.mean() / 2.0, rtol=0.01)
 
 
# ============================================================================
# detect_stream - maska cieku
# ============================================================================
 
class TestDetectStream:
    def test_dtype_uint8(self, syntetyczny_dem):
        stream = detect_stream(syntetyczny_dem)
        assert stream.dtype == np.uint8
 
    def test_binarna(self, syntetyczny_dem):
        stream = detect_stream(syntetyczny_dem)
        assert set(np.unique(stream).tolist()).issubset({0, 1})
 
    def test_jeden_piksel_na_wiersz(self, syntetyczny_dem):
        stream = detect_stream(syntetyczny_dem)
        npt.assert_array_equal(stream.sum(axis=1), np.ones(10, dtype=np.uint8))
 
 
# ============================================================================
# save_raster + round-trip
# ============================================================================
 
class TestSaveRaster:
    def test_zapis_tworzy_plik(self, tmp_path, syntetyczny_dem, standardowy_transform):
        path = tmp_path / "out.tif"
        save_raster(syntetyczny_dem, path, standardowy_transform, "EPSG:2180", -9999.0)
        assert path.exists()
        assert path.stat().st_size > 0
 
    def test_round_trip_dane(self, tmp_path, syntetyczny_dem, standardowy_transform):
        path = tmp_path / "out.tif"
        save_raster(syntetyczny_dem, path, standardowy_transform, "EPSG:2180", -9999.0)
        with rasterio.open(path) as src:
            npt.assert_array_equal(src.read(1), syntetyczny_dem)
 
    @pytest.mark.parametrize("nodata", [-9999.0, 0, 255, -1.0])
    def test_round_trip_nodata(self, tmp_path, syntetyczny_dem, standardowy_transform, nodata):
        path = tmp_path / "out.tif"
        save_raster(syntetyczny_dem, path, standardowy_transform, "EPSG:2180", nodata)
        with rasterio.open(path) as src:
            assert src.nodata == nodata
 
    def test_round_trip_crs(self, tmp_path, syntetyczny_dem, standardowy_transform):
        path = tmp_path / "out.tif"
        save_raster(syntetyczny_dem, path, standardowy_transform, "EPSG:2180", -9999.0)
        with rasterio.open(path) as src:
            assert src.crs.to_epsg() == 2180
 
 
# ============================================================================
# run() - pelny pipeline
# ============================================================================
 
class TestRun:
    def test_tworzy_cztery_pliki(self, tmp_path):
        paths = run(tmp_path)
        assert set(paths.keys()) == {"dem", "landcover", "slope", "stream"}
        for p in paths.values():
            assert p.exists()
 
    def test_pliki_maja_taka_sama_geometrie(self, tmp_path):
        paths = run(tmp_path)
        geometrie = []
        for p in paths.values():
            with rasterio.open(p) as src:
                geometrie.append((src.crs.to_epsg(), tuple(src.bounds), src.shape))
        # wszystkie cztery musza miec identyczna geometrie
        assert len(set(geometrie)) == 1
 
    def test_tworzy_katalog_jesli_nie_istnieje(self, tmp_path):
        glebszy = tmp_path / "a" / "b" / "c"
        paths = run(glebszy)
        assert glebszy.exists()
        assert paths["dem"].exists()
 
    def test_custom_pixel_size(self, tmp_path):
        paths = run(tmp_path, pixel_size=200.0)
        with rasterio.open(paths["dem"]) as src:
            assert src.transform.a == 200.0
 
    def test_custom_crs(self, tmp_path):
        paths = run(tmp_path, crs="EPSG:4326")
        with rasterio.open(paths["dem"]) as src:
            assert src.crs.to_epsg() == 4326
 
 
# ============================================================================
# Mockowanie - co gdy zapis sie nie powiedzie
# ============================================================================
 
class TestRunZBledemZapisu:
    def test_run_propaguje_blad_z_save_raster(self, tmp_path):
        """Gdy save_raster rzuca, run powinien propagowac wyjatek."""
        with patch("zlewnia.save_raster", side_effect=PermissionError("read-only fs")):
            with pytest.raises(PermissionError, match="read-only"):
                run(tmp_path)
 
    def test_run_wywoluje_save_raster_cztery_razy(self, tmp_path):
        """Run powinien zapisac dokladnie cztery rastry."""
        with patch("zlewnia.save_raster") as mock_save:
            run(tmp_path)
        assert mock_save.call_count == 4
 
    def test_run_wywoluje_save_raster_z_poprawnym_nodata(self, tmp_path):
        """Sprawdza, ze kazdy raster ma swoja konwencje NoData."""
        with patch("zlewnia.save_raster") as mock_save:
            run(tmp_path)
 
        # call_args_list ma 4 wpisy - sprawdzamy nodata w kazdym
        nodata_per_call = [c.kwargs.get("nodata") for c in mock_save.call_args_list]
        # Pozycyjne argumenty save_raster: (data, path, transform, crs, nodata)
        # Sprawdzmy pozycyjnie jezeli kwargs sa puste
        if all(n is None for n in nodata_per_call):
            nodata_per_call = [c.args[4] for c in mock_save.call_args_list]
        assert nodata_per_call == [-9999.0, 0, -9999.0, 255]

Ten plik to ~150 linii testów, ale daje:

  • 40+ testów uruchamianych w sekundzie
  • Pokrycie wszystkich funkcji + run() jako pełnego pipeline-u
  • Niezmienniki zamiast hardkodowanych wartości tam, gdzie to możliwe
  • Mockowanie w jednym miejscu — gdzie ma sens (symulacja błędu I/O)

Organizacja w klasy

Klasy TestX grupują testy tematycznie. Pytest traktuje je jak namespace — żadnych unittest.TestCase, żadnego setUp. Klasa to po prostu wygodny sposób grupowania.

Uruchomienie tylko jednej klasy:

pytest test_zlewnia.py::TestBuildDem -v
pytest test_zlewnia.py::TestRun::test_custom_pixel_size

Coverage

Code coverage to procent linii kodu (lub gałęzi, branchy) wykonanych przez testy. Narzędzie: coverage.py, integracja z pytest: pytest-cov.

Oba są w Środowisko Python dla przykładów (coverage, pytest-cov).

Uruchomienie

# z pytest-cov - najprosciej
pytest --cov=zlewnia test_zlewnia.py
 
# z raportem HTML
pytest --cov=zlewnia --cov-report=html test_zlewnia.py
# otworz htmlcov/index.html w przegladarce
 
# z brakujacymi liniami w terminalu
pytest --cov=zlewnia --cov-report=term-missing test_zlewnia.py

Przykładowy output:

---------- coverage: platform darwin, python 3.12.12 ----------
Name          Stmts   Miss  Cover   Missing
-------------------------------------------
zlewnia.py       38      1    97%   105
-------------------------------------------
TOTAL            38      1    97%
  • Stmts — liczba linii kodu
  • Miss — liczba niewykonanych
  • Cover — procent
  • Missing — które konkretnie linie nie zostały pokryte

Coverage z gałęziami (branchami)

Domyślny coverage liczy linie. --cov-branch dodaje sprawdzenie gałęzi (czy każdy if był wykonany dla obu wariantów True/False):

pytest --cov=zlewnia --cov-branch --cov-report=term-missing test_zlewnia.py

Konfiguracja w pyproject.toml

[tool.coverage.run]
source = ["zlewnia"]
branch = true
omit = ["*/tests/*", "*/__main__.py"]
 
[tool.coverage.report]
exclude_lines = [
    "pragma: no cover",
    "if __name__ == .__main__.:",
    "raise NotImplementedError",
]

Kiedy coverage pomaga, a kiedy nie

Pomaga:

  • pokazuje gdzie nie ma testów (które linie nigdy nie zostały wykonane)
  • przy refactorze daje pewność, że nie skasowałeś nikogo (linie nie pokrywane przed = nie pokrywane po)

Nie pomaga:

  • coverage 100% nie znaczy “kod działa” — można pokryć linię testem, który nie sprawdza żadnej asercji
  • coverage jako KPI tworzy złe motywacje — programiści piszą “testy” tylko po to, żeby linia była “zielona”, bez wartości

Dla bazy debever.pl: coverage to pomocnicze narzędzie do znajdowania luk, nie cel sam w sobie. Cel: testy mają sprawdzać zachowanie, nie wykonanie linii.

Dobre praktyki

AAA — Arrange, Act, Assert

Strukturyzuj test w trzech blokach:

def test_aaa():
    # Arrange - przygotuj dane
    dem = build_dem()
    pixel_size = 100.0
 
    # Act - wykonaj testowaną akcję
    slope = compute_slope_deg(dem, pixel_size)
 
    # Assert - sprawdź wynik
    assert slope.shape == dem.shape
    assert (slope >= 0).all()

Puste linie jako wizualne separatory między blokami. Czytelność rośnie znacząco.

Jedno zachowanie na test

Test sprawdza jedną rzecz. Jeśli zaczynasz pisać # i sprawdzmy jeszcze..., to znak na nowy test.

# zle - dwa zachowania w jednym tescie
def test_klasyfikacja_i_zapis(tmp_path):
    dem = build_dem()
    landcover = classify_landcover(dem)
    assert landcover.dtype == np.uint8
    save_raster(landcover, tmp_path / "lc.tif", ..., nodata=0)
    assert (tmp_path / "lc.tif").exists()
 
# dobrze - dwa osobne testy
def test_klasyfikacja_dtype():
    dem = build_dem()
    assert classify_landcover(dem).dtype == np.uint8
 
 
def test_zapis_tworzy_plik(tmp_path):
    landcover = classify_landcover(build_dem())
    save_raster(landcover, tmp_path / "lc.tif", ..., nodata=0)
    assert (tmp_path / "lc.tif").exists()

Reguła „jeden assert per test” jest nadgorliwa — test może mieć kilka asercji, jeśli wszystkie opisują to samo zachowanie (np. shape + dtype dla wyniku jednej funkcji). Ale dwa różne zachowania = dwa testy.

Niezależność testów

Testy muszą działać w dowolnej kolejności i pojedynczo. Reguła: żaden test nie może zależeć od innego testu.

Sygnały, że łamiesz tę zasadę:

  • testy działają razem, ale nie pojedynczo (pytest test_x.py::test_y failuje)
  • użycie zmiennych globalnych do “przekazania stanu” między testami
  • naprawienie jednego testu psuje drugi

Rozwiązanie: każdy test ma własne fixtures (które pytest tworzy świeże dla każdego testu).

Nazwy testów opisujące zachowanie

Słaba nazwa: test_funkcja, test_1, test_dziala.

Dobra nazwa: opisuje wejście i oczekiwany wynik:

  • test_classify_landcover_klasy_w_zbiorze_1_4
  • test_run_propaguje_blad_z_save_raster
  • test_pusta_lista_rzuca_value_error

Konwencja: test_<co_testowane>_<jak_wejscie>_<oczekiwany_wynik>. Polskie nazwy są w porządku dla projektów polskojęzycznych — Pytest indeksuje je tak samo jak angielskie.

Izolacja od sieci, dysku, czasu

W teście jednostkowym nigdy nie używaj:

  • żywej sieci (mockuj requests, urllib)
  • bazy produkcyjnej (sqlite :memory: lub testowa instancja)
  • datetime.now() (przekaż czas jako argument lub mockuj)
  • random.random() bez seeda (np.random.RandomState(42))

Każde z nich wprowadza niedeterminizm. Test, który czasem przechodzi a czasem nie, jest gorszy niż żaden test.

Anty-patterns

  • Test, który nigdy nie failuje — sprawdza coś, co zawsze prawdziwe. Bez wartości
  • Test, który testuje implementację — mock.assert_called_with(...) na każdej wewnętrznej funkcji. Każdy refactor psuje testy
  • time.sleep() w testach — zwykle sygnał, że coś jest niedeterministyczne. Zmień projekt
  • Wspólny stan między testami — moduł-level zmienne mutowane w teście. Następny test je widzi
  • Nieczytelne nazwy — test_1, test_a, test_bug

TDD jako codzienna dyscyplina

TDD nie jest uniwersalną techniką, którą zawsze warto stosować, ale:

  • Nowa logika obliczeniowa — TDD od początku. Pierwszy test → pierwsza implementacja
  • Naprawa buga — odtworz test (Red), napraw kod (Green), refactor jeśli sensowne
  • Refactor — testy muszą być zielone przed i po. Jeśli ich nie ma, dopisz przed refactorem

W projektach hydrologicznych w bazie debever.pl (Hydrolog, IMGWTools, Kartograf) TDD najmocniej sprawdza się przy:

  • nowych formułach obliczeniowych (SCS-CN, charakterystyki przepływu)
  • parserach plików (CSV IMGW, XLSX, JSON z API)
  • walidatorach (czy stacja istnieje, czy data w zakresie)

Zobacz także