Cet article fournit un guide d’introduction simple à la bibliothèque unittest.mock et illustre quelques cas d’utilisation de base avec du code ainsi que quelques règles importantes sur l’utilisation des “mocks“ dans les tests unitaires. Commençons par quelques définitions basiques :
Qu’est-ce qu’un mock et à quoi peut-il servir ?
Un mock (un mot anglais qui signifie “simulateur”), est un objet qui consiste à imiter le comportement d’un autre objet. Mais ce n’est pas toujours très clair, n’est-ce pas ? Tout simplement, un mock vous permet d’avoir un objet “vide“, c’est ce qui va vous permettre de simuler un retour API sans vraiment appeler une API (pour simuler une connexion Google Cloud par exemple), et d’une manière générale de simuler un appel à une fonction sans appeler la fonction. Cet outil imite le comportement de nombreux objets et composants afin de vous permettre de réaliser vos tests unitaires de façon indépendante et isolée.
Par principe, tous les tests unitaires doivent être indépendants et reproductibles unitairement. Il s’agit pour le programmeur de tester l’unité fonctionnelle du code, indépendamment du reste du programme, ceci afin de s’assurer qu’il répond aux spécifications fonctionnelles et qu’il fonctionne correctement pour tous les cas d’usage. Notre mock peut vraiment nous aider ici lorsque, par exemple, la logique à l’intérieur de notre unité dépend des valeurs retournées par d’autres unités – le mock peux nous servir à retourner les valeurs attendues sans exécution de code d’autres modules.
Où et comment commencer à utiliser unittest.mock dans vos tests unitaires ? En fait, il existe plusieurs cas et façons de savoir comment et ce que vous pouvez faire avec le unittest.mock. La documentation complète est bien sûr disponible, mais c’est très dense. Cependant, dans cet article, quelques cas et astuces les plus courants et les plus utiles sont présentés afin de simplifier vos premiers pas avec des mocks, car c’est vraiment plus utile d’avoir quelques exemples pratiques à re-implémenter.
Apprenez par l’exemple
Prenons comme exemple, vous avez besoin de tester une connexion cliente GCP (Google Cloud Platform). Mais est-ce de notre responsabilité de tester le code de Google SDK? Nous sommes seulement responsables du code qui réalise les appels API avec les signatures adéquates et dans le bon ordre.
1. Un seul mock
Voici un exemple très facile, qui montre comment on peut réaliser un mock de Client Google.
Pour une simple démonstration, nous pouvons imaginer un cas où notre code devrait interagir avec un Bucket sur Google Cloud Storage. Pour cela, nous pouvons créer une classe simple Bucket
, qui pour le moment n’a que le constructeur de classe, mais a besoin de mock, car elle doit instancier un Google Cloud Client. Notre objectif n’est pas de tester comment Google SDK s’occupe de l’instanciation du Client (bien plus que cela – nous devons être en mesure d’éviter une véritable instanciation de Client, car cette opération prend du temps, des ressources et nécessite d’avoir accès à GCP, ce qui irait à l’encontre du principe d’indépendance), mais vérifier que notre constructeur appelle le constructeur Client()
et lui passe un nom de projet correct.
Voici le code de notre module bucket.py où on a la classe Bucket
et import de la classe google.cloud.storage.Client
.
from google.cloud.storage import Client class Bucket: def __init__(self, working_project: str): self.storage_client = Client(project=working_project)
Il y a plusieurs façons de créer un objet mock. Celle que je trouve la plus facile et évidente consiste à utiliser le décorateur @patch
de package unittest.mock pour la méthode test_construct
afin de pouvoir lui passer un paramètre supplémentaire qui correspondra à un objet mock dont les propriétés vont dépendre de l’argument de @patch
.
Dans l’exemple ci-dessous, l’argument ‘bucket.Client’
passé au décorateur @patch
signifie que l’objet mock créé dans le test, appelé mock_gcp_client
, va remplacer l’objet Client
dans le script bucket.py. Notre objet mock_gcp_client
va donc remplacer l’object de la classe google.cloud.storage.Client
dans la classe Bucket
, car cette dernière est définie dans le module bucket.py. On peut alors utiliser la méthode mock_gcp_client.assert_called_once_with(…)
pour vérifier que le constructeur de la classe Client
a été appelé de la bonne façon par la classe Bucket
, sans avoir vraiment instancié un objet Client
ni s’être connecté à GCP.
from unittest import TestCase from unittest.mock import patch import Bucket class BucketTest(TestCase): @patch('bucket.Client') def test_construct(self, mock_gcp_client): working_project = 'test project' bucket = Bucket(working_project) mock_gcp_client.assert_called_once_with(project=working_project) self.assertEqual(mock_gcp_client(), bucket.storage_client)
Une autre façon d’utiliser le patch
de package unittest.mock est de l’appliquer comme contexte comme dans l’exemple ci-dessous :
from unittest import TestCase from unittest.mock import patch import Bucket class BucketTest(TestCase): def test_construct(self): working_project = 'test project' with patch('bucket.Client') as mock_gcp_client: bucket = Bucket(working_project) mock_gcp_client.assert_called_once_with(project=working_project) self.assertEqual(mock_gcp_client(), bucket.storage_client)
Pour vérifier que notre méthode n’a été appelée qu’une seule fois et que nous y avons passé des paramètres corrects, la méthode assert_called_once_with (…)
est utilisée – de la même manière que nous l’avons fait dans le premier exemple ci-dessus.
Variation sur un même thème…
Il est également possible de créer des mocks de méthode de classe. Pour de tels cas, nous pouvons utiliser le decorator @patch.object
auquel nous passons les paramètres suivants: la classe d’objet et le nom de la méthode. Ici, nous avons un exemple trivial de méthode my_method
qui appelle la méthode get_bucket
qu’on a ajouté à l’intérieur de notre classe Bucket
dans le module bucket.py. Nous ne voulons pas que dans le test la méthode get_bucket
à l’intérieur d’un objet de classe Bucket
effectue une action réelle, mais nous voulons nous assurer que nous appelons cette méthode et simulons simplement le fait que la méthode retourne une valeur correcte.
bucket.py
from google.cloud.storage import Client class Bucket: def __init__(self, working_project: str) self.storage_client = Client(project=working_project) def get_bucket(self, bucket_name): return self.storage_client.get_bucket(bucket_name)
my_module.py
from bucket import Bucket ... bucket = Bucket(project_name) ... def my_method(bucket_name): return bucket.get_bucket(bucket_name)
my_module_test.py
from unittest import TestCase from unittest.mock import patch, MagicMock from bucket import Bucket from my_module import my_method class MyTest(TestCase): @patch.object(Bucket, 'get_bucket') def test_my_method(self, mock_get_bucket): expected_bucket_name = 'bucket_name' expected = 'some expected result' mock_get_bucket.return_value = expected_result result = my_method(expected_bucket_name) self.assertEqual(expected, result) mock_get_bucket.assert_called_once_with(expected_bucket_name)
Vous pouvez maintenant voir que nous pouvons utiliser un mock pour une seul classe ou méthode spécifique d’une classe, mais parfois notre code testé effectue des appels à plusieurs modules et classes. Pouvons-nous encore utiliser le décorateur patch ? Bien sûr, et il y a plusieurs façons de le faire.
2. Plusieurs mocks et return_value d’une méthode
Imaginez qu’en plus de la classe Bucket
du module bucket.py notre méthode my_method
ait besoin d’appeler la méthode read
de classe BucketReader
dans le module utils.py
qui sait, par exemple, lire les données d’un fichier conservé dans GCP Storage Bucket. Le problème est ici que l’exécution de la fonction my_method va provoquer l’instanciation d’un objet de la classe Bucket
à cause de l’appel à la fonction get_bucket
, ce que nous voulons éviter pour garantir l’indépendance de nos tests. La solution est alors de remplacer la sortie de la fonction get_bucket
par une instance de l’objet unnitest.mock.MagicMock
(mock_get_bucket.return_value = MagicMock()
dans le code). Pour pouvoir effectuer une comparaison facilement, il est désormais possible de remplacer la sortie d’une méthode avec du texte ou un objet d’un autre type.
utils/bucket_reader.py
from google.cloud import storage from bucket import Bucket ... def get_bucket(project_name: str, bucket_name: str) -> Bucket: # on instancie l'objet Bucket ici return Bucket(project_name).get_bucket(bucket_name) def read(bucket_to_read: Bucket, blob_name: str) -> str: # on lit le contenu d'un fichier stocké dans un bucket GCP et # le renvoie sous la forme d'une string blob = storage.Blob(blob_name) result = blob.download_as_string(client=bucket_to_read.client) return result ...
my_module.py
from bucket import Bucket from utils.bucket_reader import read, get_bucket ... def my_method(project: str, bucket_name: str, blob_name: str) -> str: bucket_to_read = get_bucket(project, bucket_name) return read(bucket_to_read, blob_name) ...
my_module_test.py
from unittest import TestCase from unittest.mock import patch, MagicMock, call from my_module import my_method, my_other_method class MyTest(TestCase): @patch('my_module.read') @patch('my_module.get_bucket') def test_my_method(self, mock_get_bucket, mock_read): blob_name = "test_blob.csv" project_name = 'my_project' bucket_name = 'my_bucket_name' # on utilis MagicMock() pour remplacer l’objet retourné par la version mocké de get_bucket() mock_bucket = MagicMock() mock_get_bucket.return_value = mock_bucket # on attribue une valeur à mocke_read.return_value pour qu’elle puisse être utilisée # comme résultat attendu de la version mocké de la méthode read() expected = "mock blob content" mock_read.return_value = expected result = my_method(project_name, bucket_name, blob_name) mock_get_bucket.assert_called_once_with(project_name, bucket_name) mock_read.assert_called_once_with(mock_bucket, blob_name) self.assertEqual(expected, result) ...
Vous pouvez également utiliser le décorateur @patch.multiple
, qui modifie simplement le mode de déclaration de plusieurs mocks. Utilisez unittest.mock.DEFAULT
comme valeur si vous voulez que patch.multiple()
crée des mocks pour vous et les passe à la méthode de test décorée par le mot clé@patch.multiple
. D’après la documentation, lorsque vous n’utilisez pas DEFAULT, ce que vous utilisez pour définir votre méthode corrigée n’est pas transmise à la méthode décorée.
... from unittest.mock import patch, MagicMock, DEFAULT ... @patch.multiple("my_module", get_bucket=DEFAULT, read=DEFAULT) def test_my_method(self, get_bucket, read): ...
… et la combinaison de deux décorateurs patch@
et @patch.multiple
ensemble est aussi possible:
... from unittest.mock import patch, MagicMock, DEFAULT ... @patch('my_module.Bucket') @patch.multiple("my_module", get_bucket=DEFAULT, read=DEFAULT) def test_my_method(mock_bucket, get_bucket, read): ....
Note importante : lorsque vous utilisez @patch
, faites attention à la séquence des déclarations et à la séquence des paramètres que vous passez à la méthode de test. Le mock déclaré dans la ligne la plus proche de la déclaration de méthode, se déclenche en premier, et ainsi de suite. @patch.multiple
lorsqu’il est utilisé avec d’autres décorateurs de patch, s’attend à ce que vous mettiez des arguments passés par mot-clé après le dernier des arguments standard créés par @patch
.
3. Cas où un mock a été appelé plus d’une fois
C’est souvent le cas quand on appelle la même méthode dans notre code, par exemple avec différentes entrées. Dans ce cas, nous ne pouvons pas utiliser mock_method.assert_called_once
ou mock_method.assert_called_once_with
pour effectuer un test correct. De plus, il est important de pouvoir affirmer que le nombre d’appels de méthode est correct et correspond au nombre d’appels attendus. Il est également important de pouvoir tester que les appels ont été effectués dans un ordre correct. Le code ci-dessous illustre ce cas.
bucket.py
from google.cloud.storage import Client class Bucket: def __init__(self, working_project: str): self.prefix = '' self.storage_client = Client(project=working_project) def get_bucket(self, bucket_name: str): return self.storage_client.get_bucket(bucket_name) def set_prefix(self, prefix): self.prefix = prefix def get_prefix(self) -> str: return self.prefix ...
my_module.py
from bucket_reader import get_bucket def my_other_method(bucket): bucket_name_prefix = bucket.get_prefix() bucket_name_1 = bucket_name_prefix + "_name_1" b1 = get_bucket(bucket_name_1) bucket_name_2 = bucket_name_prefix + "_name_2" b2 = get_bucket(bucket_name_2) ...
Voici un test qui vérifie le nombre d’appels de méthode mock et l’ordre correct des appels de méthode mock :
from unittest import TestCase from unittest.mock import patch, MagicMock, call from my_module import my_other_method class MyTest(TestCase): @patch('my_module.get_bucket') def test_my_other_method(self, mock_get_bucket): prefix = "my_bucket" # on cree MagicMock() pour remplacer le vrai objet Bucket mock_bucket = MagicMock() # définissons la valeur de retour de la méthode get_prefix de notre mock Bucket mock_bucket.get_prefix.return_value = prefix bucket_name_1 = "_name_1" bucket_name_2 = "_name_2" # on cree une liste de noms préfixés de buckets pour les utiliser plus tard bucket_names = [prefix + bucket_name_1, prefix + bucket_name_2] my_other_method(mock_bucket) method_call_count = 2 # vérifie d'abord que le nombre d'appels de méthode mock est correct self.assertEqual(method_call_count, mock_get_bucket.call_count) for i in range(method_call_count): # et maintenant on vérifie que les appels mock et leur séquence sont également corrects self.assertEqual(call(bucket_names[i]), mock_get_bucket.mock_calls[i]) ...
Ou bien, le code de test peut ressembler à ça :
from unittest import TestCase from unittest.mock import patch, call, MagicMock from my_module import my_other_method class MyTest(TestCase): @patch('my_module.get_bucket') def test_my_other_method_as_list(self, mock_get_bucket): prefix = "my_bucket" # on utilise MagicMock() pour remplacer un objet réel Bucket mock_bucket = MagicMock() # définissons la valeur de retour de la méthode get_prefix de notre mock Bucket mock_bucket.get_prefix.return_value = prefix # on cree une liste de noms préfixés de buckets pour les utiliser plus tard # pour la création d'appels mock attendus bucket_name_1 = "_name_1" bucket_name_2 = "_name_2" bucket_names = [prefix + bucket_name_1, prefix + bucket_name_2] expected_mock_calls = [] # on ajoute les objects call() por chaque nom de bucket à la liste expected_mock_calls # car notre code doit appeler la méthode get_bucket() pour chaque nom de bucket for bucket_name in bucket_names: expected_mock_calls.append(call(bucket_name)) my_other_method(mock_bucket) # on vérifie que les listes d'appels mock attendus et réels sont égales self.assertListEqual(expected_mock_calls, mock_get_bucket.mock_calls) ...
Suivez les règles importantes
- Créez un mock dans la destination, PAS dans la source ! Par exemple, vous importez
method_1
du moduleaaa.bbb.methods
dans le moduleccc.ddd.my_module
. Vous créez maintenant un mock pour la méthodemethod_1
dans votre test pour la méthode du moduleccc.ddd.my_module
. Votre argument pour la méthodepatch
devrait donc être: patch(‘ccc.ddd.my_module.method_1’) et non pas aaa.bbb.methods.method_1.
module aaa/bbb/methods.py
... def method_1(params): ...
module ccc/ddd/my_module.py
from aaa.bbb.methods import method_1 ... def my_method(): ...
test_my_module.py
from ccc.ddd.my_module import my_method class MyTest(TestCase): @patch('ccc.ddd.my_module.method_1') def test_my_method(self, mock_method_1): ... my_method() ...
2. Conservez l’ordre correct des déclarations des mocks dans la signature de votre méthode de test.
... @patch('method_1') @patch('method_2') @patch('method_3') def my_method_test(self, mock_method_3, mock_method_2, mock_method_1): ...
3. Il est nécessaire d’utiliser les méthodes assertEqual
ou assertListEqual
intégrées au package unittest
. N’oubliez pas que l’attribut mock_calls
des fonctions mocks contient une liste d’objets call
de la fonction auxquels on ne peut pas appliquer de fonction de type assert
comme assert_called_once_with
. Cela ne fonctionne pas comme vous pourriez le penser…
# THIS WORKS mock_calls = mock_method_1.mock_calls # THIS DOES NOT WORK!!! mock_calls[0].assert_called_once()
4. Assurez-vous de ne pas confondre la méthode
mock avec la méthode d’un objet mock. Si vous avez créé un objet mock, mais que vous souhaitez accéder à sa méthode non statique, vous pouvez y accéder via l’instance d’objet mock.
my_module.py
from module_1 import Class1 def my_method(): cls1 = Class1() return cls1.method_1()
test_my_module.py
from my_module my_method class MyTest(TestCase): @patch('my_module.Class1') def test_my_method(self, mock_cls_1): ... my_method() ... # THIS WORKS mock_cls_1().method_1.assert_called_once() # THIS DOES NOT WORK!!! mock_cls_1.method_1.assert_called_once()
Quelques exemples de code sont disponibles pour téléchargement dans ce repo git.
Conclusion
Comme vous pouvez le voir avec cet article, il est relativement simple de commencer à utiliser unittest.mock et avec quelques exemples d’introduction, vous le ferez facilement. En effet, les modes d’utilisation et les capacités fournies par la bibliothèque
unittest.mock
sont assez variés. J’espère donc qu’avec ce guide de démarrage rapide, vous vous sentirez suffisamment inspirés pour les explorer davantage.