3 façons de tester votre API avec Python

Dans ce didacticiel, vous apprendrez à tester un code unitaire qui exécute des requêtes HTTP. En d’autres termes, vous découvrirez l’art des tests unitaires d’API en Python.
Les tests unitaires sont destinés à tester une seule unité de comportement. Lors des tests, une règle empirique bien connue consiste à isoler le code qui atteint des dépendances externes.
Par exemple, lors du test d’un code qui exécute des requêtes HTTP, il est recommandé de remplacer le vrai appel par un faux appel pendant la durée du test. De cette façon, vous pouvez le tester unitairement sans effectuer une véritable requête HTTP à chaque fois que vous exécutez le test.
La question est, comment isoler le code ?
J’espère que c’est ce à quoi je vais répondre dans ce post ! Je vais non seulement vous montrer comment le faire, mais aussi peser le pour et le contre de trois approches différentes.
Conditions:
- Python 3.8
- pytest-moqueur
- demandes
- ballon
- réponses
- magnétoscope.py
Contents
Application de démonstration utilisant une API REST météo
Pour replacer ce problème dans son contexte, imaginez que vous créez une application météo. Cette application utilise une API REST météo tierce pour récupérer les informations météorologiques d’une ville particulière. L’une des exigences est de générer une page HTML simple, comme l’image ci-dessous :
Pour obtenir les informations sur la météo, vous devez les trouver quelque part. Heureusement, OpenWeatherMap fournit tout ce dont vous avez besoin via son service API REST.
D’accord, c’est cool, mais comment puis-je l’utiliser ?
Vous pouvez obtenir tout ce dont vous avez besoin en envoyant un GET
demande à: https://api.openweathermap.org/data/2.5/weather?q={city_name}&appid={api_key}&units=metric
. Pour ce tutoriel, je vais paramétrer le nom de la ville et régler sur l’unité métrique.
Récupérer les données
Pour récupérer les données météo, utilisez requests
. Vous pouvez créer une fonction qui reçoit un nom de ville en paramètre et renvoie un JSON. Le JSON contiendra la température, la description de la météo, le coucher du soleil, l’heure du lever du soleil, etc.
L’exemple ci-dessous illustre une telle fonction :
def find_weather_for(city: str) -> dict:
"""Queries the weather API and returns the weather data for a particular city."""
url = API.format(city_name=city, api_key=API_KEY)
resp = requests.get(url)
return resp.json()
L’URL est composée de deux variables globales :
BASE_URL = "https://api.openweathermap.org/data/2.5/weather"
API = BASE_URL + "?q={city_name}&appid={api_key}&units=metric"
L’API renvoie un JSON au format suivant :
{
"coord": {
"lon": -0.13,
"lat": 51.51
},
"weather": [
{
"id": 800,
"main": "Clear",
"description": "clear sky",
"icon": "01d"
}
],
"base": "stations",
"main": {
"temp": 16.53,
"feels_like": 15.52,
"temp_min": 15,
"temp_max": 17.78,
"pressure": 1023,
"humidity": 72
},
"visibility": 10000,
"wind": {
"speed": 2.1,
"deg": 40
},
"clouds": {
"all": 0
},
"dt": 1600420164,
"sys": {
"type": 1,
"id": 1414,
"country": "GB",
"sunrise": 1600407646,
"sunset": 1600452509
},
"timezone": 3600,
"id": 2643743,
"name": "London",
"cod": 200
Les données sont renvoyées sous forme de dictionnaire Python lorsque vous appelez resp.json()
. Afin d’encapsuler tous les détails, vous pouvez les représenter comme un dataclass
. Cette classe a une méthode de fabrique qui obtient le dictionnaire et renvoie un WeatherInfo
exemple.
C’est bien parce que vous gardez la représentation stable. Par exemple, si l’API modifie la façon dont elle structure le JSON, vous pouvez modifier la logique à un seul endroit, le from_dict
méthode. Les autres parties du code ne seront pas affectées. Vous pouvez même obtenir des informations de différentes sources et les combiner dans le from_dict
méthode!
@dataclass
class WeatherInfo:
temp: float
sunset: str
sunrise: str
temp_min: float
temp_max: float
desc: str@classmethod
def from_dict(cls, data: dict) -> "WeatherInfo":
return cls(
temp=data["main"]["temp"],
temp_min=data["main"]["temp_min"],
temp_max=data["main"]["temp_max"],
desc=data["weather"][0]["main"],
sunset=format_date(data["sys"]["sunset"]),
sunrise=format_date(data["sys"]["sunrise"]),
)
Maintenant, vous allez créer une fonction appelée retrieve_weather
. Vous utiliserez cette fonction pour appeler l’API et renvoyer un WeatherInfo
afin que vous puissiez construire votre page HTML.
def retrieve_weather(city: str) -> WeatherInfo:
"""Finds the weather for a city and returns a WeatherInfo instance."""
data = find_weather_for(city)
return WeatherInfo.from_dict(data)
Bien, vous avez les blocs de construction de base pour notre application. Avant d’aller de l’avant, testez ces fonctions unitairement.
1. Tester l’API à l’aide de simulations
D’après Wikipédia, un objet fictif est un objet qui simule le comportement d’un objet réel en l’imitant. En Python, vous pouvez vous moquer de n’importe quel objet en utilisant le unittest.mock
lib qui fait partie de la bibliothèque standard. Pour tester le retrieve_weather
fonction, vous pouvez alors vous moquer requests.get
et renvoie des données statiques.
pytest-moqueur
Pour ce tutoriel, vous utiliserez pytest
comme cadre de test de choix. Les pytest
La bibliothèque est très extensible grâce à des plugins. Pour atteindre nos objectifs moqueurs, utilisez pytest-mock
. Ce plugin extrait un tas de configurations de unittest.mock
et rend votre code de test très concis. Si vous êtes curieux, j’en discute plus dans un autre article de blog.
Bon, assez parlé, montre-moi le code.
Voici un cas de test complet pour le retrieve_weather
fonction. Ce test utilise deux appareils : l’un est le mocker
montage fourni par le pytest-mock
brancher. L’autre est à nous. Ce ne sont que les données statiques que vous avez enregistrées à partir d’une demande précédente.
@pytest.fixture()
def fake_weather_info():
"""Fixture that returns a static weather data."""
with open("tests/resources/weather.json") as f:
return json.load(f)
def test_retrieve_weather_using_mocks(mocker, fake_weather_info):
"""Given a city name, test that a HTML report about the weather is generated
correctly."""
# Creates a fake requests response object
fake_resp = mocker.Mock()
# Mock the json method to return the static weather data
fake_resp.json = mocker.Mock(return_value=fake_weather_info)
# Mock the status code
fake_resp.status_code = HTTPStatus.OKmocker.patch("weather_app.requests.get", return_value=fake_resp)
weather_info = retrieve_weather(city="London")
assert weather_info == WeatherInfo.from_dict(fake_weather_info)
Si vous exécutez le test, vous obtenez le résultat suivant :
============================= test session starts ==============================
...[omitted]...
tests/test_weather_app.py::test_retrieve_weather_using_mocks PASSED [100%]
============================== 1 passed in 0.20s ===============================
Process finished with exit code 0
Super, tes tests sont réussis ! Mais… La vie n’est pas un lit de roses. Ce test a des avantages et des inconvénients. Je vais les regarder.
Avantages
Eh bien, un avantage déjà évoqué est qu’en se moquant du retour de l’API, vous facilitez vos tests. Isolez la communication avec l’API et rendez le test prévisible. Il retournera toujours ce que vous voulez.
Les inconvénients
En ce qui concerne les inconvénients, le problème est, que faire si vous ne voulez pas utiliser requests
plus et décidez d’aller avec la bibliothèque standard urllib
. Chaque fois que vous modifiez la mise en œuvre de find_weather_for
, vous devrez adapter le test. Un bon test ne change pas lorsque votre implémentation change. Ainsi, en vous moquant, vous finissez par coupler votre test avec l’implémentation.
En outre, un autre inconvénient est la quantité de configuration que vous devez effectuer avant d’appeler la fonction, au moins trois lignes de code.
...
# Creates a fake requests response object
fake_resp = mocker.Mock()
# Mock the json method to return the static weather data
fake_resp.json = mocker.Mock(return_value=fake_weather_info)
# Mock the status code
fake_resp.status_code = HTTPStatus.OK
...
Puis-je faire mieux ?
Oui, s’il vous plaît, suivez. Je vais voir maintenant comment l’améliorer un peu.
Utilisation des réponses
Railleur requests
en utilisant le mocker
fonctionnalité a l’inconvénient d’avoir une longue configuration. Un bon moyen d’éviter cela est d’utiliser une bibliothèque qui intercepte requests
les appelle et les patche. Il y a plus d’une bibliothèque pour ça, mais la plus simple pour moi est responses
. Voyons comment l’utiliser pour remplacer mock
.
@responses.activate
def test_retrieve_weather_using_responses(fake_weather_info):
"""Given a city name, test that a HTML report about the weather is generated
correctly."""
api_uri = API.format(city_name="London", api_key=API_KEY)
responses.add(responses.GET, api_uri, json=fake_weather_info, status=HTTPStatus.OK)weather_info = retrieve_weather(city="London")
assert weather_info == WeatherInfo.from_dict(fake_weather_info)
Encore une fois, cette fonction utilise notre fake_weather_info
fixation.
Ensuite, lancez le test :
============================= test session starts ==============================
...
tests/test_weather_app.py::test_retrieve_weather_using_responses PASSED [100%]
============================== 1 passed in 0.19s ===============================
Excellent! Ce test passe aussi. Mais… Ce n’est toujours pas génial.
Avantages
L’avantage d’utiliser des bibliothèques comme responses
est-ce que vous n’avez pas besoin de patcher requests
nous-mêmes. Vous enregistrez une configuration en déléguant l’abstraction à la bibliothèque. Cependant, au cas où vous ne l’auriez pas remarqué, il y a des problèmes.
Les inconvénients
Encore une fois, le problème est, un peu comme unittest.mock
, votre test est couplé à l’implémentation. Si vous remplacez requests
, votre test s’interrompt.
2. Tester l’API à l’aide d’un adaptateur
Si en utilisant des mocks je couple nos tests, que puis-je faire ?
Imaginez le scénario suivant : dites que vous ne pouvez plus utiliser requests
, et vous devrez le remplacer par urllib
puisqu’il est livré avec Python. Non seulement cela, vous avez appris la leçon de ne pas coupler le code de test avec l’implémentation, et vous voulez éviter cela à l’avenir. Vous souhaitez remplacer urllib
et ne pas avoir à réécrire les tests.
Il s’avère que vous pouvez faire abstraction du code qui exécute le GET
demander.
Vraiment? Comment?
Vous pouvez l’abstraire en utilisant un adaptateur. L’adaptateur est un modèle de conception utilisé pour encapsuler ou envelopper l’interface d’autres classes et l’exposer en tant que nouvelle interface. De cette façon, vous pouvez changer les adaptateurs sans changer notre code. Par exemple, vous pouvez encapsuler les détails sur requests
dans notre find_weather_for
et l’exposer via une fonction qui ne prend que l’URL.
Donc ça:
def find_weather_for(city: str) -> dict:
"""Queries the weather API and returns the weather data for a particular city."""
url = API.format(city_name=city, api_key=API_KEY)
resp = requests.get(url)
return resp.json()
Devient ceci :
def find_weather_for(city: str) -> dict:
"""Queries the weather API and returns the weather data for a particular city."""
url = API.format(city_name=city, api_key=API_KEY)
return adapter(url)
Et l’adaptateur devient ceci :
def requests_adapter(url: str) -> dict:
resp = requests.get(url)
return resp.json()
Il est maintenant temps de refactoriser notre retrieve_weather
fonction:
def retrieve_weather(city: str) -> WeatherInfo:
"""Finds the weather for a city and returns a WeatherInfo instance."""
data = find_weather_for(city, adapter=requests_adapter)
return WeatherInfo.from_dict(data)
Donc, si vous décidez de changer cette implémentation en une qui utilise urllib
, il suffit d’échanger les adaptateurs :
def urllib_adapter(url: str) -> dict:
"""An adapter that encapsulates urllib.urlopen"""
with urllib.request.urlopen(url) as response:
resp = response.read()
return json.loads(resp)
def retrieve_weather(city: str) -> WeatherInfo:
"""Finds the weather for a city and returns a WeatherInfo instance."""
data = find_weather_for(city, adapter=urllib_adapter)
return WeatherInfo.from_dict(data)
Bon, et les tests ?
Pour tester retrieve_weather
, créez simplement un faux adaptateur qui est utilisé pendant le temps de test :
@responses.activate
def test_retrieve_weather_using_adapter(
fake_weather_info,
):
def fake_adapter(url: str):
return fake_weather_infoweather_info = retrieve_weather(city="London", adapter=fake_adapter)
assert weather_info == WeatherInfo.from_dict(fake_weather_info)
Si vous exécutez le test, vous obtenez :
============================= test session starts ==============================
tests/test_weather_app.py::test_retrieve_weather_using_adapter PASSED [100%]
============================== 1 passed in 0.22s ===============================
Avantages
L’avantage de cette approche est que vous avez réussi à découpler votre test de l’implémentation. Utilisation injection de dépendance d’injecter un faux adaptateur pendant le temps de test. En outre, vous pouvez échanger l’adaptateur à tout moment, y compris pendant l’exécution. Vous avez fait tout cela sans changer le comportement.
Les inconvénients
Les inconvénients sont que, puisque vous utilisez un faux adaptateur pour les tests, si vous introduisez un bogue dans l’adaptateur que vous utilisez dans l’implémentation, votre test ne le détectera pas. Par exemple, disons que nous passons un paramètre défectueux à requests
, comme ça:
def requests_adapter(url: str) -> dict:
resp = requests.get(url, headers=<some broken headers>)
return resp.json()
Cet adaptateur échouera en production et les tests unitaires ne le détecteront pas. Mais à vrai dire, vous avez également le même problème avec l’approche précédente. C’est pourquoi il faut toujours aller au-delà des tests unitaires et avoir aussi des tests d’intégration. Cela étant dit, envisagez une autre option.
3. Tester l’API à l’aide de VCR.py
Il est enfin temps de discuter de notre dernière option. Je ne l’ai découvert que très récemment, franchement. J’utilise des mocks depuis longtemps et j’ai toujours eu des problèmes avec eux. VCR.py
est une bibliothèque qui simplifie de nombreux tests qui effectuent des requêtes HTTP.
Cela fonctionne en enregistrant l’interaction HTTP la première fois que vous exécutez le test en tant que fichier YAML plat appelé un cassette. La demande et la réponse sont sérialisées. Lorsque vous exécutez le test pour la deuxième fois, VCR.py
interceptera l’appel et renverra une réponse pour la demande faite.
Voyons maintenant comment tester retrieve_weather
à l’aide de VCR.py below:
@vcr.use_cassette()
def test_retrieve_weather_using_vcr(fake_weather_info):
weather_info = retrieve_weather(city="London")
assert weather_info == WeatherInfo.from_dict(fake_weather_info)
Wow, c’est ça ? Pas de configuration ? Qu’est-ce que c’est @vcr.use_cassette()
?
Oui c’est ça! Il n’y a pas de configuration, juste un pytest
annotation pour dire au magnétoscope d’intercepter l’appel et d’enregistrer le fichier de la cassette.
A quoi ressemble le fichier cassette ?
Bonne question. Il y a plein de choses dedans. C’est parce que le magnétoscope enregistre chaque détail de l’interaction.
interactions:
- request:
body: null
headers:
Accept:
- '*/*'
Accept-Encoding:
- gzip, deflate
Connection:
- keep-alive
User-Agent:
- python-requests/2.24.0
method: GET
uri: https://api.openweathermap.org/data/2.5/weather?q=London&appid=<YOUR API KEY HERE>&units=metric
response:
body:
string: '{"coord":{"lon":-0.13,"lat":51.51},"weather":[{"id":800,"main":"Clear","description":"clearsky","icon":"01d"}],"base":"stations","main":{"temp":16.53,"feels_like":15.52,"temp_min":15,"temp_max":17.78,"pressure":1023,"humidity":72},"visibility":10000,"wind":{"speed":2.1,"deg":40},"clouds":{"all":0},"dt":1600420164,"sys":{"type":1,"id":1414,"country":"GB","sunrise":1600407646,"sunset":1600452509},"timezone":3600,"id":2643743,"name":"London","cod":200}'
headers:
Access-Control-Allow-Credentials:
- 'true'
Access-Control-Allow-Methods:
- GET, POST
Access-Control-Allow-Origin:
- '*'
Connection:
- keep-alive
Content-Length:
- '454'
Content-Type:
- application/json; charset=utf-8
Date:
- Fri, 18 Sep 2020 10:53:25 GMT
Server:
- openresty
X-Cache-Key:
- /data/2.5/weather?q=london&units=metric
status:
code: 200
message: OK
version: 1
C’est beaucoup!
En effet! La bonne chose est que vous n’avez pas besoin de vous en soucier beaucoup. VCR.py
s’en occupe pour vous.
Avantages
Maintenant, pour les pros, je peux énumérer au moins cinq choses :
- Pas de code de configuration.
- Les tests restent isolés, donc c’est rapide.
- Les tests sont déterministes.
- Si vous modifiez la demande, par exemple en utilisant des en-têtes incorrects, le test échouera.
- Il n’est pas couplé à l’implémentation, vous pouvez donc échanger les adaptateurs et le test réussira. La seule chose qui compte, c’est que votre demande soit la même.
Les inconvénients
Encore une fois, malgré les énormes avantages par rapport à la moquerie, il y a toujours des problèmes.
Si le fournisseur d’API modifie le format des données pour une raison quelconque, le test réussira toujours. Heureusement, ce n’est pas très fréquent, et les fournisseurs d’API versionnent généralement leurs API avant d’introduire de telles modifications de rupture. De plus, les tests unitaires ne sont pas destinés à accéder à l’API externe, il n’y a donc pas grand-chose à faire ici.
Une autre chose à considérer est la mise en place de tests de bout en bout. Ces tests appelleront le serveur à chaque exécution. Comme son nom l’indique, c’est un test plus large et lent. Ils couvrent beaucoup plus de terrain que les tests unitaires. En fait, tous les projets n’en auront pas besoin. Donc, à mon avis, VCR.py
est plus que suffisant pour les besoins de la plupart des gens.
Conclusion
Ça y est. J’espère que vous avez appris quelque chose d’utile aujourd’hui. Tester les applications clientes API peut être un peu intimidant. Pourtant, lorsqu’il est armé des bons outils et des bonnes connaissances, vous pouvez apprivoiser la bête.
Vous pouvez trouver l’application complète sur mon GitHub.
Cet article a été initialement publié sur le blog personnel de l’auteur et a été adapté avec permission.