Published by

Il y a 1 mois -

Temps de lecture 15 minutes

Comment empêcher unittest.mock de se moquer de vous

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_methodait 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

  1. Créez un mock dans la destination, PAS dans la source ! Par exemple, vous importez method_1 du module aaa.bbb.methods dans le module ccc.ddd.my_module. Vous créez maintenant un mock pour la méthode method_1 dans votre test pour la méthode du module ccc.ddd.my_module. Votre argument pour la méthode patch 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.

Published by

Commentaire

Laisser un commentaire

Votre adresse de messagerie ne sera pas publiée. Les champs obligatoires sont indiqués avec *

Nous recrutons

Être un Sapient, c'est faire partie d'un groupe de passionnés ; C'est l'opportunité de travailler et de partager avec des pairs parmi les plus talentueux.