Przejdź do treści

testerembyc.pl

Czyli blog o tym, jak to jest być nie tylko testerem

Najmniejszy framework do testów w Pythonie

/images/posts/20201218/bobcat.jpg

Zdjęcie zaczerpnięte z portalu Pexels

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 świecie. 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?

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 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.

request

Najpopularniejszą i najbardziej rozbudowaną biblioteką do wysyłania zapytań do REST API jest biblioteka requests. Biblioteka wymaga instalacji, gdyż nie jest częścią biblioteki standardowej i możemy to zrobić, wydając następującą komendę:

python -m pip install requests

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ę:

pip install unittest-xml-reporting

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 :code:'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:

import unittest, xmlrunner, json, requests

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.

response = requests.get("https://reqres.in/api/users")
print(response.status_code)
>>> 200

Super. Wiemy, że endpoint działa a kod 200 mówi nam, że wszystko przebiegło bez problemów (200 oznacza OK).

No ale gdzie tu test? No faktycznie nie ma go. Więc przeróbmy troszeczkę ten kod.

response = requests.get("https://reqres.in/api/users")
assert response.status_code == 200

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_employment (w unittest, wszystkie metody testowe, muszą zaczynać się od słowa test)

  • podmieniliśmy assert na assertEqual

Niestety przy próbie uruchomienia, nic się nie wydarzy.

Naprawmy to poprzez dodanie poniższego kodu na końcu pliku oraz go uruchommy:

if __name__ == '__main__':
    unittest.main()
>>> .
----------------------------------------------------------------------
Ran 1 test in 0.281s
OK

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 :code:'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 :code:'get' z przytrzymanym klawiszem :code:'CTRL' przejdziesz to implementacji metody :code:'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 na prawdę 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 2 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 fakto jako słownik, gdzie kluczem jest nazwa argumentu, a wartością danego klucza, wartość argumentu. Mówię o tym kawałku kodu:

response = requests.request(
    method=data['request']['method'],
    url=data['request']['url'],
)

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:

response = requests.request(**data['request'])

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 2 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?

  1. Klasa Tests w kodzie została zaimplementowana tak, że nic poza dziedziczeniem po klasie unittest.TestCase nie robi nic poza tym. Jest po prostu pustą definicją.

  2. 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ł.

  3. 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?

  1. Metoda abstract_test oraz wywołanie metody setattr ukryte zostało w metodzie add_test. Zauważ, że metoda ta przyjmuje 2 atrybuty:

  • cls - to klasa, do której będziemy dodawać test,

  • name - to nazwa testu, jaki będziemy dodawać.

  1. W metodzie abstract_test zmienił się sposób dotarcia do danych testowych w słowniku przechowywanym w zmiennej data. Doszedł tam po prostu dodatkowy poziom zagnieżdżenia wynikający ze zmiany struktury danych w pliku tests.json. Zauważ również, że zmienna name nie jest argumentem wywołania metody abstract_test, a metody nadrzędnej, czyli add_test. Jak to możliwe, że to działa? Otóż zmienna name staje się dla metody add_test zmienną globalną, ze względu na jej zagnieżdżenie wewnątrz metody add_test.

  2. Wywołanie settatr korzysta teraz ze zmienne name, a nie bezpośredniej nazwy.

  3. Dodaliśmy pętlę for iterującą po kluczach słownika ze zmiennej data. Te klucze to tak na prawdę nazwy testów z pliku tests.json (w tym momencie mamy tylko jeden klucz o wartości test_get_all_users).

Czy to wszystko?

Mamy 3 problemy:

  1. Mamy tylko 1 test.

  2. Brakuje nam jeszcze raportów.

  3. 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",
        "jon": "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:

  1. Implementacja klasy Tests mieści się w jednej linii (tak wiem, znów naginam dobre zasady formatowania kodu).

  2. W wywołaniu metody unittest.main jako argument testRunner podałem do tej pory nie wykorzystywany xmlrunner. 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).

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.

Zapraszam do newslettera

Jeśli powyższy artykuł uznajesz za wartościowy oraz masz ochotę być informowany o nowościach na moim blogu, to zachęcam Cię do zapisania się do mojego newslettera. Obiecuję, że nie będę Cię spamował, a tylko czasu do czasu wyślę Ci powiadomienie o nowym artykule lub prześlę krótkiego maila z ciekawymi informacjami.
BONUS: Dodatkowo otrzymasz mój mini poradnik: Jak zacząć automatyzować testy? Czyli 12 pytań i odpowiedzi, które w tym pomogą

Imię
Nazwisko
E-mail

Wszystkie podane przez Ciebie dane, będą przetwarzane zgodnie z polityką prywatności.

Komentarze

Comments powered by Disqus