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):
| Poziom | Co testuje | Szybkość | Liczba |
|---|---|---|---|
| Jednostkowe (unit) | jedna funkcja / klasa, w izolacji | milisekundy | bardzo dużo |
| Integracyjne (integration) | współpraca kilku komponentów | sekundy | wiele |
| Systemowe / e2e (end-to-end) | cały pipeline, jak użytkownik | minuty | kilka |
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:
- Red — napisz test, który nie przechodzi (bo funkcji jeszcze nie ma albo jest błędna)
- Green — napisz najprostszą możliwą implementację, która ten test przejdzie
- 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.0Uruchomienie: 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.0Różnice:
- Brak klasy
TestCase— testem jest dowolna funkcja zaczynająca się odtest_ - Wbudowane
assertzamiastself.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) == -5Uruchomienie 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 == bPytest 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 znalazlDiscovery — jak pytest znajduje testy
Domyślnie pytest szuka:
- Pliki:
test_*.pylub*_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ówmarkers— deklaracja własnych markerów (z--strict-markersbrak deklaracji powoduje błąd, co chroni przed literówkami)addopts— domyślne flagi (-rapokazuje summary fail/error,--strict-markersjak 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.0Każ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:
| Scope | Tworzony… | 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 testowy | wczytanie pliku, kompilacja regexpa |
"package" | raz na pakiet | rzadko |
"session" | raz na całą sesję pytest | bardzo 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żywajscope="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 tescieWszystko 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 katalogtmp_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 == 42monkeypatch — 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 automatyczniePeł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 > 0Pytest 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"] == oczekiwanaOutput:
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 > 0Wygenerowane: 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 == 2skip— test nigdy się nie uruchomi, oznaczony jakosskipif(warunek)— uruchom tylko, gdy warunek nie jest spełnionyxfail— expected failure. Jeśli test failuje, pytest pokazujex(nie błąd). Jeśli niespodziewanie przejdzie, pokazujeX(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 TrueAby 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" # sumaTestowanie 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 ValueErrorexc_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 failowalMockowanie
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 30Moż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 kolejneMock-i. Pamięta wszystkie wywołaniaMagicMock—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() == 200Mock 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() == 200patch.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").textTu 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.ioi robiimport requests, patchuj"hydro.io.requests"(lub"hydro.io.requests.get"jeśli chcesz tylkoget).
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 AttributeErrorspec_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) == 2Najczęściej używane:
| Metoda | Znaczenie |
|---|---|
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_count | liczba wywołań (int) |
call_args | argumenty ostatniego wywołania jako (args, kwargs) |
call_args_list | lista wszystkich (args, kwargs) |
Taksonomia podwójników testowych
Gerard Meszaros (xUnit Test Patterns, 2007) wprowadził rozróżnienie pięciu typów “atrap”:
| Typ | Co robi | Przykład w Pythonie |
|---|---|---|
| Dummy | przekazywany, nigdy nie używany | None, dowolny placeholder |
| Stub | zwraca z góry ustalone wartości, brak weryfikacji wywołań | Mock(return_value=42) bez asercji |
| Fake | działająca, uproszczona implementacja (in-memory DB, fake API) | sqlite3 :memory: zamiast prawdziwej DB |
| Spy | zapisuje wywołania, oryginalna funkcja działa normalnie | wraps= w Mock |
| Mock | zwraca ustalone wartości + sprawdza, czy był wywołany właściwie | Mock 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 wywolanieSpy 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 stringTest 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.0Każ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
specprzyjmują 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 wywolaneReguł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
fakenadmockdla integracji —sqlite3 :memory:jako fake DB jest realniejszy niż mock SQLAlchemy - Patchuj jak najniżej — patch
requests.get, niepobierz_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_equalod NumPy 2.0
assert_array_equalod NumPy 2.0 traktuje NaN-y na tych samych pozycjach jako równe domyślnie — bez argumentuequal_nan(którego ta funkcja nie ma). Parametrequal_nanistnieje wassert_allclose, gdzie jest potrzebny: bez niego porównanie z NaNem failuje.
Najczęściej używane:
| Asercja | Sprawdza |
|---|---|
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 oczekiwaniemdtypezgodny z deklarowanym (np. DEM jakofloat32, klasyfikacja jakouint8)crsto ten układ, którego się spodziewamytransformma właściwy rozmiar piksela i originnodataustawione 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.0lub5) - 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.tifjest wewnątrz funkcji — w teście nie damytmp_path pixel_size,crs,originsą stałymi — w teście nie zmienimymain()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_demprzyjmujerows,cols— można generować mniejsze rastry w testachrun(output_dir, pixel_size=, crs=, origin=)jest “mainem” sparametryzowanymrunzwracadict[str, Path]z mapowaniem nazwa → ścieżka — test wie, gdzie szukać plikówsave_rasterprzyjmujePathzamiaststr— 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 pathTest 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 == 10Pytest 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 * 100Każ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_sizeCoverage
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.pyPrzykł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 koduMiss— liczba niewykonanychCover— procentMissing— 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.pyKonfiguracja 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_yfailuje) - 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_4test_run_propaguje_blad_z_save_rastertest_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
- IT — indeks narzędzi informatycznych
- Python - podstawy — fundamenty Pythona (sekcja “Testowanie” — pełna wersja tutaj)
- Środowisko Python dla przykładów — wspólne
requirements.txtzpytest,pytest-cov,coverage - Praca z rastrami w Pythonie — kontekst dla sekcji rastrowych (DEM, transform, CRS)
- SQL - podstawy — testowanie zapytań SQL (kierunek:
sqlite3 :memory:jako fake DB) - Hydrolog, IMGWTools, Kartograf — projekty, w których testy są krytyczne
- pytest documentation — dokumentacja oficjalna
- unittest.mock — biblioteka standardowa,
Mock/MagicMock/patch - coverage.py — coverage, raporty HTML/XML, konfiguracja
- pytest-cov — integracja
pytest+coverage.py