Zaawansowane techniki pytest
W ostatnich miesiącach zrobiłem sporo pull requestów do pandas. Ta biblioteka ma już ponad 10 lat i składa się na nią ponad pół miliona linii kodu i nikt już nie jest w stanie przewidzieć wszystkich konsekwencji nawet prostych zmian kodu. W związku z tym musimy polegać na naszym zestawie testów, aby uniknąć ryzyka ciągłego psucia kodu milionów naszych użytkowników. Szerokie pokrycie testami różnych przypadków ma tu kluczowe znaczenie.
W tym artykule chcę podzielić się kilkoma zaawansowanymi funkcjami pytest, których ostatnio się nauczyłem. Ważne: by to zrozumieć, wypadałoby mieć podstawową wiedzę o pytest, parametryzacji i testowaniu wyjątków.
Parametryzacja fixture'ów
Jestem wielkim fanem parametryzacji w testach. To elegancki i konkretny sposób testowania różnych konfiguracji w celu wykrywania błędów i dążenia do niezawodności Twojej aplikacji. Coś, czego nie wiedziałem, to to, że można nawet bezpośrednio parametryzować fixture’y, a nie tylko przypadki testowe.
Intensywnie korzystamy np. z naszego ukochanego fixture’a index
, który daje nam wiele różnych instancji:
indices_dict = {
"unicode": tm.makeUnicodeIndex(100),
"string": tm.makeStringIndex(100),
"datetime": tm.makeDateIndex(100),
"datetime-tz": tm.makeDateIndex(100, tz="US/Pacific"),
"period": tm.makePeriodIndex(100),
"timedelta": tm.makeTimedeltaIndex(100),
"int": tm.makeIntIndex(100),
"uint": tm.makeUIntIndex(100),
"range": tm.makeRangeIndex(100),
"float": tm.makeFloatIndex(100),
"bool": tm.makeBoolIndex(10),
"categorical": tm.makeCategoricalIndex(100),
"interval": tm.makeIntervalIndex(100),
"empty": Index([]),
"tuples": MultiIndex.from_tuples(zip(["foo", "bar", "baz"], [1, 2, 3])),
"mi-with-dt64tz-level": _create_mi_with_dt64tz_level(),
"multi": _create_multiindex(),
"repeats": Index([0, 0, 1, 1, 2, 2]),
}
@pytest.fixture(params=indices_dict.keys())
def index(request):
"""
Fixture for many "simple" kinds of indices.
These indices are unlikely to cover corner cases, e.g.
- no names
- no NaTs/NaNs
- no values near implementation bounds
- ...
"""
# copy to avoid mutation, e.g. setting .name
return indices_dict[request.param].copy()
Przedstawmy to na małym przykładzie - przeprowadzimy następujący test:
def test_indices(index):
assert isinstance(index, pd.Index)
Wynikiem tego jest:
test_example.py::test_indices[unicode] PASSED
test_example.py::test_indices[string] PASSED
test_example.py::test_indices[datetime] PASSED
test_example.py::test_indices[datetime-tz] PASSED
test_example.py::test_indices[period] PASSED
test_example.py::test_indices[timedelta] PASSED
test_example.py::test_indices[int] PASSED
test_example.py::test_indices[uint] PASSED
test_example.py::test_indices[range] PASSED
test_example.py::test_indices[float] PASSED
test_example.py::test_indices[bool] PASSED
test_example.py::test_indices[categorical] PASSED
test_example.py::test_indices[interval] PASSED
test_example.py::test_indices[empty] PASSED
test_example.py::test_indices[tuples] PASSED
test_example.py::test_indices[mi-with-dt64tz-level] PASSED
test_example.py::test_indices[multi] PASSED
test_example.py::test_indices[repeats] PASSED
Wygodny sposób, prawda? Możemy napisać test tak, jakbyśmy używali zwykłego fixture’a, a pytest automatycznie wykona go raz dla każdej wartości fixture'a!
W pandas świetne jest to, że oferuje szeroką gamę różnych indeksów, które czasami (ze względu na historię lub wyniki) nie mają wspólnej implementacji. Sparametryzowane fixture’y uwalniają nas od niepotrzebnego myślenia o tym. Jeśli przypadek testowy powinien działać dla wszystkich indeksów, możemy po prostu użyć fixture’a index
i automatycznie uzyskać pokrycie dla wszystkich typów indeksów. I to działa: stosując sparametryzowane fixuture'y częściej w naszym zestawie testowym, znalazłem błędy, dla których jeszcze nie mieliśmy pokrycia.
Nie sądzę, aby sparametryzowane fixture’y były niezbędne w każdej bazie kodu, ale przydają się w tych dwóch sytuacjach:
- Gdy chcesz zapewnić wspólną funkcjonalność, której nie gwarantuje samo dziedziczenie (jak w powyższym przykładzie).
- Masz standardowe parametryzacje, których używasz do wielu testów, np. gdy identyczne parametry akceptowane są przez różne funkcji (patrz tutaj).
Uwaga: Nawet jeśli sparametryzowane fixture’y mają wiele wartości, należy nadać im pojedyncze nazwy.
Ustaw identyfikatory w parametryzacjach
Inną (pokrewną) funkcją, której wcześniej nie używałem, jest ustawienie ids w parametryzacjach. W większości przypadków nie jest to konieczne, ponieważ pytest znajdzie dobre wartości domyślne. Czasami jednak te wartości domyślne są trudne do odczytania lub nie są wystarczająco zwięzłe. Weźmy następujący przykład:
@pytest.mark.parametrize("obj", [pd.Index([]), pd.Series([])])
def test_with_ids(obj):
assert obj.empty
Uruchomienie tego testu skutkuje tym:
test_example.py::test_with_ids[obj0] PASSED
test_example.py::test_with_ids[obj1] PASSED
W przypadku niepowodzenia któregoś z testów w przyszłości, zawsze trzeba spojrzeć w konfigurację i znaleźć indeks, który odpowiada testowi, który nie przeszedł. To znaczy, że jeśli obj1
się nie powiedzie, będziesz musiał poszukać drugiej wartości parametru. To działa, ale też jest dość denerwujące. Lepszą alternatywą jest ręczne nadpisywanie identyfikatorów.
@pytest.mark.parametrize(
"obj",
[pd.Index([]), pd.Series([])],
ids=["Index", "Series"]
)
def test_with_ids(obj):
assert obj.empty
Drugą opcją jest uzyskanie identyfikatorów w sposób niejawny:
@pytest.mark.parametrize(
"obj",
[pd.Index([]), pd.Series([])],
ids=lambda x: type(x).__name__
)
def test_with_ids(obj):
assert obj.empty
Oba podejścia dają wynik:
test_example.py::test_with_ids[Index] PASSED
test_example.py::test_with_ids[Series] PASSED
Ważne: Powyższe przykłady opierały się na parametryzacji testów, ale działa to w ten sam sposób podczas parametryzacji fixture’ów.
Wywołanie skip lub xfail wewnątrz testu
Zwykle, gdy chcesz użyć skip
lub xfail
dla testu, wybierasz użycie dekoratora z pytest. Czasami jednak warunek, który należy sprawdzić, jest dostępny tylko w czasie wykonywania. W takim przypadku możesz również wywołać pytest.skip
lub pytest.xfail
wewnątrz logiki testu:
def test_indexing(index):
if len(index) == 0:
pytest.skip("Test case is not applicable for empty data.")
index[0]
to daje:
test_example.py::test_indexing[unicode] PASSED
test_example.py::test_indexing[string] PASSED
test_example.py::test_indexing[datetime] PASSED
test_example.py::test_indexing[datetime-tz] PASSED
test_example.py::test_indexing[period] PASSED
test_example.py::test_indexing[timedelta] PASSED
test_example.py::test_indexing[int] PASSED
test_example.py::test_indexing[uint] PASSED
test_example.py::test_indexing[range] PASSED
test_example.py::test_indexing[float] PASSED
test_example.py::test_indexing[bool] PASSED
test_example.py::test_indexing[categorical] PASSED
test_example.py::test_indexing[interval] PASSED
test_example.py::test_indexing[empty] SKIPPED
test_example.py::test_indexing[tuples] PASSED
test_example.py::test_indexing[mi-with-dt64tz-level] PASSED
test_example.py::test_indexing[multi] PASSED
test_example.py::test_indexing[repeats] PASSED
Ważne: to świetne rozwiązanie, ale ma tę wadę, że konfiguruje uruchomienie testu, który jest ignorowany w wynikach. Dlatego użycie dekoratora powinna być zawsze preferowana (jeśli to możliwe), ponieważ jest ewaluowana na poziomie kolekcji testów.
Parametryzacja pośrednia
Czasami chcesz użyć tylko podzbioru sparametryzowanych wartości swojego fixture’a. Co robisz, jeśli chcesz użyć fixture index
, ale chcesz użyć tylko jego wartości MultiIndex
? Wtedy masz te opcje:
- Użyj nowej parametryzacji lub fixture’a (→ co wprowadzi redundancję)
- Wprost wywołaj
pytest.skip
(→ co tworzy testy, które finalnie nie zostaną wykorzystane) - Użyj pośredniej parametryzacji
Skoro już omówiliśmy punkt 2, spójrzmy na opcję 3:
@pytest.mark.parametrize(
"index",
["mi-with-dt64tz-level", "multi"],
indirect=True
)
def test_dummy(index):
assert isinstance(index, pd.MultiIndex)
Na początku składnia może być nieco przytłaczająca, ale w rzeczywistości ma dużo sensu (jak to zwykle bywa). Mianowicie - najpierw dodajesz sparametryzowany fixture do sygnatury. Potem określasz parametryzację, w której określasz:
- Fixture, który chcesz pośrednio sparametryzować
- Wartości, które chcesz wybrać
- Flagę
indirect
ustawioną naTrue
Skutkuje to następującymi dwoma uruchomieniami testów:
test_example.py::test_multiindex[mi-with-dt64tz-level] PASSED
test_example.py::test_multiindex[multi] PASSED
Głównym minusem tego podejścia jest to, że wymaga ręcznego określenia wartości fixture’a. Gdybyśmy dodali nową wartość MultiIndex
do naszego fixture’a index
, pośrednia parametryzacja nie będzie miała o niej pojęcia. Dlatego musisz się dobrze zastanowić, które z trzech podejść zamierzasz zastosować.
Jeśli chcesz zagłębić się w ten temat, polecam przeczytanie tego świetnego tekstu na pytest-tricks, który da Ci nieco więcej kontekstu.
pytest.raises kontra nullcontext
Ostatni fragment tego artykułu dotyczy redundancji, z którą możesz się spotkać podczas parametryzacji. Mówiąc dokładniej, podczas testowania wyjątków, które powinny wystąpić tylko z niektórymi wartościami parametryzacji. Weźmy następujący przykład:
@pytest.mark.parametrize(
"obj, raises",
[
([], False),
(pd.Index([]), True),
],
)
def test_exception(obj, raises):
if raises:
with pytest.raises(ValueError):
# you should also test the error message, using
# the 'matches' parameter in .raises()
# I don't have enough space here though...
bool(obj)
else:
bool(obj)
Podwójne wywołanie bool (obj)
może być denerwująca, gdy test będzie większy niż ten prosty przykład.
W trakcie mojego researchu natknąłem się na ten wątek na stronie pytest na GitHubie, gdzie ktoś ostatecznie zasugerował skorzystanie z contextlib.nullcontext
:
from contextlib import nullcontext
@pytest.mark.parametrize(
"obj, expected_raises",
[
([], nullcontext()),
(pd.Index([]), pytest.raises(ValueError)),
],
)
def test_exception2(obj, expected_raises):
with expected_raises:
bool(obj)
Ważne: contextlib.nullcontext
jest dostępne dopiero od wersji Pythona 3.7. Jeśli używasz wcześniejszej, zapoznaj się z tym komentarzem we wspomnianym wątku.
Nie nadużywaj jednak tej funkcji. Chociaż może to być wygodny sposób dla prostych przypadków użycia, może również zachęcić do używania parametryzacji dla różnych zachowań, w których bardziej odpowiednie byłyby oddzielne testy.
Podsumowanie
Chociaż istnieje wiele innych przydatnych funkcji pytest
, o których nie wspomniałem, mam nadzieję, że podobało Ci się odkrywanie tych wymienionych.
Branie udziału w projektach open source to zazwyczaj świetna okazja do odkrycia i nauczenia się nowych koncepcji lub technologii, jednocześnie dając społeczności programerskiej coś od siebie. Jeśli więc jeszcze tego nie robisz, sprawdź, czy nie ma nierozwiązanych problemów w projektach open source’owych, które uważasz za interesujące lub ich używasz i działaj!
Oryginał tekstu w języku angielskim przeczytasz tutaj.