Co można robić w tawernie? Pić, jeść, słuchać opowieści starych marynarzy lub… testować API! Jeśli wykonywanie tej czynności w portowej karczmie wydaje się Wam zaskakujące, to najwyraźniej nie słyszeliście jeszcze o pythonowym frameworku Tavern, służącym właśnie do testów API. To zaś oznacza, że prawdopodobnie warto poświęcić krótką chwilę na lekturę niniejszego wpisu.
Załóżmy, że chcemy przetestować API, zawierające między innymi endpointy POST /users
oraz GET /users/{user_id}
. Aby łatwiej nam było eksperymentować, użyjmy do tego jakiejś prostej implementacji, np.:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 |
from aiohttp import web class UsersDatabaseApp(web.Application): def __init__(self): web.Application.__init__(self) self.users = {} self.router.add_route('GET', '/users/{user_id}', self.get_user) self.router.add_route('POST', '/users', self.add_user) async def get_user(self, request): user_id = int(request.match_info['user_id']) if user_id in self.users: return web.json_response({'user_id': user_id, **self.users[user_id]}) return web.json_response({'error': f"User with ID {user_id} not found"}, status=400) async def add_user(self, request): json_data = await request.json() user_id = len(self.users) self.users[user_id] = { 'user_admin': json_data['user_admin'], 'user_first_name': json_data['user_first_name'], 'user_last_name': json_data['user_first_name'], 'user_email': json_data['user_email'], 'user_phone': json_data.get('user_phone', None) } return web.json_response({'user_id': user_id, **self.users[user_id]}, status=201) if __name__ == '__main__': port = 9000 app = UsersDatabaseApp() web.run_app(app, port=port) |
Kod ten zawiera kilka błędów, co przyda nam się przy jego testowaniu. Jak zrobić to przy użyciu biblioteki Tavern? Serwowany nam przez jej twórców pomysł polega na wykorzystaniu języka YAML do definiowania przypadków testowych. Nie piszemy kodu odpowiedzialnego za wysyłanie zapytań HTTP, lecz w przejrzysty sposób deklarujemy co chcemy zweryfikować i jakich danych należy użyć. Jeden z naszych testów, sprawdzający poprawność dodawania nowego użytkownika, mógłby więc wyglądać tak:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 |
test_name: Create a new admin user providing minimum set of input data includes: - !include common.yaml stages: - name: Create new admin user providing only mandatory parameters request: url: "{host}/users" method: POST json: user_admin: true user_first_name: "{test_user_first_name}" user_last_name: "{test_user_last_name}" user_email: "{test_user_email}" response: status_code: 201 body: user_id: !anyint user_admin: !bool "{tavern.request_vars.json.user_admin}" user_first_name: "{test_user_first_name}" user_last_name: "{test_user_last_name}" user_email: "{test_user_email}" save: body: returned_user_id: user_id new_user_admin: user_admin - name: Get created user request: url: "{host}/users/{returned_user_id}" method: GET response: status_code: 200 body: user_id: !int "{returned_user_id}" user_admin: !bool "{new_user_admin}" user_first_name: "{test_user_first_name}" user_last_name: "{test_user_last_name}" user_email: "{test_user_email}" |
Test składa się z dwóch etapów: w pierwszym wysyłamy request POST, dodając użytkownika o uprawnieniach administratora, zaś w drugim, przy pomocy metody GET, pobieramy dane o tym użytkowniku.
W sekcjach request
podajemy dane, które mają zostać wysłane do serwera (w naszym przykładzie w formacie JSON), metodę HTTP oraz adres endpointu, natomiast w response
definiujemy oczekiwany kod statusu i określamy co spodziewamy się otrzymać w odpowiedzi.
Dla przykładu, w piewszym kroku wymagamy, aby serwer zwracał te same wartości, które zostały mu przekazane w zapytaniu, a dodatkowo chcemy otrzymać wygenerowany przez niego identyfikator, który ma być liczbą całkowitą (!anyint
).
Zmienne, które potrzebujemy przekazywać pomiędzy różnymi krokami, zapisujemy, używając sekcji save:
. Tym sposobem po otrzymaniu pierwszej odpowiedzi z serwera zapamiętujemy user_id
i używamy go, wykonując kolejne zapytanie.
Zanim przystąpimy do odpalenia testu, konieczne będzie jeszcze zdefiniowanie pliku common.yaml
, który załączyliśmy w czwartej linii. Jest to nasz plik pomocniczy, w którym ustawiamy dane, wykorzystywane w wielu różnych testach, takie jak adres serwera, przykładowe dane itp.
1 2 3 4 5 6 7 8 9 |
name: Common test data description: Data for API tests variables: host: http://localhost:9000 test_user_first_name: Jan test_user_last_name: Kowalski test_user_email: jan@kowalski.pl |
Teraz, kiedy mamy już wszystko, włączmy nasz prosty serwer, zapiszmy test jako test_create_admin_user.tavern.yaml, i zlećmy jego wykonanie komendą tavern-ci test_create_admin_user.tavern.yaml
. Naszym oczom powinien ukazać się błąd, ponieważ, o czym już wspomniałem, nasze API zawiera bugi. Wydrukowany przez Tavern komunikat powinien pozwolić na zidentyfikowanie błędnego fragmentu, ale czy naprawdę konieczne jest zalewanie użytkownika setkami linii?
Na ten problem zwrócił w swoim wpisie uwagę również Alessandro Pagiaro. Opisał też kilka innych utrudnień, których świadomość należy mieć, decydując się na użycie tego frameworka. Od siebie dodam też, że sama dokumentacja nie jest przesadnie obszerna i nie zachwyca swoją dokładnością ani liczbą przykładów, przez co do niektórych rozwiązań trzeba docierać samodzielnie.
Warto natomiast zaznaczyć, że potrzebując wykonać bardziej skomplikowane operacje niż te obsługiwane na poziomie kodu w YAML-u, można zakodować je w Pythonie. Na przykład jeśli w celu autoryzacji musimy dokonać jakichś obliczeń, to możemy wrzucić je do pliku conftest.py
:
1 2 3 4 5 6 7 8 9 10 11 |
from datetime import datetime import pytest import base64 API_SECRET = 'secret' @pytest.fixture def password(): day_of_a_year = datetime.now().timetuple().tm_yday return base64.b64encode(f"{day_of_a_year}_{API_SECRET}".encode('utf-8')) |
a następnie odnieść się do tak zaimplementowanej funkcji wewnątrz testu:
1 2 3 4 5 6 7 8 9 10 11 |
marks: - usefixtures: - password stages: - name: Create new admin user providing only mandatory parameters request: url: "{host}/users" method: POST headers: authorization: "Basic {password}" |
Do pythonowego kodu możemy też oddelegować sprawdzanie co bardziej skomplikowanych warunków w otrzymywanych ze strony API odpowiedziach.
Chociaż tworzonym w Tavern testom nie sposób odmówić czytelności, to osobiście i tak nie wybrałbym tego rozwiązania. Zdecydowanie wolę wyzbyć się wszelkich ograniczeń i bezpośrednio korzystać chociażby z pythonowego modułu unittest lub nawet pytest (który to jest bazą dla frameworka Tavern). Jeśli jednak z jakiegoś powodu bardziej przemawia do Ciebie pisanie testów w YAML-u lub użycie Tavern sprawdziło się w Twoim projekcie, zachęcam do podzielenia się swoimi doświadczeniami!