Une introduction à la moquerie en Python

Publié: 2022-03-11

Comment exécuter des tests unitaires en Python sans tester votre patience

Le plus souvent, le logiciel que nous écrivons interagit directement avec ce que nous appellerions des services "sales". En termes simples : des services cruciaux pour notre application, mais dont les interactions ont des effets secondaires voulus mais non souhaités, c'est-à-dire non souhaités dans le cadre d'un test autonome.

Par exemple : nous écrivons peut-être une application sociale et souhaitons tester notre nouvelle fonctionnalité "Publier sur Facebook", mais nous ne voulons pas réellement publier sur Facebook à chaque fois que nous exécutons notre suite de tests.

La bibliothèque Python unittest inclut un sous-paquet nommé unittest.mock - ou si vous le déclarez comme une dépendance, simplement mock - qui fournit des moyens extrêmement puissants et utiles pour se moquer et supprimer ces effets secondaires indésirables.

mocking et tests unitaires dans la bibliothèque python unittest

Remarque : mock est nouvellement inclus dans la bibliothèque standard à partir de Python 3.3 ; les distributions antérieures devront utiliser la bibliothèque Mock téléchargeable via PyPI.

Appels système contre Python Mocking

Pour vous donner un autre exemple, et celui que nous utiliserons pour le reste de l'article, considérez les appels système . Il n'est pas difficile de voir que ce sont des candidats de choix pour se moquer : que vous écriviez un script pour éjecter un lecteur de CD, un serveur Web qui supprime les fichiers de cache obsolètes de /tmp , ou un serveur de socket qui se lie à un port TCP, ces appels présentent tous des effets secondaires indésirables dans le cadre de vos tests unitaires.

En tant que développeur, vous vous souciez plus que votre bibliothèque appelle avec succès la fonction système pour éjecter un CD plutôt que de voir votre plateau de CD ouvert à chaque fois qu'un test est exécuté.

En tant que développeur, vous vous souciez plus que votre bibliothèque appelle avec succès la fonction système pour éjecter un CD (avec les arguments corrects, etc.) plutôt que de voir votre plateau de CD ouvert à chaque fois qu'un test est exécuté. (Ou pire, plusieurs fois, car plusieurs tests référencent le code d'éjection au cours d'une seule exécution de test unitaire !)

De même, garder vos tests unitaires efficaces et performants signifie garder autant de "code lent" hors des tests automatisés, à savoir le système de fichiers et l'accès au réseau.

Pour notre premier exemple, nous allons refactoriser un cas de test Python standard de la forme originale à une autre utilisant mock . Nous démontrerons comment l'écriture d'un cas de test avec des simulations rendra nos tests plus intelligents, plus rapides et capables d'en révéler davantage sur le fonctionnement du logiciel.

Une fonction de suppression simple

Nous avons tous besoin de supprimer des fichiers de notre système de fichiers de temps en temps, alors écrivons une fonction en Python qui facilitera un peu la tâche de nos scripts.

 #!/usr/bin/env python # -*- coding: utf-8 -*- import os def rm(filename): os.remove(filename)

De toute évidence, notre méthode rm à ce stade ne fournit pas beaucoup plus que la méthode os.remove sous-jacente, mais notre base de code s'améliorera, nous permettant d'ajouter plus de fonctionnalités ici.

Écrivons un cas de test traditionnel, c'est-à-dire sans mocks :

 #!/usr/bin/env python # -*- coding: utf-8 -*- from mymodule import rm import os.path import tempfile import unittest class RmTestCase(unittest.TestCase): tmpfilepath = os.path.join(tempfile.gettempdir(), "tmp-testfile") def setUp(self): with open(self.tmpfilepath, "wb") as f: f.write("Delete me!") def test_rm(self): # remove the file rm(self.tmpfilepath) # test that it was actually removed self.assertFalse(os.path.isfile(self.tmpfilepath), "Failed to remove the file.")

Notre cas de test est assez simple, mais chaque fois qu'il est exécuté, un fichier temporaire est créé puis supprimé. De plus, nous n'avons aucun moyen de tester si notre méthode rm transmet correctement l'argument à l'appel os.remove . Nous pouvons supposer que c'est le cas sur la base du test ci-dessus, mais il reste beaucoup à désirer.

Refactoring avec Python Mocks

Refactorisons notre cas de test en utilisant mock :

 #!/usr/bin/env python # -*- coding: utf-8 -*- from mymodule import rm import mock import unittest class RmTestCase(unittest.TestCase): @mock.patch('mymodule.os') def test_rm(self, mock_os): rm("any path") # test that rm called os.remove with the right parameters mock_os.remove.assert_called_with("any path")

Avec ces refactors, nous avons fondamentalement changé le fonctionnement du test. Maintenant, nous avons un insider , un objet que nous pouvons utiliser pour vérifier la fonctionnalité d'un autre.

Pièges potentiels de la moquerie Python

L'une des premières choses qui devrait ressortir est que nous utilisons le décorateur de méthode mock.patch pour simuler un objet situé dans mymodule.os , et injecter cette simulation dans notre méthode de cas de test. Ne serait-il pas plus logique de se moquer de lui-même, plutôt que de la référence mymodule.os os

Eh bien, Python est un peu un serpent sournois en ce qui concerne les importations et la gestion des modules. Lors de l'exécution, le module mymodule a son propre système d' os qui est importé dans sa propre portée locale dans le module. Ainsi, si nous nous moquons de os , nous ne verrons pas les effets du mock dans le module mymodule .

Le mantra à répéter est celui-ci :

Se moquer d'un objet là où il est utilisé, pas d'où il vient.

Si vous avez besoin de simuler le module tempfile pour myproject.app.MyElaborateClass , vous devrez probablement appliquer le simulacre à myproject.app.tempfile , car chaque module conserve ses propres importations.

Avec cet écueil écarté, continuons à nous moquer.

Ajout de la validation à 'rm'

La méthode rm définie précédemment est assez simplifiée. Nous aimerions qu'il valide qu'un chemin existe et qu'il s'agit d'un fichier avant de tenter aveuglément de le supprimer. Refactorisons rm pour être un peu plus intelligent :

 #!/usr/bin/env python # -*- coding: utf-8 -*- import os import os.path def rm(filename): if os.path.isfile(filename): os.remove(filename)

Génial. Maintenant, ajustons notre cas de test pour maintenir la couverture.

 #!/usr/bin/env python # -*- coding: utf-8 -*- from mymodule import rm import mock import unittest class RmTestCase(unittest.TestCase): @mock.patch('mymodule.os.path') @mock.patch('mymodule.os') def test_rm(self, mock_os, mock_path): # set up the mock mock_path.isfile.return_value = False rm("any path") # test that the remove call was NOT called. self.assertFalse(mock_os.remove.called, "Failed to not remove the file if not present.") # make the file 'exist' mock_path.isfile.return_value = True rm("any path") mock_os.remove.assert_called_with("any path")

Notre paradigme de test a complètement changé. Nous pouvons désormais vérifier et valider la fonctionnalité interne des méthodes sans aucun effet secondaire.

Suppression de fichiers en tant que service avec Mock Patch

Jusqu'à présent, nous n'avons travaillé qu'avec des simulations pour les fonctions, mais pas pour les méthodes sur les objets ou les cas où la simulation est nécessaire pour envoyer des paramètres. Couvrons d'abord les méthodes d'objet.

Nous commencerons par une refactorisation de la méthode rm dans une classe de service. Il n'y a vraiment pas de besoin justifiable, en soi, d'encapsuler une fonction aussi simple dans un objet, mais cela nous aidera au moins à démontrer les concepts clés de mock . Refactorisons :

 #!/usr/bin/env python # -*- coding: utf-8 -*- import os import os.path class RemovalService(object): """A service for removing objects from the filesystem.""" def rm(filename): if os.path.isfile(filename): os.remove(filename)

Vous remarquerez que peu de choses ont changé dans notre cas de test :

 #!/usr/bin/env python # -*- coding: utf-8 -*- from mymodule import RemovalService import mock import unittest class RemovalServiceTestCase(unittest.TestCase): @mock.patch('mymodule.os.path') @mock.patch('mymodule.os') def test_rm(self, mock_os, mock_path): # instantiate our service reference = RemovalService() # set up the mock mock_path.isfile.return_value = False reference.rm("any path") # test that the remove call was NOT called. self.assertFalse(mock_os.remove.called, "Failed to not remove the file if not present.") # make the file 'exist' mock_path.isfile.return_value = True reference.rm("any path") mock_os.remove.assert_called_with("any path")

Parfait, nous savons maintenant que le RemovalService fonctionne comme prévu. Créons un autre service qui le déclare comme dépendance :

 #!/usr/bin/env python # -*- coding: utf-8 -*- import os import os.path class RemovalService(object): """A service for removing objects from the filesystem.""" def rm(self, filename): if os.path.isfile(filename): os.remove(filename) class UploadService(object): def __init__(self, removal_service): self.removal_service = removal_service def upload_complete(self, filename): self.removal_service.rm(filename)

Comme nous avons déjà une couverture de test sur RemovalService , nous n'allons pas valider la fonctionnalité interne de la méthode rm dans nos tests de UploadService . Au lieu de cela, nous allons simplement tester (sans effets secondaires, bien sûr) que UploadService appelle la méthode RemovalService.rm , dont nous savons qu'elle « fonctionne » grâce à notre cas de test précédent.

Il y a deux façons d'aborder cela:

  1. Mock out la méthode RemovalService.rm elle-même.
  2. Fournissez une instance simulée dans le constructeur de UploadService .

Comme les deux méthodes sont souvent importantes dans les tests unitaires, nous les examinerons toutes les deux.

Option 1 : Méthodes d'instance simulées

La bibliothèque mock a un décorateur de méthode spécial pour se moquer des méthodes et des propriétés d'instance d'objet, le décorateur @mock.patch.object :

 #!/usr/bin/env python # -*- coding: utf-8 -*- from mymodule import RemovalService, UploadService import mock import unittest class RemovalServiceTestCase(unittest.TestCase): @mock.patch('mymodule.os.path') @mock.patch('mymodule.os') def test_rm(self, mock_os, mock_path): # instantiate our service reference = RemovalService() # set up the mock mock_path.isfile.return_value = False reference.rm("any path") # test that the remove call was NOT called. self.assertFalse(mock_os.remove.called, "Failed to not remove the file if not present.") # make the file 'exist' mock_path.isfile.return_value = True reference.rm("any path") mock_os.remove.assert_called_with("any path") class UploadServiceTestCase(unittest.TestCase): @mock.patch.object(RemovalService, 'rm') def test_upload_complete(self, mock_rm): # build our dependencies removal_service = RemovalService() reference = UploadService(removal_service) # call upload_complete, which should, in turn, call `rm`: reference.upload_complete("my uploaded file") # check that it called the rm method of any RemovalService mock_rm.assert_called_with("my uploaded file") # check that it called the rm method of _our_ removal_service removal_service.rm.assert_called_with("my uploaded file")

Génial! Nous avons validé que UploadService appelle avec succès la méthode rm de notre instance. Remarquez quelque chose d'intéressant là-dedans? Le mécanisme de correction a en fait remplacé la méthode rm de toutes les instances RemovalService dans notre méthode de test. Cela signifie que nous pouvons réellement inspecter les instances elles-mêmes. Si vous voulez en voir plus, essayez d'insérer un point d'arrêt dans votre code moqueur pour avoir une bonne idée du fonctionnement du mécanisme de correction.

Mock Patch Pitfall : Ordre du décorateur

Lorsque vous utilisez plusieurs décorateurs sur vos méthodes de test, l'ordre est important et c'est un peu déroutant. Fondamentalement, lors du mappage des décorateurs aux paramètres de méthode, travaillez à l'envers. Considérez cet exemple :

 @mock.patch('mymodule.sys') @mock.patch('mymodule.os') @mock.patch('mymodule.os.path') def test_something(self, mock_os_path, mock_os, mock_sys): pass

Remarquez comment nos paramètres correspondent à l'ordre inverse des décorateurs ? C'est en partie à cause de la façon dont Python fonctionne. Avec plusieurs décorateurs de méthodes, voici l'ordre d'exécution en pseudocode :

 patch_sys(patch_os(patch_os_path(test_something)))

Étant donné que le correctif vers sys est le correctif le plus externe, il sera exécuté en dernier, ce qui en fait le dernier paramètre dans les arguments de méthode de test réels. Prenez bien note de cela et utilisez un débogueur lors de l'exécution de vos tests pour vous assurer que les bons paramètres sont injectés dans le bon ordre.

Option 2 : Créer des instances fictives

Au lieu de se moquer de la méthode d'instance spécifique, nous pourrions simplement fournir une instance simulée à UploadService avec son constructeur. Je préfère l'option 1 ci-dessus, car elle est beaucoup plus précise, mais il existe de nombreux cas où l'option 2 pourrait être efficace ou nécessaire. Refaisons à nouveau notre test :

 #!/usr/bin/env python # -*- coding: utf-8 -*- from mymodule import RemovalService, UploadService import mock import unittest class RemovalServiceTestCase(unittest.TestCase): @mock.patch('mymodule.os.path') @mock.patch('mymodule.os') def test_rm(self, mock_os, mock_path): # instantiate our service reference = RemovalService() # set up the mock mock_path.isfile.return_value = False reference.rm("any path") # test that the remove call was NOT called. self.assertFalse(mock_os.remove.called, "Failed to not remove the file if not present.") # make the file 'exist' mock_path.isfile.return_value = True reference.rm("any path") mock_os.remove.assert_called_with("any path") class UploadServiceTestCase(unittest.TestCase): def test_upload_complete(self, mock_rm): # build our dependencies mock_removal_service = mock.create_autospec(RemovalService) reference = UploadService(mock_removal_service) # call upload_complete, which should, in turn, call `rm`: reference.upload_complete("my uploaded file") # test that it called the rm method mock_removal_service.rm.assert_called_with("my uploaded file")

Dans cet exemple, nous n'avons même pas eu à corriger de fonctionnalité, nous créons simplement une spécification automatique pour la classe RemovalService , puis injectons cette instance dans notre UploadService pour valider la fonctionnalité.

La méthode mock.create_autospec crée une instance fonctionnellement équivalente à la classe fournie. Ce que cela signifie, en pratique, c'est que lorsque l'instance renvoyée est en interaction, elle lèvera des exceptions si elle est utilisée de manière illégale. Plus précisément, si une méthode est appelée avec le mauvais nombre d'arguments, une exception sera levée. Ceci est extrêmement important car les refactorisations se produisent. Au fur et à mesure qu'une bibliothèque change, les tests se cassent et c'est normal. Sans utiliser de spécification automatique, nos tests réussiront même si l'implémentation sous-jacente est cassée.

Piège : Les mock.Mock et mock.MagicMock

La bibliothèque mock comprend également deux classes importantes sur lesquelles reposent la plupart des fonctionnalités internes : mock.Mock et mock.MagicMock . Lorsque vous avez le choix d'utiliser une instance mock.Mock , une instance mock.MagicMock ou une spécification automatique, privilégiez toujours l'utilisation d'une spécification automatique, car cela permet de garder vos tests sains pour les modifications futures. En effet, mock.Mock et mock.MagicMock acceptent tous les appels de méthode et les affectations de propriété, quelle que soit l'API sous-jacente. Considérez le cas d'utilisation suivant :

 class Target(object): def apply(value): return value def method(target, value): return target.apply(value)

Nous pouvons tester cela avec une instance mock.Mock comme celle-ci :

 class MethodTestCase(unittest.TestCase): def test_method(self): target = mock.Mock() method(target, "value") target.apply.assert_called_with("value")

Cette logique semble sensée, mais modifions la méthode Target.apply pour prendre plus de paramètres :

 class Target(object): def apply(value, are_you_sure): if are_you_sure: return value else: return None

Réexécutez votre test et vous constaterez qu'il réussit toujours. C'est parce qu'il n'est pas construit sur votre API actuelle. C'est pourquoi vous devez toujours utiliser la méthode create_autospec et le paramètre autospec avec les @patch et @patch.object .

Python Mock Example : se moquer d'un appel d'API Facebook

Pour finir, écrivons un exemple simulé de python réel plus applicable, celui que nous avons mentionné dans l'introduction : publier un message sur Facebook. Nous allons écrire une belle classe wrapper et un cas de test correspondant.

 import facebook class SimpleFacebook(object): def __init__(self, oauth_token): self.graph = facebook.GraphAPI(oauth_token) def post_message(self, message): """Posts a message to the Facebook wall.""" self.graph.put_object("me", "feed", message=message)

Voici notre cas de test, qui vérifie que nous publions le message sans réellement publier le message :

 import facebook import simple_facebook import mock import unittest class SimpleFacebookTestCase(unittest.TestCase): @mock.patch.object(facebook.GraphAPI, 'put_object', autospec=True) def test_post_message(self, mock_put_object): sf = simple_facebook.SimpleFacebook("fake oauth token") sf.post_message("Hello World!") # verify mock_put_object.assert_called_with(message="Hello World!")

Comme nous l'avons vu jusqu'à présent, il est très simple de commencer à écrire des tests plus intelligents avec mock en Python.

Conclusion

La bibliothèque mock de Python, si elle est un peu déroutante à utiliser, change la donne pour les tests unitaires. Nous avons démontré des cas d'utilisation courants pour commencer à utiliser mock dans les tests unitaires, et nous espérons que cet article aidera les développeurs Python à surmonter les obstacles initiaux et à écrire un excellent code testé.