Comment utiliser des fonctions comme des mocks dans les tests de Python

Comment utiliser des fonctions comme des mocks dans les tests de Python

Une des questions les plus souvent posées concernant les tests est : Comment puis-je éviter d’appeler des services lourds pendant l’exécution des tests ?

Si j’avais appelé des services tels que Redis, Docker, MySQL, RabbitMQ ou d’autres pour chaque test, j’aurais probablement dû les installer sur mon serveur de tests. Cela aurait consommé des ressources et augmenté les temps d’exécution. Je pense que les développeurs n’auraient pas aimé cela.

Aussi, c’est utile pour prévenir l’usage des adresses URL réelles. Nous n’avons pas le contrôle sur ces adresses, elles pourraient ne pas être disponibles à certaines heures.

On peut profiter des mocks de Python pour vérifier, par exemple, que notre code effectue un appel à une adresse URL externe et force l’exécution des cas alternatifs. Comment faire si le service n’est pas disponible ou lorsqu’il renvoie une mauvaise réponse.

Pour le projet de démonstration que je vais présenter, je vais utiliser Poetry comme gestionnaire de packages, la bibliothèque requests pour appeler une URL externe et pytest avec unittest comme plugins de test.

Les exemples de code suivants ont été testés sur Python 3.11.

Utiliser Poetry pour configurer le projet

Poetry est un gestionnaire de packages très simple à utiliser. Tu peux le télécharger sur https://python-poetry.org/ . Voici la liste des dépendances à utiliser.

pyproject.toml
[tool.poetry.dependencies]
python = "^3.10"
requests = "^2.31.0"
[tool.poetry.group.dev.dependencies]
black = "^23.3.0"
pytest = "^7.4.0"
pytest-mock = "^3.11.1"

On a black, un formateur de code, pytest et pytest-mock pour avoir un moyen facile de faire des mocks sur Python, ils vont être dans les dépendances de développement. On n’a pas besoin de les avoir dans notre code déployé en production.

Comme dépendances de production, on va utiliser Python 3.11 et requests, le plugin pour effectuer plusieurs appels HTTP à différentes URL.

Un script en Python pour appeler une API

Comme script en Python, on va le faire très simplement. Je vais procéder à réaliser les 3 types d’appels les plus utilisés : un appel GET, un appel POST et un appel POST avec des données.

Si j’exécute ce script, il fonctionnera à condition que l’URL de JsonPlaceholder soit disponible.

main.py
import requests
todos_single_url = "https://jsonplaceholder.typicode.com/todos/1"
posts_url = "https://jsonplaceholder.typicode.com/posts"
def call_single_endpoint():
request = requests.get(todos_single_url)
return request.json()
def call_post_endpoint():
request = requests.post(posts_url)
return request.json()
def call_post_endpoint_with_data(data):
request = requests.post(posts_url, data)
return request.json()

Utiliser des mocks pour remplacer des fonctions

On va souvent utiliser la méthode patch de unittest.mock. Avec ce décorateur, on va substituer la méthode que l’on souhaite et en faire un mock. Dans ce processus, elle obtiendra de multiples fonctionnalités utiles qui nous permettront de savoir si elle a été appelée, les paramètres utilisés et même de retourner une valeur fausse comme réponse.

La fonction que l’on doit remplacer par un mock dans ce cas est requests.get. Assure-toi d’importer get uniquement depuis requests. On doit mettre l’adresse complète dans la fonction, ainsi que le package inclus.

Les fonctions dans les mocks sont très diverses, et parfois tu peux utiliser assert ou les assertions, qui sont inclus dans les mocks.

Vérifier si la fonction a été appelée une seule fois, mode décorateur

Pour ce cas, on va utiliser le mot-clé spécial de Python assert. Le mock est livré avec une fonction qui retourne vrai ou faux, appelée called_once.

Regardez comment on utilise un décorateur sur notre test test_call_single_endpoint_mock. De cette manière, on évite d’indenter le code.

tests.py
from unittest.mock import patch
from python_mocking import call_single_endpoint
@patch("requests.get")
def test_call_single_endpoint_mock(mocked_requests_get):
call_single_endpoint()
assert mocked_requests_get.called_once

Vérifier si la fonction a été appelée une seule fois, en utilisant with

On peut ainsi ne pas utiliser le décorateur, dans ce cas, on ne va pas faire de patch. C’est une ligne additionnelle qu’on doit ajouter. Personnellement, je préfère la version avec décorateur, mais il n’est jamais superflu d’apprendre les manières alternatives.

tests.py
from python_mocking import call_single_endpoint
def test_call_single_endpoint_mock_without_decorator():
with patch("requests.get") as mocked_requests_get:
call_single_endpoint()
assert mocked_requests_get.called_once

Vérifier si la fonction a été appelée, en utilisant le paramètre mocker

De plus, on peut utiliser mocker comme paramètre pour faire un mock de la fonction que tu manques de substituer. Avec cette méthode, on évite une indentation supplémentaire. L’avantage est que avec ce paramètre tu peux faire un mock de plus de fonctions, en évitant l’accumulation des décorateurs.

tests.py
from python_mocking import call_single_endpoint
def test_call_single_endpoint_mock_with_mocker_param(mocker):
mocked_requests_get = mocker.patch("requests.get")
call_single_endpoint()
assert mocked_requests_get.called_once

Vérifier si la fonction a été appelée avec des paramètres spécifiques

On a deux versions de ces assertions. On peut vérifier si une fonction a été appelée avec des paramètres spécifiques. Pour cela, on utilise la fonction du mock assert_called_with. Il s’agit d’une variation qui s’assure que la fonction a été appelée une seule fois exactement avec assert_called_once_with.

tests.py
from unittest.mock import patch
from python_mocking import call_post_endpoint_with_data, posts_url
@patch("requests.post")
def test_assert_called_with(mocked_requests_post):
data = {"userId": 3}
call_post_endpoint_with_data(data)
mocked_requests_post.assert_called_with(posts_url, data)
@patch("requests.post")
def test_assert_called_once_with(mocked_requests_post):
data = {"userId": 3}
call_post_endpoint_with_data(data)
mocked_requests_post.assert_called_once_with(posts_url, data)

Groupement des tests

Si on veut un meilleur ordre pour nos tests, on peut les regrouper dans une classe. Tu peux même combiner des tests groupés et non groupés dans le même fichier.

Dans l’exemple suivant, on va spécifier au mock l’objet à retourner. Cela peut être utile si tu veux forcer un flux spécifique pour tester comment tes fonctions (comme mocks) se comportent.

Souviens-toi que dans une classe, le premier paramètre de ses fonctions sera toujours self. Donc, le deuxième paramètre sera ta fonction comme mock.

tests.py
from unittest.mock import patch
from python_mocking import call_single_endpoint
class TestRequests:
@patch("requests.get")
def test_call_and_stubbed_response(self, mocked_requests_get):
mocked_requests_get.return_value.json.return_value = {"foo": "bar"}
call_single_endpoint()
assert mocked_requests_get.called_once
assert mocked_requests_get.return_value.json.return_value == {
"foo": "bar"}

Vérifier les appels consécutifs d’une méthode

Dans la fonction mock, il y a une méthode appelée side_effect. Cela est utile lorsque ta méthode est appelée plus d’une fois et que tu souhaites spécifier des retours exclusifs.

La méthode side_effect est également utilisée pour programmer des Exceptions comme réponse.

tests.py
from unittest.mock import patch
from python_mocking import call_single_endpoint
class TestRequests:
@patch("requests.get")
def test_call_and_response_different_values(self, mocked_requests_get):
mocked_requests_get.return_value.json.side_effect = [
{"fruit": "orange"},
{"fruit": "apple"},
]
first_result = call_single_endpoint()
assert mocked_requests_get.return_value.json.call_count == 1
assert first_result == {"fruit": "orange"}
second_result = call_single_endpoint()
assert second_result == {"fruit": "apple"}
assert mocked_requests_get.return_value.json.call_count == 2

Vérifier si un appel est similaire à un autre en utilisant call

Si on veut éviter d’utiliser assert_called_with ou assert_called_once_with, on peut aussi utiliser la fonction qui se trouve dans unittest appelée call. Ce que call fait, c’est retourner un appel à un Mock ou MagicMock. Ainsi, on va comparer si les appels à un point spécifique d’exécution sont les mêmes que ceux enregistrés par le mock.

tests.py
from unittest.mock import patch, call
from python_mocking import call_single_endpoint, todos_single_url
class TestRequests:
@patch("requests.get")
def test_call_args(self, mocked_requests_get):
call_single_endpoint()
assert mocked_requests_get.call_args == call(todos_single_url)

Vérifier si le paramètre n d’une fonction a été appelé avec une valeur spécifique

Ceci est une variation d’un test où l’on souhaite savoir si le paramètre dans une position spécifique d’une fonction a une valeur spécifique. Étant donné que l’on souhaite obtenir les paramètres d’un mock sous forme de liste, il sera très facile d’accéder à l’index exact.

Dans l’exemple suivant, on peut acquérir les paramètres d’un appel à une fonction en utilisant call_args.

tests.py
from unittest.mock import patch
from python_mocking import call_post_endpoint_with_data
class TestRequests:
@patch("requests.post")
def test_call_post_endpoint_with_data(self, mocked_requests_post):
mocked_requests_post.return_value.json.return_value = {"foo": "bar"}
call_post_endpoint_with_data({"userId": 2})
second_param_of_call = mocked_requests_post.call_args[0][1]
assert mocked_requests_post.called_once
assert second_param_of_call == {"userId": 2}

Le résultat final

Ceci est le code final de tous les tests que j’ai faits. Ils ne couvrent pas 100% des cas qu’une application peut avoir, mais j’espère avoir montré les plus utilisés.

Le lien vers le dépôt sera dans la section des Recommandations.

tests.py
from unittest.mock import patch, call
from python_mocking import (
call_single_endpoint,
call_post_endpoint,
call_post_endpoint_with_data,
posts_url,
todos_single_url,
)
@patch("requests.get")
def test_call_single_endpoint_mock(mocked_requests_get):
call_single_endpoint()
assert mocked_requests_get.called_once
def test_call_single_endpoint_mock_without_decorator():
with patch("requests.get") as mocked_requests_get:
call_single_endpoint()
assert mocked_requests_get.called_once
def test_call_single_endpoint_mock_with_mocker_param(mocker):
mocked_requests_get = mocker.patch("requests.get")
call_single_endpoint()
assert mocked_requests_get.called_once
@patch("requests.post")
def test_assert_called_with(mocked_requests_post):
data = {"userId": 3}
call_post_endpoint_with_data(data)
mocked_requests_post.assert_called_with(posts_url, data)
@patch("requests.post")
def test_assert_called_once_with(mocked_requests_post):
data = {"userId": 3}
call_post_endpoint_with_data(data)
mocked_requests_post.assert_called_once_with(posts_url, data)
class TestRequests:
@patch("requests.get")
def test_call_and_stubbed_response(self, mocked_requests_get):
mocked_requests_get.return_value.json.return_value = {"foo": "bar"}
call_single_endpoint()
assert mocked_requests_get.called_once_with(todos_single_url)
assert mocked_requests_get.return_value.json.return_value == {"foo": "bar"}
@patch("requests.get")
def test_call_args(self, mocked_requests_get):
call_single_endpoint()
assert mocked_requests_get.call_args == call(todos_single_url)
@patch("requests.get")
def test_call_and_response_different_values(self, mocked_requests_get):
mocked_requests_get.return_value.json.side_effect = [
{"fruit": "orange"},
{"fruit": "apple"},
]
first_result = call_single_endpoint()
assert mocked_requests_get.return_value.json.call_count == 1
assert first_result == {"fruit": "orange"}
second_result = call_single_endpoint()
assert second_result == {"fruit": "apple"}
assert mocked_requests_get.return_value.json.call_count == 2
@patch("requests.post")
def test_call_post_endpoint(self, mocked_requests_post):
mocked_requests_post.return_value.json.return_value = {"foo": "bar"}
call_post_endpoint()
assert mocked_requests_post.called_once
assert mocked_requests_post.return_value.json.return_value == {"foo": "bar"}
@patch("requests.post")
def test_call_post_endpoint_with_data(self, mocked_requests_post):
mocked_requests_post.return_value.json.return_value = {"foo": "bar"}
call_post_endpoint_with_data({"userId": 2})
second_param_of_call = mocked_requests_post.call_args[0][1]
assert mocked_requests_post.called_once
assert second_param_of_call == {"userId": 2}

Recommandations

Tu peux voir le dépôt avec le résultat final ici .

Pour plus d’informations sur unittest.mock, consulte la documentation .

Mes articles ne sont pas generés par l'IA, cependant ils pourrait y être corrigés. Le premier brouillon est ma création originale

Tags

Auteur

Écrit par Helmer Davila

Dans d'autres langues

Useful for avoid calling real APIs or services

How to use functions as Mocks in Python tests

Útil para evitar llamadas reales hacia API o servicios

Cómo usar funciones como Mocks para tests en Python

Articles connexes

J’ai essayé de tester quelques variables globales dans Python, spécialement pour un script, qui contient des variables globales. Et après avoir essayé et essayé (et échouer), je pense que je peux te comment le faire.

Faire un mock pour une variable globale dans les tests de Python