
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.
[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.
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.
from unittest.mock import patchfrom 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.
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.
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
.
from unittest.mock import patchfrom 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.
from unittest.mock import patchfrom 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.
from unittest.mock import patchfrom 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.
from unittest.mock import patch, callfrom 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
.
from unittest.mock import patchfrom 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.
from unittest.mock import patch, callfrom 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 .