Jedna funkcja potrafi zamienić krótki test w całą serię sensownych scenariuszy bez kopiowania kodu. W tym artykule pokazuję, jak działa pytest parametrize, jak tworzyć przypadki dla wielu danych wejściowych, oznaczać je czytelnymi identyfikatorami, testować wyjątki oraz łączyć parametryzację z fixture’ami.
Jedna definicja testu może obsłużyć dziesiątki scenariuszy
- Parametryzacja uruchamia tę samą funkcję testową dla wielu zestawów danych.
- Podstawą jest dekorator @pytest.mark.parametrize.
- Argument ids poprawia czytelność raportów i ułatwia znalezienie błędnego przypadku.
- Do wyjątków, pominięć i oczekiwanych awarii służy pytest.param.
- Fixture’y można parametryzować bezpośrednio albo przez indirect=True.

Jak działa pytest parametrize w praktyce
Parametryzacja pozwala opisać test raz, a potem uruchomić go z wieloma wartościami. Zamiast pisać trzy niemal identyczne funkcje dla liczb dodatnich, zera i wartości ujemnych, przekazuję dane wejściowe oraz oczekiwany wynik do dekoratora.
import pytest
@pytest.mark.parametrize(
"a, b, expected",
[
(2, 3, 5),
(10, 5, 15),
(-1, 4, 3),
],
)
def test_addition(a, b, expected):
assert a + b == expected
Ten test zostanie wykonany trzy razy. Każdy wiersz listy dostarcza osobny zestaw argumentów, ale sama logika sprawdzenia pozostaje wspólna. To właśnie robi największą różnicę w utrzymaniu testów, bo poprawkę w asercji wprowadzam tylko w jednym miejscu.
Najważniejsza zasada jest prosta. Nazwy podane w pierwszym argumencie dekoratora muszą odpowiadać wartościom w każdym zestawie danych i występować w tej samej kolejności. Przy dwóch argumentach każdy przypadek powinien więc zawierać dokładnie dwie wartości.
Jeden argument i dane brzegowe
Najprostszy wariant sprawdza zachowanie funkcji dla pojedynczych danych. Dobrze nadaje się do walidacji, konwersji typów, filtrowania albo funkcji obsługujących graniczne wartości.
@pytest.mark.parametrize("value", [0, 1, 100, -1])
def test_is_integer(value):
assert isinstance(value, int)
Nie ograniczam się przy tym do przypadków, które powinny działać poprawnie. Zero, pusta wartość, liczba ujemna i bardzo duża liczba często ujawniają błędy, których nie widać przy testowaniu wyłącznie typowego scenariusza.
Wiele argumentów bez powielania funkcji
Gdy funkcja zwraca wynik zależny od kilku danych, wszystkie można przekazać w jednym wierszu. Przykład z rabatem pokazuje, że dane testowe mogą być czytelne nawet wtedy, gdy scenariusz ma więcej parametrów.
@pytest.mark.parametrize(
"price, discount, expected",
[
(100, 0, 100),
(100, 10, 90),
(200, 25, 150),
],
)
def test_discount(price, discount, expected):
assert calculate_discount(price, discount) == expected
W praktyce zwracam uwagę na to, by lista przypadków nie stała się przypadkowym zbiorem liczb. Każdy wiersz powinien reprezentować konkretny scenariusz biznesowy, a nie tylko zwiększać liczbę testów.
Jak przygotować czytelne przypadki testowe
Sam wynik testu to nie wszystko. Przy kilku lub kilkunastu zestawach danych komunikat w stylu test_discount[100-10-90] szybko przestaje być wygodny. Czytelne nazwy przypadków pomagają od razu zobaczyć, co właściwie się zepsuło.
Identyfikatory przypadków
Do tego służy parametr ids. Podaję listę nazw w tej samej kolejności co dane, dzięki czemu raport pytesta mówi o scenariuszu językiem zrozumiałym dla zespołu.
@pytest.mark.parametrize(
"username, expected",
[
("anna", True),
("", False),
(" bardzo-dlugi-login ", False),
],
ids=["poprawny_login", "pusty_login", "login_poza_limitem"],
)
def test_username_validation(username, expected):
assert validate_username(username) is expected
To drobna rzecz, ale bardzo pomaga podczas pracy w CI. Zamiast przeglądać cały test, od razu widzę, że nie przeszedł przypadek pustego loginu albo wartości przekraczającej limit.
Id można też przypisać bezpośrednio do konkretnego zestawu za pomocą pytest.param. Ten sposób jest wygodniejszy, gdy przypadki powstają w różnych miejscach albo nie chcę pilnować osobnej listy nazw.
@pytest.mark.parametrize(
"role, can_edit",
[
pytest.param("admin", True, id="administrator"),
pytest.param("editor", True, id="redaktor"),
pytest.param("guest", False, id="gosc"),
],
)
def test_edit_permission(role, can_edit):
assert has_edit_permission(role) is can_edit
Iloczyn kombinacji i kontrola skali
Można użyć kilku dekoratorów parametryzujących. Pytest utworzy wtedy kombinacje wartości, czyli na przykład 3 języki razy 2 typy kont = 6 testów.
@pytest.mark.parametrize("language", ["pl", "en", "de"])
@pytest.mark.parametrize("account_type", ["free", "pro"])
def test_translation(language, account_type):
assert translation_exists(language, account_type)
To przydatne przy macierzach kompatybilności, ale łatwo przesadzić. Cztery parametry po pięć wartości oznaczają już 625 kombinacji. Gdy liczba przypadków rośnie, wybieram reprezentatywne scenariusze albo rozdzielam testy na mniejsze grupy, zamiast bezrefleksyjnie sprawdzać każdą możliwą kombinację.
Testowanie wyjątków, pominięć i oczekiwanych awarii
Parametryzacja dobrze sprawdza się także wtedy, gdy część danych powinna prowadzić do wyjątku. Najczytelniej rozdzielić scenariusze poprawne od błędnych, ponieważ wtedy każda funkcja testowa ma jednoznaczny cel.
@pytest.mark.parametrize(
"value",
["", None, "abc"],
ids=["pusty_tekst", "brak_wartosci", "tekst"],
)
def test_parse_id_rejects_invalid_value(value):
with pytest.raises(ValueError):
parse_id(value)
Jeżeli różne dane wywołują różne typy wyjątków, przekazuję także oczekiwany wyjątek jako parametr. Dzięki temu test nie musi zawierać dużego łańcucha warunków.
@pytest.mark.parametrize(
"value, error",
[
("abc", ValueError),
(None, TypeError),
("999999999999999999999", OverflowError),
],
)
def test_parse_id_errors(value, error):
with pytest.raises(error):
parse_id(value)
Przeczytaj również: Pytest parametrize i fixture - Kiedy co wybrać?
Oznaczanie wyjątkowych przypadków
Czasami jeden przypadek jest znany jako niedziałający na danej platformie albo oczekuje na poprawkę. pytest.param pozwala oznaczyć go jako pominięty lub oczekiwaną porażkę, bez wyłączania całego testu.
@pytest.mark.parametrize(
"expression, expected",
[
("2 + 2", 4),
pytest.param(
"1 / 0",
None,
marks=pytest.mark.xfail(reason="Dzielenie przez zero"),
id="dzielenie_przez_zero",
),
],
)
def test_expression(expression, expected):
assert evaluate(expression) == expected
Takie oznaczenie stosuję oszczędnie. Jeśli przypadek jest stale czerwony i nikt nie wie dlaczego, xfail może ukrywać realny problem. Powód powinien być konkretny, a zespół powinien regularnie przeglądać oczekiwane awarie.
Parametryzacja fixture’ów i użycie indirect
Nie zawsze chcę przekazywać do testu surową wartość. Czasem parametr opisuje środowisko, użytkownika albo konfigurację, a dopiero fixture ma zbudować gotowy obiekt. Wtedy parametryzuję fixture za pomocą pola request.param.
@pytest.fixture(params=["sqlite", "memory"])
def database(request):
db = create_database(request.param)
yield db
db.close()
def test_user_can_be_saved(database):
user = User(name="Anna")
database.save(user)
assert database.get_user("Anna") == user
Test zostanie wykonany dla obu baz danych. To dobry wybór, gdy każdy test korzystający z fixture’a powinien sprawdzić wszystkie warianty środowiska. Trzeba jednak pamiętać, że parametryzacja fixture’a mnoży liczbę uruchomień także w innych testach, które z niego korzystają.
Druga opcja to indirect=True. W tym przypadku wartości podane w dekoratorze trafiają najpierw do fixture’a, a nie bezpośrednio do funkcji testowej.
@pytest.fixture
def user(request):
return create_user(role=request.param)
@pytest.mark.parametrize(
"user",
["admin", "guest"],
indirect=True,
ids=["administrator", "gosc"],
)
def test_user_role(user):
assert user.role in {"admin", "guest"}
| Wariant | Kiedy go użyć | Co otrzymuje test |
|---|---|---|
| Bezpośrednia parametryzacja | Gdy test sprawdza prostą wartość lub zestaw wartości | Surowe dane wejściowe |
| Parametryzowany fixture | Gdy wiele testów ma działać w różnych środowiskach | Gotowy obiekt z fixture’a |
indirect=True |
Gdy tylko wybrane testy mają przekazać parametr do fixture’a | Obiekt przygotowany przez fixture |
Ja wybieram indirect wtedy, gdy parametr opisuje sposób utworzenia obiektu, a nie sam obiekt. Dzięki temu test pozostaje prosty, a przygotowanie złożonego stanu nie trafia do listy danych testowych.
Błędy, które psują sens parametryzacji
Najczęstszy problem to nieprawidłowy kształt danych. Jeśli deklaruję trzy argumenty, każdy przypadek musi zawierać trzy wartości. Błąd pojawi się jeszcze przed właściwym wykonaniem testu, dlatego przy większych listach warto trzymać dane w czytelnej, wieloliniowej formie.
# Błędnie
@pytest.mark.parametrize("a, b, result", [(1, 2)])
# Poprawnie
@pytest.mark.parametrize("a, b, result", [(1, 2, 3)])
Drugą pułapką są obiekty mutowalne, takie jak listy i słowniki. Parametry są przekazywane do testów bez automatycznego kopiowania, więc jeśli test zmieni listę, kolejny przypadek może zobaczyć zmodyfikowany stan. W takich sytuacjach tworzę dane od nowa w fixture’ze albo jawnie wykonuję kopię.
Nie warto również zamieniać parametryzacji w miniaplikację z rozbudowaną logiką warunkową. Gdy test zawiera wiele konstrukcji if zależnych od konkretnego przypadku, traci prostotę. Lepiej rozdzielić scenariusze na dwie funkcje albo przygotować osobne dane i oczekiwane zachowanie.
- Dodaj ids, gdy przypadków jest więcej niż kilka.
- Nie mieszaj w jednej funkcji scenariuszy o zupełnie różnych celach.
- Unikaj gigantycznych tabel danych, które trudno przeglądać w code review.
- Nie mutuj parametrów bez świadomego przygotowania izolacji.
- Sprawdzaj, czy pusta lista parametrów nie oznacza przypadkowo pominięcia testu.
Jeśli dane pochodzą z konfiguracji albo z pliku, przydaje się mechanizm pytest_generate_tests. Pozwala on tworzyć parametry dynamicznie, na przykład na podstawie opcji wiersza poleceń. Sięgam po niego dopiero wtedy, gdy zwykła lista lub fixture przestają być czytelne, ponieważ dodatkowa elastyczność zwiększa też trudność diagnozowania błędów.
Jak włączyć parametryzację do codziennej pracy
Najlepszy efekt daje stopniowe podejście. Najpierw wybieram jedną funkcję z powtarzalnymi testami, łączę podobne przypadki w dekoratorze i dodaję nazwy scenariuszy. Dopiero później rozszerzam rozwiązanie na fixture’y, kombinacje parametrów lub dane generowane dynamicznie.
Po uruchomieniu testów pytest pokazuje każdy przypadek osobno. Dzięki temu można uruchomić tylko wybrany scenariusz po jego identyfikatorze albo filtrować testy przez -k. To szczególnie wygodne w debugowaniu, gdy cała grupa ma kilkadziesiąt wariantów, ale błąd dotyczy jednego konkretnego wejścia.
Parametryzacja nie zastępuje dobrze zaprojektowanych testów. Nie naprawi niejasnych asercji, zależności między testami ani niestabilnego środowiska. Daje jednak bardzo praktyczny kompromis między pokryciem wielu danych a utrzymaniem jednej, czytelnej definicji zachowania.
Mały dekorator, duża różnica w jakości testów
Najważniejsza lekcja jest prosta. Używaj parametryzacji wtedy, gdy scenariusze mają tę samą logikę, ale różne dane, oczekiwane wyniki lub warunki wykonania. Zacznij od podstawowego @pytest.mark.parametrize, dodawaj identyfikatory, świadomie kontroluj liczbę kombinacji i sięgaj po fixture’y dopiero wtedy, gdy testy naprawdę tego potrzebują.
W dobrze utrzymanym projekcie każdy przypadek powinien odpowiadać na konkretne pytanie o zachowanie kodu. Jeśli lista parametrów pomaga te pytania zobaczyć, skraca testy i ułatwia diagnozę, spełnia swoją rolę. Jeśli tylko pomnaża liczbę uruchomień bez jasnego celu, czas ją uprościć.