2020.12.18
Najmniejszy framework do testów w Pythonie
Jeśli interesujesz się choć trochę programowaniem, to zapewne trafiłeś kiedyś na artykuły porównujące, ile linii kodu potrzebnych jest do stworzenia uwielbianego hello world w różnych językach programowania. Sam zresztą pokazywałem, w pierwszym wpisie na tym blogu ile linii kodu potrzebne jest w Pythonie, aby wyświetlić proste witaj swiecie. A co by się stało, gdybyśmy w tych rozważaniach poszli ciut dalej i zastanowili się, ile potrzebnych jest linii kodu, aby stworzyć działający framework do testów?
Jeszcze mniejszy framework do testów
Do tego artykułu istnieje kontynuacja, ale zachęcam do przeczytania obu artykułów w kolejności, w jakiej były publikowane. Jeśli jednak znasz już ten artykuł, to kolejny zatytułowany jest jeszcze mniejszy framework do testów w pythonie i również zachęcam do jego lektury.
Ale skąd w ogóle ten pomysł?¶
Historia tego pomysłu sięga początków tegorocznej pandemii i sławnego #hot16challenge oraz pomysłu, o jakim opowiedział mi wtedy Bartosz Kita z bloga Tester Programuje. Sam pomysł był prosty i do tej pory mocno żałuję, że pomimo gotowego kodu, nie znalazłem czasu na nagranie krótkiego filmu, gdzie bym o tym opowiedział (wybacz Bartek, bo pomysł był przedni). O co chodzi? Najlepiej niech sam autor o tym opowie:
Wiem, że akcja już dawno przestała być modna, ale skoro kod powstał, a wiele osób prosiło mnie o ciut bardziej techniczne wpisy, to pobawmy się wspólnie troszkę Pythonem i pokażę Ci jego siłę. Jak zapewne domyślasz się po treści powyższego filmu, spróbuję pokazać Ci, że w 16 linijkach kodu da się stworzyć działający framework do testów.
Zanim jednak to zrobię, miej na uwadze, że zostanie tu złamane kilka reguł formatowania kodu Pythona, które są opisane w dokumencie PEP-8 - Style Guide for Python Code. Bez tych drobnych, celowych wypaczeń, nie udało mi się upakować kodu w tak małej ilości linijek.
Założenia¶
Aby cała powyższa zabawa w ogóle nam się udała, musimy przyjąć pewne założenia i mieć świadomość, że stworzony framework będzie miał dosyć ograniczoną funkcjonalność (choć bardzo łatwo będzie można ją rozszerzyć). Założenia, jakie przyjąłem, prezentują się następująco:
- zajmiemy się testami REST API — wysyłając zapytanie do REST API, w odpowiedzi zawsze otrzymamy kod statusu odpowiedzi, a tworzony framework będzie sprawdzał właśnie ten kod,
- dane testowe będą przechowywane poza kodem Pythona — takie podejście pomoże nam zaoszczędzić kilka linijek kodu, ale również umożliwi tworzenie kolejnych testów tylko poprzez edycję zewnętrznego pliku oraz nie będzie wymagało znajomości Pythona,
- framework powinien wygenerować jakąś formę raportu — co to za testy, po których uruchomieniu nie dostaniemy jakiegoś raportu?
Założenia są proste, ale na pierwszy rzut oka wydaje się to praktycznie niemożliwe. A gdzieś napisałem, że tworzony kod będzie prosty i że to zadanie będzie łatwe do wykonania?
Co będziemy testować?¶
Skoro już wiemy, że chcemy testować jakieś REST API, to przydałoby się nam takie. Pod adresem https://reqres.in/ znajduje się przykładowe i dosyć proste API. Pomimo swoich ograniczeń na nasze potrzeby jest w zupełności wystarczające.
Potrzebne biblioteki¶
Skoro znamy już założenia, zastanówmy się, z czego możemy skorzystać, aby maksymalnie ograniczyć potrzebną ilość linii kodu.
requests¶
Najpopularniejszą i najbardziej rozbudowaną biblioteką do wysyłania zapytań do REST API jest biblioteka requests <https://requests.readthedocs.io/en/master/>
_. Biblioteka wymaga instalacji, gdyż nie jest częścią biblioteki standardowej i możemy to zrobić, wydając następującą komendę:
unittest¶
Unittest to wbudowany w bibliotekę standardową framework do przeprowadzania testów, a więc nie wymaga dodatkowej instalacji. W naszym kodzie posłuży nam jako podstawa budowanego frameworka.
xmlrunner¶
Xmlrunner jest tzw. runnerem dla frameworka unittest, który umożliwia generowanie raportów w formacie XML, zgodnym ze schematem JUnit. Format ten akceptowany jest przez większość narzędzi CI/CD, a więc budowany framework można bardzo prosto z takimi narzędziami zintegrować oraz przeglądać raport z przeprowadzonych testów. W celu instalacji biblioteki należy wydać następującą komendę:
json¶
Standardowa biblioteka Pythona, a więc nie wymaga instalacji. Biblioteka ta umożliwia obsługę danych w formacie JSON i posłuży do odczytu danych testowych z zewnętrznego pliku.
Kodujemy¶
Skoro mamy już wszystkie potrzebne elementy tej całej układanki to zacznijmy w końcu pisać ten kod. Na początek stwórzmy sobie 2 pliki: tests.py
i tests.json
. Mając je, przejdźmy do pliku tests.py
.
import¶
Każdy szanujący się programista Pythona, postępuje zgodnie z regułami i na początku pliku importuje wszystkie biblioteki. Dobry obyczaj mówi, że import każdej biblioteki powinie znajdować się w oddzielnej linijce, jednakże, ze względu na ograniczone miejsce, w naszym kodzie wszystkie importy zostaną wykonane w jednej linii:
Kolejność importów jest dowolna (choć i tutaj są pewne reguły, które warto stosować).
Pierwszy test¶
Wiemy, że chcemy sprawdzać status odpowiedzi na wysłane żądanie, a więc zacznijmy od czegoś prostego: wyślemy proste żądanie typu GET na adres url https://reqres.in/api/users i sprawdzimy kod statusu odpowiedzi.
Super. Wiemy, że endpoint działa a kod 200
mówi nam, że wszystko przebiegło bez problemów (kod ten oznacza `OK``).
No ale gdzie tu test? No faktycznie nie ma go. Więc przeróbmy troszeczkę ten kod.
Po uruchomieniu tego kodu nic się nie wyświetli, gdyż wszystko jest w porządku. W ramach samodzielnego ćwiczenia sprawdź, co się stanie jak podmienisz 200
na 202
.
Czy to już koniec? Na razie mamy 4 linie kodu (po importach zostawiamy jedną linię przerwy) a mamy do dyspozycji ich aż 16. No więc co dalej?
Test w unittest¶
Przeróbmy teraz kod tak, aby wykorzystać dobrodziejstwa unittest.
class Tests(unittest.TestCase):
def test_get_all_users(self):
response = requests.get("https://reqres.in/api/users")
self.assertEqual(response.status_code, 200)
Odpowiedzmy sobie, co tu się wydarzyło:
- stworzyliśmy klasę testową
Tests``, która dziedziczy po
unittest.TestCase``, - przenieśliśmy nasz test do metody
test_get_all_users
(w unittest, wszystkie metody testowe, muszą zaczynać się od słowa test), - podmieniliśmy
assert
naassertEqual
.
Niestety przy próbie uruchomienia, nic się nie wydarzy.
Naprawmy to poprzez dodanie poniższego kodu na końcu pliku oraz go uruchommy:
Wygląda to już zdecydowanie lepiej, ale to nie koniec naszej zabawy. Zajmijmy się teraz przechowywaniem danych testowych w pliku.
tests.json¶
Zanim jednak dojdziemy do samego pliku, zmieńmy jeszcze jedną rzecz w naszym kodzie, tak abyś lepiej zrozumiał, dlaczego pewne rzeczy działają. Zauważ, że w naszym kodzie, do tej pory używaliśmy requests.get
. Czy da się to jakoś sparametryzować? Jak to mawiają 'ciekawość to pierwszy stopień do piekła' to poszukajmy do niego drzwi. Jeśli do edycji kodu, używasz PyCharma, to klikając w get
z przytrzymanym klawiszem CTRL
przejdziesz to implementacji metody requests.get
. I cóż tam widzimy (pominąłem komentarze)?
def get(url, params=None, **kwargs):
kwargs.setdefault('allow_redirects', True)
return request('get', url, params=params, **kwargs)
No więc skoro samo biblioteka requests
tak robi, to dlaczego nie możemy my tak postąpić? Nasz kod po zmianach będzie wyglądał tak:
import unittest, xmlrunner, json, requests
class Tests(unittest.TestCase):
def test_get_all_users(self):
response = requests.request(
method='GET',
url="https://reqres.in/api/users"
)
self.assertEqual(response.status_code, 200)
if __name__ == '__main__':
unittest.main()
Zauważ, że podałem wprost nazwy parametrów przekazywanych do requests.request
.
Przejdźmy zatem do przeniesienia danych testowych do pliku tests.json
. W pliku musimy przechować tak naprawdę 3 informacje dla pojedynczego testu (a dokładniej to 4, ale o tym będę mówił troskę dalej):
- metoda do wysyłki żądania,
- URL endpointu, na który wysyłamy żądanie,
- spodziewany kod statusu odpowiedzi.
Zawartość pliku tests.json
prezentuje się tak:
{
"request": {
"method": "GET",
"url": "https://reqres.in/api/users"
},
"assert": {
"statusCode": 200
}
}
Przeróbmy teraz nasz kod, tak aby skorzystał z tych danych:
import unittest, xmlrunner, json, requests
data = json.load(open('tests.json', 'r'))
class Tests(unittest.TestCase):
def test_get_all_users(self):
response = requests.request(
method=data['request']['method'],
url=data['request']['url'],
)
self.assertEqual(response.status_code, data['assert']['statusCode'])
if __name__ == '__main__':
unittest.main()
Co tu się zmieniło? Do zmiennej data
wczytaliśmy zawartość pliku tests.json
oraz podmieniliśmy wszystkie wartości testu na te odczytane z pliku. Zauważ, że dane pobrane z pliku i umieszczone w zmiennej data
tworzą słownik.
Zanim przejdziemy dalej, popatrz na wartości wstawiane do argumentów wywołania metody requests.request
. Nie zauważasz tam pewnej prawidłowości?
Podpowiem: porównaj nazwę argumentu, do którego wstawiane są dane z nazwą klucza, z jakiego te dane są pobierane.
Może da się to jakoś wykorzystać na naszą korzyść i zaoszczędzić ciut miejsca w kodzie? Przecież w tym momencie mamy już 14 linii kodu, a nie mamy jeszcze ani, większej ilości testów, ani raportów.
Rozpakowywanie słownika¶
Jeśli czytałeś mój artykuł dotyczący dekoratorów w Pythonie, to wspominam w nim o dwóch sposobach przekazywania argumentów do funkcji: przez args i kwargs (jeśli nie wiesz, o co chodzi, to zanim przejdziesz dalej, polecam się z tym zapoznać). W naszym kodzie przekazanie argumentów do metody requests.request
wykonaliśmy właśnie przy użyciu kwargs
, a więc de facto jako słownik, gdzie kluczem jest nazwa argumentu, a wartością danego klucza, wartość argumentu. Mówię o tym kawałku kodu:
W Pythonie istnieje mechanizm tzw. rozpakowywania słownika, który można wykorzystać do przekazania wartości do wywoływanej metody. Przyjrzyj się poniższemu zapisowi:
Zauważ, że wykorzystałem w nim zapis **
przed nazwą zmiennej, która jest słownikiem. Jak to działa? Zmienna data['request']
przechowuje słownik z dwoma kluczami: method
i url
. Zapis **
powoduje rozpakowanie słownika, a więc w przypadku wywołania metody, powoduje przypisanie konkretnym argumentów, wartości z odpowiadających ich nazwom kluczy ze słownika. Dlatego też oba powyższe zapisy są ze sobą równoważne. Jak więc teraz prezentuje się nasz kod?
import unittest, xmlrunner, json, requests
data = json.load(open('tests.json', 'r'))
class Tests(unittest.TestCase):
def test_get_all_users(self):
response = requests.request(**data['request'])
self.assertEqual(response.status_code, data['assert']['statusCode'])
if __name__ == '__main__':
unittest.main()
Zauważ, że z 14 linii kodu, zredukowaliśmy zapis do 11 linii. Można tutaj jeszcze jedną rzecz uprościć, a mianowicie pozbyć się zmiennej pomocniczej response
i nasz kod będzie się prezentował w następujący sposób:
import unittest, xmlrunner, json, requests
data = json.load(open('tests.json', 'r'))
class Tests(unittest.TestCase):
def test_get_all_users(self):
self.assertEqual(requests.request(**data['request']).status_code, data['assert']['statusCode'])
if __name__ == '__main__':
unittest.main()
Zeszliśmy tym samym do 10 linii kodu. Na co wykorzystamy pozostałe 6 linii?
Generator testów z danych testowych¶
Dochodzimy do najfajniejszej części tego wpisu, czyli jeszcze większej magii niż zapis z **
. Przeróbmy teraz nasz kod tak, aby metoda z testem nie była zdefiniowana bezpośrednio w klasie testów, ale umieszczona w niej w sposób dynamiczny. Zerknij na poniższy kod:
import unittest, xmlrunner, json, requests
data = json.load(open('tests.json', 'r'))
class Tests(unittest.TestCase):
pass
def abstract_test(self):
self.assertEqual(requests.request(**data['request']).status_code, data['assert']['statusCode'])
setattr(Tests, 'test_get_all_users', abstract_test)
if __name__ == '__main__':
unittest.main()
Tak naprawdę w dalszym ciągu pod względem funkcjonalnym oraz końcowego wynika, powyższy kod jest tym samym co poprzedni, gdzie metoda test_get_all_users
był zdefiniowa wewnątrz klasy Tests
.
Jak to działa?
-
Klasa
Tests
w kodzie została zaimplementowana tak, że nic poza dziedziczeniem po klasieunittest.TestCase
nie robi nic poza tym. Jest po prostu pustą definicją. -
Metoda służąca do wysyłania żądania do endpointu, znajdują się teraz poza ciałem klasy oraz została nazwana
abstract_test
. Sam sposób wysyłania żądania się nie zmienił. -
Następnie wywołujemy metodę setattr, która jest metodą wbudowaną w język Python. Pozwala ona na wstawienie do obiektu, nowego atrybutu oraz przypisania mu wartości (o tym również wspominałem w artykule dotyczącym dekoratorów w sekcji funkcja jest obiektem. Zauważ, że jej wywołanie przyjęło 3 argumenty:
- obiekt, do którego wstawiamy — u nas jest to klasa `Tests``,
- nazwę atrybutu, pod jakim będzie znajdowała się wstawiona wartość — u nas jest to `test_get_all_users``,
- wartość, jaka będzie przypisana do atrybutu — nas jest to adres w pamięci metody
abstract_test
(widać to po braku()
na końcu).
Jeśli wywołamy powyższy kod, to w dalszym ciągu otrzymujemy taki sam wynik.
No dobra. Umiemy już dynamicznie wstawić metodę z testem do obiektu, ale to jeszcze nie do końca jest generator. Żeby nasz kod umiał coś więcej, musimy dokonać jeszcze małych przeróbek w obu naszych plikach.
Zacznijmy od pliku `tests.json``:
{
"test_get_all_users": {
"request": {
"method": "GET",
"url": "https://reqres.in/api/users"
},
"assert": {
"statusCode": 200
}
}
}
Tu zmiany są niewielkie, bo tak naprawdę, nazwaliśmy tylko już istniejące dane jako test_get_all_users
.
Teraz kolej na plik `main.py``:
import unittest, xmlrunner, json, requests
data = json.load(open('tests.json', 'r'))
class Tests(unittest.TestCase):
pass
def add_test(cls, name):
def abstract_test(self):
self.assertEqual(requests.request(**data[name]['request']).status_code, data[name]['assert']['statusCode'])
setattr(cls, name, abstract_test)
for test_name in data.keys():
add_test(Tests, test_name)
if __name__ == '__main__':
unittest.main()
Co zmieniliśmy?
- Metoda
abstract_test
oraz wywołanie metodysetattr
ukryte zostało w metodzieadd_test
. Zauważ, że metoda ta przyjmuje dwa atrybuty:
cls
- to klasa, do której będziemy dodawać test,name
- to nazwa testu, jaki będziemy dodawać.
-
W metodzie
abstract_test
zmienił się sposób dotarcia do danych testowych w słowniku przechowywanym w zmiennejdata
. Doszedł tam po prostu dodatkowy poziom zagnieżdżenia wynikający ze zmiany struktury danych w plikutests.json
. Zauważ również, że zmiennaname
nie jest argumentem wywołania metodyabstract_test``, a metody nadrzędnej, czyli
add_test. Jak to możliwe, że to działa? Otóż zmienna
namestaje się dla metody
add_testzmienną globalną, ze względu na jej zagnieżdżenie wewnątrz metody
add_test`. -
Wywołanie
settatr
korzysta teraz ze zmienne `name``, a nie bezpośredniej nazwy. -
Dodaliśmy pętlę
for
iterującą po kluczach słownika ze zmiennejdata
. Te klucze to tak naprawdę nazwy testów z plikutests.json
(w tym momencie mamy tylko jeden klucz o wartości `test_get_all_users``).
Czy to wszystko?
Mamy 3 problemy:
- Mamy tylko 1 test.
- Brakuje nam jeszcze raportów.
- Mamy 17 linii kodu (o 1 za dużo).
Więcej testów¶
Skoro tyle się napracowaliśmy, to dorzućmy więcej testów. Jak możesz się domyślić, aby dopisać nowe testy, wystarczy odpowiednie dane umieścić w pliku tests.json
. Poniżej przykładowy zestaw testów:
{
"test_get_all_users": {
"request": {
"method": "GET",
"url": "https://reqres.in/api/users"
},
"assert": {
"statusCode": 200
}
},
"test_get_users_id_2": {
"request": {
"method": "GET",
"url": "https://reqres.in/api/users/2"
},
"assert": {
"statusCode": 200
}
},
"test_get_non_existing_user": {
"request": {
"method": "GET",
"url": "https://reqres.in/api/users/23"
},
"assert": {
"statusCode": 404
}
},
"test_create_new_user": {
"request": {
"method": "POST",
"url": "https://reqres.in/api/users",
"json": {
"name": "testerembyc",
"job": "tester"
}
},
"assert": {
"statusCode": 201
}
}
}
Raporty i 16 linii kodu¶
To zadanie to w zasadzie już tylko drobna formalność. Spójrz na poniższy kod:
import unittest, xmlrunner, json, requests
data = json.load(open('tests.json', 'r'))
class Tests(unittest.TestCase): pass
def add_test(cls, name):
def abstract_test(self):
self.assertEqual(requests.request(**data[name]['request']).status_code, data[name]['assert']['statusCode'])
setattr(cls, name, abstract_test)
for test_name in data.keys():
add_test(Tests, test_name)
if __name__ == '__main__':
unittest.main(testRunner=xmlrunner.XMLTestRunner())
Co się zmieniło:
- Implementacja klasy
Tests
mieści się w jednej linii (tak wiem, znów naginam dobre zasady formatowania kodu). - W wywołaniu metody
unittest.main
jako argumenttestRunner
podałem do tej pory nie wykorzystywanyxmlrunner
. Dzięki temu po uruchomieniu testów w konsoli zobaczymy poniższy tekst:
Running tests...
----------------------------------------------------------------------
....
----------------------------------------------------------------------
Ran 4 tests in 0.609s
OK
Generating XML reports...
Dodatkowo w katalogu z naszymi plikami, pojawi się plik o rozszerzeniu `.xml``, który jest naszym raportem z przeprowadzonych testów.
Czy da się jeszcze lepiej?¶
Jak zauważył Jakub Spórna z bloga https://sporna.dev/, można zrobić jeszcze małe poprawki w kodzie, zarówno względem ilości linii, jak i również czytelności oraz zaoszczędzenia dodatkowej 1 linii kodu. Jak tego dokonać? Jakub zaproponował poniższy kod:
import unittest, xmlrunner, json, requests
class Tests(unittest.TestCase): pass
def add_test(cls, name, data):
def abstract_test(self):
self.assertEqual(requests.request(**data['request']).status_code, data['assert']['statusCode'])
setattr(cls, name, abstract_test)
with open('tests.json', 'r') as json_file:
for test_name, test_data in json.load(json_file).items():
add_test(Tests, test_name, test_data)
if __name__ == '__main__':
unittest.main(testRunner=xmlrunner.XMLTestRunner())
Zakres zmian w kodzie, nie wymaga zbyt dużego komentarza i powinien być zrozumiały dla każdego, kto miał już choć trochę styczności z programowaniem w Pythonie.
Podsumowanie¶
Zmieściliśmy się w 16 linijkach kodu?
Chyba nam się udało (a po poprawkach od Jakuba mamy nawet jedną linijkę w zapasie). Choć nagięliśmy przy okazji kilka reguł dotyczących formatowania kodu w Pythonie, ale udało nam się zachować względną czytelność i dosyć sporą funkcjonalność.
Mam nadzieję, że ten wpis pokaz Ci jak potężnym narzędziem potrafi być Python.
Czy da się coś więcej z tego kodu wykrzesać?
Oczywiście, że tak, ale wtedy nie zmieścimy się w 16 linijkach kodu. Jako ćwiczenie dla Ciebie mogę podpowiedzieć, że przy niewielkim nakładzie pracy, można dodać dodatkowe sprawdzenia, np. czy dane, które otrzymujemy w odpowiedzi na wysłane żądanie, są danymi, jakich się spodziewamy. Jak to zrobić, to już zostawiam Ci jako dalsze ćwiczenie swoich szarych komórek (ten kod dla mnie był takim właśnie ćwiczeniem).
Ciąg dalszy ...
Kontynuację zmagań z najmniejszym frameworkiem do testów, możesz odnaleźć w kolejnym artykule zatytułowanym jeszcze mniejszy framework do testów. Zapraszam do jego lektury.
Bonus¶
Cały powyższy kod znajdziesz również w poniższym repozytorium w GitHubie.
Backlinks:
Python
- Najmniejszy framework do testów w Pythonie
- json - obsługa formatu JSON,
- requests - wysyłanie zapytań do REST API,
- unittest - framework do testów będący częścią biblioteki standardowej,
- xmlrunner - runner dla biblioteki unittest, wspomagający tworzenie raportów w formacie JUnit akceptowanym przez większość narzędzi CI/CD.
Jeszcze mniejszy framework do testów w Pythonie
Poniższy wpis jest kontynuacją poprzedniego wpisu, gdzie próbowałem pokazać Ci jak w Pythonie można stworzyć najmniejszy framework do testów.
Jak zacząć automatyzować testy?
Poznaj 12 pytań, które pomogą Ci rozpocząć proces automatyzacji testów. W zupełnym oderwaniu od języka programowania, frameworków do testów oraz technologi w jakiej napisana została aplikacja, którą będziesz testować. Całość opisana prostym i zrozumiałym językiem.
Pobieram darmowy poradnik