W poprzednim artykule na temat frameworku Pytest
opisałem koncepcję fixtures oraz omówiłem podstawy statycznej i dynamicznej parametryzacji testów. Tym razem chciałbym się zająć wykorzystaniem funkcjonalności definiowania zakresu (scope) dla fixtures oraz wykonania kroków kończących (teardown) po zakończeniu testów. Jest to też okazja do zmierzenia się z prostymi testami REST API, wykonywanymi w języku Python
z wykorzystaniem biblioteki requests
.
Na podstawie materiału opisanego w tym artykule przeprowadziłem prezentację na spotkaniu TrójQA, nagranie można obejrzeć tutaj: Wierny, szybki, niezależny - dobry test automatyczny REST API (ale nie tylko).
SUT: Trello API
Jako system under test (SUT) do przedstawienia wspomnianych koncepcji wykorzystam REST API popularnej aplikacji Trello.
Dlaczego Trello?
Trello to serwis do zarządzania zadaniami o rozbudowanych funkcjonalnościach. Jest udostępniany jako aplikacja internetowa, aplikacja mobilna, oferuje też wspomniany dostęp przez REST API. Ze względu na proste do zrozumienia user journeys, nowoczesne interfejsy, mnogość metod dostępu i szeroki zakres darmowych opcji jest bardzo dobrym poligonem doświadczalnym do nauki automatyzacji testów. Dlatego skorzystam z niego do przedstawienia koncepcji poruszanych w tym artykule.
Dokumentacja API
Trello oferuje rozbudowane i dobrze udokumentowane REST API, z którym można zapoznać się w szczegółach na oficjalnej stronie dla programistów. Do testowania i prototypowania można użyć własnego konta w serwisie, dla którego należy utworzyć klucz i token dostępowy, zgodnie z instrukcją w dokumentacji API.
Tip: Klucz i token należy dobrze zabezpieczyć, ponieważ każdy, kto je zna, może podawać się za ich właściciela i poprzez API uzyskać pełny dostęp do jego konta w serwisie. Dlatego nie należy ich umieszczać w skryptach i dostępnych publicznie repozytoriach.
Tworzenie nowej karty
Jak wspomniałem, Trello ma liczne funcjonalności, wśród najważniejszych można wymienić tworzenie tablic dla projektów (boards), umieszczanie na tablicach list zadań do wykonania (lists) oraz umieszczanie na listach samych zadań w postaci kart (cards).
Na potrzeby tego artykułu wykorzystam funkcjonalność tworzenia nowej karty na liście przy pomocy funkcji REST API opisanej na tej stronie w dokumentacji. Wcześniej, w ramach przygotowania środowiska, użyłem interfejsu aplikacji do utworzenia tablicy o nazwie pytest-board
oraz umieszczenia na niej listy o nazwie pytest-list
.
Przypadki testowe
Udane żądanie API utworzenia nowej karty metodą POST zwraca kod odpowiedzi HTTP 200 OK
oraz JSON zawierający szczegółowe dane utworzonej karty:
{
"id": "5b15bdc827709694d5be84e1",
"badges": {
"votes": 0,
"attachmentsByType": {
"trello": {
"board": 0,
"card": 0
}
},
"viewingMemberVoted": false,
"subscribed": false,
"fogbugz": "",
"checkItems": 0,
"checkItemsChecked": 0,
"comments": 0,
"attachments": 0,
"description": true,
"due": null,
"dueComplete": false
},
"checkItemStates": [],
"closed": false,
"dueComplete": false,
"dateLastActivity": "2018-06-04T22:31:36.588Z",
"desc": "Test Description",
"descData": {
"emoji": {}
},
"due": null,
"email": null,
"idBoard": "5b13083cc204756935699dd0",
"idChecklists": [],
"idList": "5b13084d232f56be3c69ae16",
"idMembers": [],
"idMembersVoted": [],
"idShort": 1,
"idAttachmentCover": null,
"labels": [],
"idLabels": [],
"manualCoverAttachment": false,
"name": "Test Name",
"pos": 16384,
"shortLink": "o7ysPcBy",
"shortUrl": "https://trello.com/c/o7ysPcBy",
"subscribed": false,
"stickers": [],
"url": "https://trello.com/c/o7ysPcBy/1-test-name",
"limits": {}
}
W ramach testów sprawdzimy:
- Czy kod opowiedzi HTTP to
200 OK
. - Czy JSON z danymi utworzonej karty zawiera niepustą wartość parametru
id
. - Czy JSON z danymi utworzonej karty zawiera wartość parametru
name
zgodną z żądaniem. - Czy JSON z danymi utworzonej karty zawiera wartość parametru
desc
zgodną z żądaniem.
Konfiguracja projektu
Jeśli chcesz pobrać opisywany kod, możesz skorzystać z gotowego projektu znajdującego się w repozytorium: https://gitlab.com/qalabs/blog/pytest-fixture-scope-teardown-example. Aby uruchomić skrypty na swojej maszynie, skonfiguruj środowisko i projekt w sposób opisany w artykule Narzędzia - Python i PyCharm
Tip: W repozytorium jest kilka commitów zawierających kolejne wersje plików opisanych w artykule. Jeśli chcesz przejść do konkretnego kroku i sprawdzić, jak działa skrypt na tym etapie, wystarczy jeśli zrobisz checkout odpowiedniego commita.
Pierwsza wersja testów
Zaczniemy od utworzenia skryptu zawierającego cztery funkcje testowe (niepowiązane ze sobą) oraz trzy fixtures, które na potrzeby testów wykonają kroki konieczne do utworzenia karty na liście pytest-list
na tablicy pytest-board
. Karta będzie miała nazwę Test Name
i opis Test Description
, a testy wykorzystają ją do zweryfikowania wymienionych wyżej warunków.
1 | import json |
Przeanalizujmy przedstawiony kod fragment po fragmencie.
Zarządzanie wrażliwą konfiguracją
1 |
|
Fixture trello_creds
odczytuje klucz i token do API z pliku konfiguracyjnego trello_creds.json
. W repozytorium znajduje się tylko szablon pliku, bez rzeczywistych danych. To sposób na zarządzanie wrażliwą konfiguracją. Umieszczenie jej w osobnym pliku pozwoliło mi na zabezpieczenie jej przed przypadkowym upublicznieniem. Użyłem do tego polecenia Git
:
git update-index --skip-worktree trello_creds.json
Dzięki temu Git
przestał “widzieć” zmiany w pliku z konfiguracją i mogłem w nim podać rzeczywiste dane dostępowe, bez obaw że przez przypadek dodam je do repozytorium (dalsze szczegóły dotyczące tego podejścia znajdziesz w dokumentacji oraz w tej odpowiedzi na Stack Overflow)
Pobranie identyfikatora listy
Jak już wspomniałem wyżej, w ramach przygotowania środowiska utworzyłem na swoim koncie tablicę pytest-board
, a na niej listę pytest-list
. Aby utworzyć kartę poprzez API, muszę znać identyfikator utworzonej listy. Mógłbym go pobrać raz i po prostu umieścić w kodzie, ale to nie jest dobre podejście, choćby dlatego, że identyfikator będzie inny dla każdej osoby, która będzie chciała uruchomić testy na swoim koncie.
Dlatego lepiej użyć API i na podstawie nazwy tablicy i nazwy listy pobrać potrzebny identyfikator dynamicznie. Ta operacja nie ma bezpośredniego związku z opisywanymi funkcjonalnościami Pytest
, dlatego póki co potraktuję tę fixture jak “czarną skrzynkę” i ograniczę się do stwierdzenia, że trello_list_id
odpowiada za wskazanie identyfikatora testowej listy. Szczegóły działania, dla zainteresowanych, wyjaśnię w dodatku na końcu artykułu.
Wysłanie żądania utworzenia karty
1 |
|
Fixture create_card_response
odpowiada za wykonanie żądania utworzenia nowej karty i odebranie odpowiedzi serwera. Wykorzystuje podany w dokumentacji API adres zapisany w zmiennej cards_url
. Wśród parametrów zapytania definiuje testową nazwę i testowy opis karty oraz identyfikator testowej listy, pozyskany przez fixture trello_list_id
. Po dodaniu do parametrów klucza i tokenu, dostarczonych przez fixture trello_creds
, wykonuje żądanie metodą POST
i zwraca odpowiedź serwera, w postaci instancji obiektu klasy requests.Response
, zawierającą wszystkie dane potrzebne do jej zweryfikowania.
Właściwe testy
Funkcje testowe są bardzo proste i ograniczają się do wykonania odpowiednich asercji na danych dostarczonych przez fixture create_card_response
. Po pierwsze, przyrównują kod uzyskanej odpowiedzi do wartości HTTPStatus.OK
. Po drugie, odczytują zwrócony JSON i sprawdzają, czy wartość id
karty jest niepusta (a dokładnie, czy ma wartość logiczną True
). Po trzecie i czwarte, przyrównują parametry name
i desc
ze zwróconego JSON do wartości podanych w żądaniu utworzenia karty.
1 | def test_response_http_status_ok(create_card_response): |
Tip: W profesjonalnym programowaniu należy unikać używania tak zwanych magic numbers, czyli wartości (najczęściej) liczbowych, które nie wiadomo skąd się wzięły, trudno odgadnąć, co oznaczają, przez które kod staje się mniej czytelny i trudniejszy w utrzymaniu. W powyższej funkcji testowej mogłem z powodzeniem przyrównać kod odpowiedzi HTTP do liczby 200, ale dla zapewnienia lepszej czytelności, zamiast tego użyłem odpowiedniej stałej
HTTPStatus.OK
, dostarczonej przez wbudowany modułhttp
.
Wykonanie testów
Przygotowane testy można uruchomić z konsoli poleceniem:
pytest -v
Dzięki parametrowi -v
(verbose) zobaczymy szczegółowe informacje na temat wykonanych testów, w tym nazwy poszczególnych funkcji testowych:
============================= test session starts =============================
platform win32 -- Python 3.6.5, pytest-3.6.0, py-1.5.3, pluggy-0.6.0
cachedir: .pytest_cache
rootdir: pytest-fixture-scope-teardown-example, inifile:
collected 4 items
test_create_new_card.py::test_response_http_status_ok PASSED [ 25%]
test_create_new_card.py::test_response_card_id_set PASSED [ 50%]
test_create_new_card.py::test_response_card_name_correct PASSED [ 75%]
test_create_new_card.py::test_response_card_desc_correct PASSED [100%]
========================== 4 passed in 8.09 seconds ===========================
Jak widać, wszystkie testy zakończyły się sukcesem. Ale rzut oka do interfejsu webowego ujawnia coś niepokojącego:
Wykonanie testów utworzyło na liście pytest-list
cztery karty o tych samych tytułach. Spodziewaliśmy się raczej jednej karty, na której dokonane zostaną wszystkie sprawdzenia. Skąd więc wzięły się pozostałe trzy?
Jest to związane z zakresem (scope) ustawionym dla utworzonych fixtures. W Pytest
domyślnym zakresem dla każdej fixture jest pojedyncza funkcja testowa, co oznacza, że przed wykonaniem każdej funkcji wykonywane są wszystkie fixture, na których polega. Czyli każda z naszych trzech fixture została wykonana cztery razy, dla każdej z czterech funkcji testowych. Dotyczy to także fixture create_card_response
, która odpowiada za utworzenie testowej karty.
Aby się o tym przekonać, można na szybko dodać funkcję print
, która wypisze na ekran informację o wykonaniu fixture create_card_response
:
1 |
|
Po (na razie) manualnym przywróceniu środowiska do stanu początkowego (usunięciu utworzonych kart) i wykonaniu testów z dodatkowym parametrem -s
(dzięki któremu Pytest
nie przechwyci komunikatów wypisywanych na ekran), w konsoli widać, że fixture w istocie została wykonana cztery razy:
============================= test session starts =============================
platform win32 -- Python 3.6.5, pytest-3.6.0, py-1.5.3, pluggy-0.6.0
cachedir: .pytest_cache
rootdir: pytest-fixture-scope-teardown-example, inifile:
collected 4 items
test_create_new_card.py::test_response_http_status_ok
create card request sent
PASSED
test_create_new_card.py::test_response_card_id_set
create card request sent
PASSED
test_create_new_card.py::test_response_card_name_correct
create card request sent
PASSED
test_create_new_card.py::test_response_card_desc_correct
create card request sent
PASSED
========================== 4 passed in 7.19 seconds ===========================
Na wynik testów w tym przypadku to nie ma wpływu, chociaż mimo wszystko nieco obniża ich wiarygodność, bo można sobie wyobrazić jakiś ekstremalny scenariusz, w którym na przykład tylko pierwsza utworzona karta nie ma prawidłowo wypełnionego identyfikatora id
, a w ten sposób to nie zostałoby wychwycone.
Ale z nadmiarowym utworzeniem trzech kart jest związany dużo większy problem - niepotrzebnie wydłużony czas wykonania testów. Wykonanie każdego żądania do API jest kosztowne i związane z istotnym czasem odpowiedzi. Przez to test trwa niepotrzebnie prawie cztery razy dłużej (bo samo wykonanie asercji zajmuje już pomijalnie mało czasu).
Na szczęście Pytest
daje nam narzędzia, które umożliwiają szybkie i łatwe naprawienie tego problemu.
Ustawienie zakresu dla fixture
Pytest
posługuje się czterema zakresami, które można ustawić dla fixture: function
(domyślny), class
(wykonanie raz dla wszystkich funkcji w ramach klasy testowej), module
(wykonanie raz dla wszystkich funkcji w ramach modułu, czyli w uproszczeniu katalogu z testami), session
(wykonanie raz dla całej sesji testowej). Za ich pomocą można optymalizować wykorzystanie zasobów potrzebnych testom.
Zakres klasy testowej
Aby poradzić sobie z opisanym wyżej problemem, przeorganizuję funkcje testowe i umieszczę je wewnątrz klasy testowej nazwanej TestCreateNewCard
:
1 | class TestCreateNewCard: |
Umożliwi mi to dodanie do dekoratora dla fixture create_card_response
niestandardowego zakresu class
, dzięki czemu ta fixture zostanie wykonana raz dla wszystkich funkcji, które jej potrzebują i są zdefiniowane w ramach wspomnianej klasy testowej:
1 |
|
Hierarchia zakresów
W tej sytuacji fixtures trello_creds
i trello_list_id
nie mogą zachować domyślnego zakresu function
, ponieważ obowiązuje tutaj hierarchia, to znaczy fixture o szerszym zakresie (np. class
) nie może zależeć od fixture o węższym zakresie (np. function
). Warto zauważyć, że wartości, które te fixtures dostarczają testom, nie zmieniają się przez cały czas wykonania testów, więc możemy im swobodnie zdefiniować zakres scope="session"
.
To zresztą dobry pomysł również dlatego, że odczytanie klucza i tokenu z pliku tekstowego też nie jest za darmo (w sensie wykorzystania zasobów) i nie ma potrzeby, aby wielokrotne wykonanie tej operacji spowalniało testy.
Wykonanie testów
Zaktualizowane testy można ponownie uruchomić z konsoli, z wynikiem jak poniżej:
============================= test session starts =============================
platform win32 -- Python 3.6.5, pytest-3.6.0, py-1.5.3, pluggy-0.6.0
cachedir: .pytest_cache
rootdir: pytest-fixture-scope-teardown-example, inifile:
collected 4 items
test_create_new_card.py::TestCreateNewCard::test_response_http_status_ok PASSED [ 25%]
test_create_new_card.py::TestCreateNewCard::test_response_card_id_set PASSED [ 50%]
test_create_new_card.py::TestCreateNewCard::test_response_card_name_correct PASSED [ 75%]
test_create_new_card.py::TestCreateNewCard::test_response_card_desc_correct PASSED [100%]
========================== 4 passed in 3.22 seconds ===========================
Podczas wykonania testów wyraźnie widać, że pierwszy z nich wykonuje się dłużej (to wtedy właśnie wykonywane jest żądanie do API), a kolejne przechodzą błyskawicznie (bo korzystają z pobranych wcześniej danych). Całkowity czas wykonania jest istotnie krótszy, a w interfejsie webowym powstała tylko jedna karta:
Jednakże nawet ta jedna karta, która pozostała po teście, wciąż oznacza, że środowisko nie zostało automatycznie przywrócone do stanu początkowego. Kolejne uruchomienia testów spowodują nagromadzenie takich pozostawionych kart. Każdy test automatyczny powinien po sobie posprzątać, aby zapewnić powtarzalne rezultaty oraz dowolność w kolejności wykonania.
Większość frameworków do testów automatycznych zapewnia możliwość zdefiniowania kodu teardown, czyli kroków wykonywanych po testach, w tym także “sprzątających” po nich. Pytest
oczywiście również wspiera tę koncepcję. Wykorzystam tę funkcjonalność do rozwiązania problemu pozostawionej karty.
Dodanie kroków teardown
W Pytest
wszystkie działania wykonywane wokół testów są obsługiwane przez fixtures. Dotyczy to nie tylko kroków przygotowawczych, takich jakie były przedstawione w przykładach powyżej, ale również tych kończących, które są wykonywane po zakończeniu wszystkich testów zależnych od danej fixture.
Najprostszą metodą zdefiniowania w fixture kodu teardown jest zastąpienie w niej słowa kluczowego return
słowem yield
. Wówczas wszystkie instrukcje, jakie znajdą się w ciele tej fixture poniżej yield
, zostaną wykonane na zakończenie, po wszystkich testach.
Usunięcie karty po testach
Właściwym miejscem na dodanie kodu usuwającego kartę po wykonaniu testów jest ta sama fixture, która ją tworzy, czyli create_card_response
:
1 |
|
Polecenie return response
, zwracające obiekt requests.Response
, zastąpiłem poleceniem yield response
. Dodałem po nim dwie linie, które z odpowiedzi serwera odczytują identyfikator utworzonej karty oraz wykorzystują żądanie do API aplikacji w celu jej usunięcia przy pomocy metody DELETE
.
Wykonanie testów
Wynik uruchomienia testów w konsoli jest identyczny, jak poprzednim razem. Najważniejszą zmianę można zaobserwować w interfejsie aplikacji po wykonaniu testów:
Jak widać, testowej karty nie ma, została usunięta, czyli w praktyce środowisko zostało przywrócone do stanu początkowego.
Podsumowanie
Wiele przydatnych funkcjonalności frameworku Pytest
jest skupionych wokół koncepcji fixtures. We wspomnianym na wstępie poprzednim artykule poruszyłem kwestie ich modułowej budowy oraz statycznej i dynamicznej parametryzacji. Tutaj przedstawiłem możliwości definiowania zakresu (scope) oraz dodawania kroków kończących (teardown). To potężne narzędzia, ułatwiające tworzenie wydajnych i łatwych w utrzymaniu testów. Ale na tym atrakcje się nie kończą, więc w przyszłości zapewne wrócę do tematu. W międzyczasie zapraszam do zapoznania się z dedykowaną stroną w dokumentacji Pytest
: pytest fixtures: explicit, modular, scalable.
Pobranie id testowej listy
W post scriptum chcę się jeszcze pochylić nad fragmentem kodu, o którym wspomniałem wcześniej tylko ogólnie, a który umożliwia dynamiczne pobranie identyfikatora testowej listy. To kwestia specyficzna dla API Trello, nie jest konieczna do zrozumienia scope i teardown, stąd szczegółowe wyjaśnienie umieszczam dopiero tutaj, dla szczególnie zainteresowanych.
1 |
|
Implementacja testów zakłada, że w ramach przygotowania środowiska, w aplikacji zostały utworzone tablica pytest-board
oraz lista pytest-list
. W celu dynamicznego pobrania identyfikatora tej listy, skrypt wykorzystuje API aplikacji do pobrania wszystkich tablic bieżącego użytkownika i odnalezienia identyfikatora tablicy pytest-board
, a następnie pobrania wszystkich list na tej tablicy i odnalezienia identyfikatora listy pytest-list
.
Do odnalezienia i pobrania identyfikatora tablicy i listy użyłem dwóch interesujących mechanizmów języka Python
: list comprehension oraz iterable unpacking. Przeanalizujmy poniższą linię kodu:
1 | board_id, = [trello_board["id"] for trello_board in response.json() if trello_board["name"] == "pytest-board"] |
Po prawej stronie przypisania wykonałem list comprehension, to znaczy utworzyłem nową listę (w sensie strukturę danych) na podstawie przefiltrowanej zawartości istniejącej listy. Istniejąca lista to w tym przypadku pobrana przez API lista wszystkich tablic, gdzie każdy element to słownik zawierający dane tablicy. Instrukcja ta oznacza dokładnie: przejdź po wszystkich elementach listy pobranych tablic (for trello_board in response.json()
), zostaw na nowej liście te elementy, których nazwa to pytest-board
(if trello_board["name"] == "pytest-board
), a zamiast całego słownika, zostaw tylko wartość dla klucza id
(trello_board["id"]
).
Na utworzonej w ten sposób liście identyfikatorów spodziewam się tylko jednego elementu, to znaczy identyfikatora poszukiwanej tablicy. Najlepszą metodą pozyskania go jest użycie iterable unpacking i “odpakowanie” go do pojedynczej zmiennej, co zrobiłem po lewej stronie przypisania. Takie podejście nie tylko “wyciąga” pojedynczą wartość z listy, ale automatycznie weryfikuje, czy na liście rzeczywiście jest tylko jeden element. Jeśli będzie ich więcej lub lista będzie pusta, skrypt zostanie przerwany z powodu ValueError
, jak w poniższych przykładach:
>>> a, = [1, 2]
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
ValueError: too many values to unpack (expected 1)
>>> a, = []
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
ValueError: not enough values to unpack (expected 1, got 0)