Pytest – ujarzmij fixtury i mocki

pytest

Jedną z najważniejszych rzeczy w trakcie pracy nad aplikacją jest zapewnienie tego, by miała ona jak najmniej błędów oraz żeby kolejne zmiany w kodzie nie zepsuły naszych dotychczasowych funkcjonalności. W tym artykule chciałbym przybliżyć Ci nieco filozofię testowania kodu przy pomocy biblioteki Pytest.

Zanim zaczniemy pisać testy w Pythonie, powiedzmy sobie czym tak właściwie jest testowanie oprogramowania. Możemy zrobić to dzieląc sposoby testowania na kilka różnych sposobów.

  • Manualne i automatyczne
  • Jednostkowe i integracyjne
  • Blackboxowe i whiteboxowe

Powyżej wymieniłem kilka najpopularniejszych metod podziałów testów. Testy manualne wykonujemy uruchamiając naszą aplikację i postępując zgodnie z założonym sprawdzamy czy wszystkie funkcjonalności działają prawidłowo, takie podejście sprawdza się gdy nasza aplikacja jest niewielka i ma stosunkowo mało testów.

W momencie gdy tych testów zaczynamy mieć coraz więcej warto zadbać o skrypty automatyzujące nasze scenariusze i w ten sposób stworzyć testy automatyczne.

Testy jednostkowe jak sama nazwa wskazuje testują pojedyncze funkcjonalności naszej aplikacji, na przykład posiadając w aplikacji formularz w którym podajemy wiek, możemy chcieć sprawdzić jak zareaguje aplikacja gdy podamy wartości nieprawidłowe. Taki rodzaj testów sprawdza się również gdy rozwijamy oprogramowanie techniką TDD.

Jeżeli w teście będziemy chcieli sprawdzić działanie kilku funkcjonalności i tego jak na przykład komunikują się one między sobą to otrzymamy testy integracyjne.

Testy whiteboxowe i blackboxowe występują w zależności od tego czy są one przeprowadzone bezpośrednio na kodzie czy na interfejsie aplikacji . W testach czarnej skrzynki skupiamy się na tym czy program działa poprawnie, a nie na tym w jaki sposób to robi. W testach białej skrzynki jest na odwrót.

pytest vs. unittest

Gdy wiemy już o co chodzi z tym całym testowaniem, przychodzi pora na wybranie sposobu w jaki będziemy testować nasz kod. W Pythonie mamy do wyboru dwie najpopularniejsze biblioteki do testowania kodu unittest i pytest.

Biblioteka unittest jest wbudowana w Pythonie, co oznacza że jest dostępna zawsze tam gdzie jest zainstalowany Python. Udostępnia ona proste funkcjonalności pisania i uruchamiania testów. Pisanie testów z unittest w dużym skrócie polega na stworzeniu klasy, która dziedziczy po unittest.TestCase i posiada metody, które są testami. Natomiast sprawdzenie poprawności wykonujemy asercjami z unittest np. self.assertEqual()

Pytest jest biblioteką, która rozszerza funkcjonalności unittest. Pozwala również na tworzenie testów w nieco prostszej strukturze oraz daje możliwość używania wbudowanego wyrażenia assert do sprawdzenia poprawności testów. Jedną z największych zalet tej biblioteki jest duża baza rozszerzeń, które możemy zainstalować i które pomogą nam lepiej przetestować nasz kod.

Uruchom swój pierwszy test

Aby móc korzystać z biblioteki pytest musimy ją zainstalować w naszym środowisku. W tym celu należy wywołać w linii poleceń następującą komendę:

pip install -U pytest

Następnie stwórz plik o nazwie test_first.py, w pliku tym zamieścimy naszą prostą funkcję oraz testy do niej. W tym pliku umieść następującą zawartość:

# plik test_first.py
def add_two(x):
    return x + 2

def test_add_two_and_two():
    assert add_two(3) == 5

def test_add_two_and_two_fail():
    assert add_two(3) == 3

Zwróć uwagę, że nasz plik posiada nazwę zaczynającą się od „test„. Zabieg ten pozwala rozpoznać pytestowi, że w tym pliku znajdują się testy. Podobna zasada dotyczy nazw funkcji. Pytest uruchamiając testy wywoła te funkcje, których nazwy zaczynają się od „test_”. Tak przygotowane testy możemy uruchomić, będąc w katalogu z plikiem, komendą:

pytest

Wynik naszego testu powinien wyglądać mniej więcej następująco:

========================== test session starts ===========================
platform linux -- Python 3.6.9, pytest-5.2.1
collected 2 items
test_first.py .F [100%]
================================ FAILURES ================================
_______________________ test_add_two_and_two_fail _______________________
def test_add_two_and_two_fail():
assert add_two(3) == 3
E assert 5 == 3
E + where 5 = add_two(3)
test_first.py:9: AssertionError
====================== 1 failed, 1 passed in 0.12s =======================

Taki wynik oznacza, że w drugim przypadku funkcja zwróciła inny wynik niż oczekiwaliśmy. (Oczywiście było to zamierzone ponieważ chciałem pokazać Ci jak wygląda sytuacja gdy test nie przejdzie)

Jeżeli korzystasz z IDE możesz uruchamiać testy z jego poziomu. W przypadku Visual Studio Code konfiguracja jest bardzo prosta (należy mieć zainstalowaną wtyczkę Python). Wciśnij klawisze Ctrl + Shift + P aby otworzyć okno poleceń i wpisz python test. Najpierw wybierz opcję Configure Tests i ustaw pytest jako środowisko testowe. Następnie wybierz Debug All Test aby uruchomić testy.

Pytest uruchomienie w VSC

Wynik testów pokaże się również na dole okna, możesz w niego kliknąć żeby znaleźć więcej informacji albo uruchomić testy ponownie.

pytest: rezultat testów w VSC

Różne przypadki testowe

Ok, pierwsze koty za płoty. Teraz przyszedł czas na poważne testowanie. Mając już w swoim projekcie więcej testów nie będziemy chcieli trzymać ich w plikach razem z kodem naszej aplikacji. Ogólna konwencja proponuje, żeby testy trzymać w osobnych plikach w katalogu tests w naszym projekcie. Wtedy wywołanie pytest’a będzie wyglądało następująco

pytest tests

Wraz z rozwojem naszej aplikacji będziemy prawdopodobnie chcieli przetestować inne aspekty naszego programu. Takie jak na przykład poprawną obsługę błędów. W tym celu posłużymy się menadżerem kontekstu raises dostępnym w pytest. Stwórz plik test_errors.py i umieść w nim następujący kod:

# plik test_errors.py
import pytest

def div_two_by(x):
    return 2/x

def test_div_two_by_zero():
    with pytest.raises(ZeroDivisionError):
        div_two_by(0)

pytest.raises przyjmuje jako argument wyjątek, którego się spodziewamy i sprawdza czy zostaje on rzucony w bloku menadżera kontekstu.

W tym miejscu warto wspomnieć też o projektowaniu naszej aplikacji zgodnie z zasadami SOLID. Ułatwia to późniejsze testowanie kodu, ponieważ tzw. side effects naszych klas potrafią być ciężkie do prawidłowego testowania.

Pytest fixtury

Fixtury w pytest służą do dostarczania zasobów do naszych testów. Dzięki nim możemy zapewnić między innymi to, że we wszystkich testach będziemy mieli taki sam zestaw danych.

Stworzyć własną fixturę możemy przy pomocy dekoratora @pytest.fixture, następnie jej nazwę przekazujemy jako argument funkcji, która jest testem. Spójrz na przykład:

import pytest

@pytest.fixture
def users_data():
    return [
        {"username": "user1", "age": 30},
        {"username": "user2", "age": 20}
    ]

def test_users_are_of_legal_age(users_data):
    assert all([user['age']>=18 for user in users_data])

W powyższym przypadku fixtura users_data dostarcza listę użytkowników wraz z ich wiekiem. W teście przekazujemy nazwę fixtury jako argument, a następnie pytest zajmuje się tym, żeby dane z naszej funkcji przekazać do testu. Jeżeli chcielibyśmy użyć w teście więcej fixtur możemy po prostu przekazać więcej argumentów.

W fixturach możemy również na przykład inicjować połączenia z bazą danych, czy otwierać pliki. Więcej o fixturach możesz przeczytać tutaj.

Parametryzacja testów – napisz jeden test dla różnych scenariuszy

Pytest udostępnia łatwą możliwość wykonywania jednego testu dla wielu różnych zestawów danych. W tym celu możemy posłużyć się dekoratorem @pytest.mark.parametrize, przyjmuje on dwa argumenty: pierwszy z nich to string, który zawiera nazwy argumentów, do których zostaną rozpakowane dane zawarte w liście kolekcji, którą przekazujemy w drugim argumencie.

import pytest

testdata = [('1', '2', 3), ('2','3',5), ('8', '-6', 1)]

@pytest.mark.parametrize("a,b,expected", testdata)
def test_sum_string_numbers(a, b, expected):
    assert int(a) + int(b) == expected

Powyższy przykład prezentuje prosty test dodawania liczb. Użyłem w nim trzy zestawy danych, z czego ostatni jest niepoprawny, aby zaprezentować negatywny wynik takiego testu.

test_param.py ..F [100%]
=================================== FAILURES ===================================
_______________________ test_sum_string_numbers[8--6-1] ________________________

a = '8', b = '-6', expected = 1

@pytest.mark.parametrize("a,b,expected", testdata)
def test_sum_string_numbers(a, b, expected):
> assert int(a) + int(b) == expected
E AssertionError: assert (8 + -6) == 1
E + where 8 = int('8')
E + and -6 = int('-6')

Możesz zauważyć że test wykonał się trzy razy, jest to zależne od tego ile zestawów danych przekażemy. Ostatni test nie miał pozytywnego rezultatu, a pytest pokazał nam dokładnie, w którym przypadku miało to miejsce.

Taki sposób parametryzacji przydaje się zwłaszcza gdy nasz scenariusz testów przewiduje dużo podobnych logicznie testów różniących się jedynie warunkami. Może to być na przykład potrzeba sprawdzenia poprawności walidacji pola w formularzu.

Mockowanie – przetestuj tylko to co ma być przetestowane

Mockowanie w testach polega na nadpisaniu danego obiektu w naszej aplikacji, specjalnym obiektem, który będzie imitował działanie swojego oryginału. Takie podejście przydaje się w przypadku gdy nie chcemy w teście wykonywać pewnych akcji, które mają miejsce w naszym programie. Na przykład kiedy chcemy przetestować działanie funkcji, która łączy się z bazą danych, pobiera z niej dane i następnie je przetwarza. Jeżeli zależy nam tylko na sprawdzeniu tego ostatniego aspektu, to możemy zamockować połączenie z bazą danych. Korzystając z mockowania możemy również sprawdzić czy interesująca nas funkcja została wywołana w odpowiedni sposób.

Z modułu Mock możemy skorzystać importując go z biblioteki unittest.mock(W pythonie 2 moduł ten znajdował się w bibliotece mock, którą należy zainstalować). Obiekt typu mock posiada kilka atrybutów które pozwalają nam na jego konfiguracje, takie jak na przykład return_value pozwalający na ustawienie wartości która jest zwracana z nadpisanej funkcji. Dostępne są również wbudowane asercje, pozwalające na sprawdzenie używany był nasz obiekt.

W module mock dostępny jest również dekorator patch umożliwiający łatwiejsze określenie jakie obiekty chcemy nadpisać. Ścieżkę do nich podajemy jako jego argument. Obiekt mock zostanie przekazany do testu w postaci argumentu podobnie jak miało to miejsce przy fixturach.

import os
from unittest.mock import patch

def dir_exist(name):
    return name in os.listdir()

@patch('os.listdir')
def test_dir_is_in_list(listdir_mock):
    listdir_mock.return_value = ['tmp', 'tmp1']
    assert dir_exist('tmp')
    listdir_mock.assert_called_once()

Powyższy przykład testuje funkcję sprawdzającą czy dany katalog istnieje. W tym teście zamockowaliśmy funkcję listdir z pakietu os. Następnie przy pomocy atrybutu return_value ustawiliśmy wartość zwracaną przez funkcję udającą listdir(). W kolejnej linijce sprawdzamy poprawność działania naszej funkcji z argumentem „tmp”, a w następnej dzięki wbudowanej asercji obiektu mock assert_called_once sprawdzamy czy funkcja listdir była wywołana tylko raz.

Asercje

Poniżej znajdziesz pozostałe dostępne asercje i ich opis:

assert_called – sprawdza czy funkcja została wywołana,
assert_called_once – sprawdza czy funkcja była wywołana tylko raz,
assert_called_with – sprawdza czy funkcja została wywołana po raz ostatni z odpowiednimi argumentami,
assert_called_once_with – sprawdza czy funkcja została wywołana tylko raz z podanymi argumentami,
assert_not_called – sprawdza czy funkcja nie została wywołana.

To tylko kilka podstawowych informacji o tym jak stosować mocki w testach. Jeżeli chcesz dowiedzieć się więcej daj znać w komentarzach pod tym postem 🙂

Wtyczki do Pytest

Jedną z zalet biblioteki pytest, którą wymieniłem na początku są wtyczki dostępne do instalacji. Pozwalają one rozszerzać podstawowe funkcjonalności pytest’a na przykład o testowanie różnych parametrów kodu lub dostarczają nam gotowe fixtury.

Wtyczki mają zazwyczaj nazwy w postaci pytest-nazwa i możemy je zainstalować przy pomocy polecenia pip install pytest-nazwa

Pierwszą wtyczką, którą warto zainstalować jest pytest-pep8, po zainstalowaniu uruchamiamy ją odpalając test z flagą pep8

py.test --pep8

Wtyczka ta pozwala nam przy okazji testowania na wykonanie statycznej analizy kodu, czyli sprawdzenie czy nasz kod jest zgodny z konwencją pep8.

Innymi ciekawymi wtyczkami są między innymi pytest-cov pozwalająca na sprawdzenie pokrycia kodu testami, oraz pytest-timeout która pozwala na określenie czasu w jakim test powinien się wykonać.

Podsumowanie

W tym artykule chciałem przedstawić Ci podstawy tworzenia testów do Twojej aplikacji w Pythonie. Jeżeli interesuje Cię ten temat daj mi o tym znać w komentarzu. Możesz też zapisać się do newslettera żeby dostawać informację o najnowszych postach.

Jeżeli zaczynasz naukę Pythona zajrzyj na stronę o mnie i sprawdź jak mogę Ci pomóc.

Na zakończenie zachęcam Cię do odwiedzenia moich pozostałych wpisów na tym blogu, oraz życzę powodzenia w dalszej nauce Pythona.




8 komentarzy

Odczytuję w projekcie plik .ini configparserem i przez to właśnie wywala mi pytest z KeyError.
Nie mogę nic przetestować przez tego configparsera. Próbowałem wszystkich kombinacji z lokalizacją pliku i nic. Wciąż błąd.

To norma?

KeyError brzmi jakby czegoś brakowało w configu, albo jakbyś odwoływał się do jakiegoś elementu nie tak jak trzeba. Ciężko powiedzieć, musiałbyś podesłać więcej szczegółów. Możesz też spróbować zamockwać config jeżeli ma to sens w Twoich testach.

hej,
puszczam dwa testy przy pomocy
test_env = [QA, DEV]
@pytest.mark.parametrize(„env”, test_env)
Idą dwa testy jak mogę określić by było widoczne zamiast env0, env 1 w wyniku w konsoli jako QA i DEV przy kazdym tescie?:)

wizard[env0] PASSED [ 50%]
wizard[env1] PASSED [100%]

Pytanie czym są zmienne QA i DEV? Żeby w logach z testów były widoczne wartości parametrów, to musiałyby to być np. stringi. Więc żeby uzyskać wynik w stylu:
wizard[QA] PASSED [ 50%]
wizard[DEV] PASSED [100%]
można by spróbować zrobić słownik z tymi obiektami, a parametrami byłyby klucze z tego słownika:


test_env = {"QA": QA, "DEV":DEV}
@pytest.mark.parametrize(„env”, ["QA", "DEV"])
def test_params(env):
    test_env[env]

Doczytałem trochę dokumentację, w samym dekoratorze jest opcja dodania „ids” czyli tego co ma się wyświetlić w konsoli, domyślnie pytest doda do nazyw zmiennej liczbę 0, 1 itd.
W tym momencie mój dekorator wygląda nasepująco:
@pytest.mark.parametrize(„env”, test_env, ids=[„QA”, „DEV”])

dzięki temu w wynikach w konsoli widzę dokładnie jakiego parametru dotyczył test.
Wynik u mnie wygląda tak:
wizard[QA] PASSED [ 50%]
wizard[DEV] PASSED [100%]

zamiast dziwnego env0 i env1 🙂 dzieki

Na początku chciałam podziękować za bardzo przyjemny opis. Mam pytanie, bo próbuję napisać unittest dla funkcji, która zawiera funkcję for. Funckcja pobiera dwa argumenty, które wcześniej 'mockuje’. Nie za bardzo wiem jednak jak podejść do obiektu, który tworzony i iterowany jest wewnątrz pętli for. Czy jest jakaś błyskotliwa metoda przekazywania wartości do unittestu aby każda iteracja pętli for pobierała inną wartość?

Dodaj komentarz

Twój adres e-mail nie zostanie opublikowany. Wymagane pola są oznaczone *