Mocking en Python 101 : faites semblant avant de vous lancer
10 février 2018
0 minutes de lectureNote de la rédaction
Cet article est paru à l’origine sur fugue.co. Fugue a rejoint Snyk en 2022 et constitue un élément clé de Snyk IaC.
FugueBienvenue dans ce guide consacré aux bases du mocking en Python. Il est né de mon besoin de tester du code faisant appel à de nombreux services réseau et de mon expérience avec GoMock, qui m’a montré à quel point le mocking peut être puissant lorsqu’il est bien utilisé (merci, Tyler). Je commencerai par une réflexion philosophique sur le mocking, car bien mocker demande un état d’esprit différent de celui nécessaire pour bien développer. Le développement consiste à créer des choses, tandis que le mocking consiste à en simuler. Cela peut sembler évident, mais la dimension « simulation » des tests de mocking est fondamentale, et bien la comprendre change complètement notre regard sur les tests. Nous examinerons ensuite les outils de mocking fournis par Python, puis nous terminerons par un exemple complet. Découvrez notre antisèche pour en savoir plus sur les tests de code en sécurité Python.
Le mocking peut être difficile à appréhender. Lorsque je teste du code que j’ai écrit, je veux vérifier de bout en bout qu’il fait bien ce qu’il est censé faire. Je commence généralement par envisager un test fonctionnel et intégré, dans lequel je fournis des données réalistes et obtiens des résultats réalistes. J’accède à tous les systèmes réels utilisés par mon code pour m’assurer que les interactions entre eux fonctionnent correctement, en utilisant de vrais objets et de vrais appels d’API. Ces tests sont indispensables pour vérifier le bon fonctionnement conjoint de systèmes complexes, mais ce n’est pas ce que nous attendons des tests unitaires.
Les tests unitaires portent sur la couche la plus externe du code. Les tests d’intégration sont nécessaires, mais les tests unitaires automatisés que nous exécutons ne doivent pas aller aussi loin dans les interactions entre systèmes. Cela signifie que les appels d’API dans la fonction testée peuvent et doivent être mockés. Nous devons remplacer tout appel d’API ou toute création d’objet non trivial par un appel ou un objet mocké. Nous évitons ainsi de mobiliser inutilement des ressources, simplifions la préparation des tests et réduisons leur durée d’exécution. Prenons le cas d’une fonction qui accède à une API HTTP externe. Au lieu de vérifier qu’un serveur de test est disponible pour envoyer les bonnes réponses, nous pouvons mocker la bibliothèque HTTP et remplacer tous les appels HTTP par des appels mockés. Cela réduit la complexité et les dépendances des tests, tout en nous donnant un contrôle précis sur les réponses de la bibliothèque HTTP, ce qui peut être difficile à obtenir autrement.
Qu’entend-on par mocking ?
Le terme « mocking » est souvent employé, mais dans cet article, nous lui donnons la définition suivante :
« Le remplacement d’un ou de plusieurs appels de fonction ou objets par des appels ou des objets mockés »
Un appel de fonction mocké renvoie immédiatement une valeur prédéfinie, sans effectuer aucun travail. De la même manière, les attributs et méthodes d’un objet mocké sont entièrement définis dans le test, sans créer l’objet réel ni effectuer de travail. Le fait que la personne qui écrit le test puisse définir les valeurs de retour de chaque appel de fonction lui confère un pouvoir considérable lors des tests, mais suppose également un travail préparatoire pour tout mettre en place correctement.
En Python, le mocking s’effectue à l’aide du module unittest.mock. Celui-ci contient plusieurs classes et fonctions utiles, notamment la fonction patch (en tant que décorateur ou gestionnaire de contexte) et la classe MagicMock. En Python, le mocking repose en grande partie sur ces deux composants puissants.
Qu’est-ce que le mocking ne désigne PAS ?
Les développeurs utilisent de nombreux objets ou modules « mock », qui sont des substituts locaux entièrement fonctionnels aux services réseau et aux API. Par exemple, la bibliothèque moto est une bibliothèque boto mockée qui intercepte tous les appels à l’API boto et les traite localement. Ces mocks permettent aux développeurs de tester des API externes en local, mais nécessitent tout de même la création d’objets réels. Ce n’est pas le type de mocking abordé dans cet article. Nous nous intéressons spécifiquement à l’utilisation d’objets MagicMock pour contrôler entièrement le flux d’exécution de la fonction testée, ce qui facilite le test des échecs et de la gestion des exceptions.
Comment effectuer un mocking en Python ?
En Python, le mocking consiste à utiliser patch pour intercepter un appel à une fonction d’API ou une création d’objet. Lorsque patch intercepte un appel, il renvoie par défaut un objet MagicMock. En définissant des propriétés sur l’objet MagicMock, vous pouvez faire en sorte que l’appel d’API renvoie la valeur de votre choix ou déclenche une Exception.
Voici la procédure générale :
Écrivez le test comme si vous utilisiez de vraies API externes.
Dans la fonction testée, déterminez les appels d’API à mocker ; il devrait y en avoir peu.
Dans la fonction de test, utilisez patch pour intercepter les appels d’API.
Configurez les réponses de l’objet
MagicMock.Exécutez votre test.
Si le test réussit, vous avez terminé. Sinon, il peut y avoir une erreur dans la fonction testée ou la réponse de votre MagicMock est peut-être mal configurée. Nous allons maintenant examiner plus en détail les outils permettant de créer et de configurer des mocks.
patch
patch peut être utilisé comme décorateur de la fonction de test, en lui passant en argument une chaîne indiquant le nom de la fonction à patcher. Pour que patch trouve la fonction à patcher, vous devez préciser son nom complet, qui peut différer de ce à quoi vous vous attendez. Si une classe est importée à l’aide d’une instruction from module import ClassA, ClassA fait alors partie de l’espace de noms du module dans lequel elle a été importée.
Par exemple, si une classe est importée dans le module my_module.py comme suit :
Il faut la patcher avec @patch(my_module.ClassA), et non avec @patch(module.ClassA), en raison de la sémantique de l’instruction from ... import ..., qui importe les classes et les fonctions dans l’espace de noms actuel.
En général, patch sert à patcher un appel à une API externe ou tout autre appel de fonction ou création d’objet gourmand en temps ou en ressources. Vous ne devriez patcher que quelques objets appelables par test. Si vous vous retrouvez à utiliser patch plus d’une poignée de fois, envisagez de remanier votre test ou la fonction testée.
L’utilisation du décorateur patch transmet automatiquement un argument positionnel à la fonction décorée (c’est-à-dire votre fonction de test). Lorsque plusieurs fonctions sont patchées, le décorateur le plus proche de la fonction décorée est appelé en premier et crée donc le premier argument positionnel.
Par défaut, ces arguments sont des instances de MagicMock, l’objet de mocking par défaut de unittest.mock. Vous pouvez définir le comportement de la fonction patchée en définissant des attributs sur l’instance MagicMock renvoyée.
MagicMock
Les objets MagicMock offrent une interface de mocking simple qui vous permet de définir la valeur de retour ou un autre comportement de l’appel de fonction ou de création d’objet que vous avez patché. Vous pouvez ainsi définir entièrement le comportement de l’appel et éviter de créer des objets réels, ce qui peut être fastidieux. Par exemple, si nous patchons un appel à requests.get, une fonction de bibliothèque HTTP, nous pouvons définir la réponse qui sera renvoyée lors de l’appel d’API dans la fonction testée, au lieu de devoir vérifier qu’un serveur de test est disponible pour renvoyer la réponse voulue.
Les deux attributs les plus importants d’une instance MagicMock sont return_value et side_effect, qui nous permettent tous deux de définir le comportement de retour de l’appel patché.
return_value
L’attribut return_value de l’instance MagicMock transmise à votre fonction de test vous permet de choisir ce que renvoie l’objet appelable patché. Dans la plupart des cas, vous voudrez renvoyer une version mockée de ce que l’objet appelable renverrait normalement. Il peut s’agir de JSON, d’un itérable, d’une valeur, d’une instance de l’objet de réponse réel, d’un MagicMock qui simule l’objet de réponse, ou presque de n’importe quoi d’autre. Lors du patch d’objets, l’appel patché est l’appel de création de l’objet ; le return_value du MagicMock doit donc être un objet mocké, qui peut être un autre MagicMock.
Si le code que vous testez est idiomatique en Python et utilise le duck typing plutôt que le typage explicite, utiliser un MagicMock comme objet de réponse peut être pratique. Plutôt que de créer une véritable instance de classe, vous pouvez définir des paires clé-valeur d’attributs arbitraires dans le constructeur de MagicMock ; elles seront automatiquement appliquées à l’instance.
Notez que l’argument transmis à test_some_func, c’est-à-dire mock_api_call, est un MagicMock et que nous définissons son return_value sur un autre MagicMock. Avec le mocking, tout est un MagicMock.
Spécifier un MagicMock
La flexibilité de MagicMock est pratique pour mocker rapidement des classes aux exigences complexes, mais elle peut aussi présenter un inconvénient. Par défaut, les objets MagicMock se comportent comme s’ils possédaient tous les attributs, même ceux qu’ils ne devraient pas avoir. Dans l’exemple ci-dessus, nous renvoyons un objet MagicMock plutôt qu’un objet Response. Supposons toutefois que nous ayons fait une erreur dans l’appel à patch et patché une fonction censée renvoyer un objet Request au lieu d’un objet Response. Le MagicMock renvoyé se comportera tout de même comme s’il possédait tous les attributs de l’objet Request, alors que nous voulions simuler un objet Response. Cela peut entraîner des erreurs de test déroutantes et des comportements de test incorrects.
Pour résoudre ce problème, vous pouvez spécifier le MagicMock à l’aide de spec lors de sa création, en utilisant l’argument nommé spec : MagicMock(spec=Response). Vous obtenez ainsi un MagicMock qui n’autorise l’accès qu’aux attributs et méthodes de la classe indiquée dans le MagicMock. Toute tentative d’accès à un attribut absent de l’objet d’origine déclenchera une AttributeError, comme ce serait le cas avec l’objet réel.
Voici un exemple simple :
side_effect
Vous voudrez parfois vérifier que votre fonction gère correctement une exception ou que plusieurs appels à la fonction patchée sont traités comme prévu. Vous pouvez le faire avec side_effect. Si vous définissez side_effect sur une exception, celle-ci est immédiatement déclenchée lorsque la fonction patchée est appelée.
Si vous définissez side_effect sur un itérable, l’élément suivant de l’itérable est renvoyé chaque fois que la fonction patchée est appelée. Si vous définissez side_effect sur toute autre valeur, cette valeur sera renvoyée.
assert_called_with
assert_called_with vérifie que la fonction patchée a été appelée avec les arguments spécifiés dans l’appel à assert_called_with.
Un exemple complet
Dans cet exemple, je teste une fonction retry sur Client.update. Les appels d’API dans update seront donc effectués deux fois, ce qui est une excellente occasion d’utiliser MagicMock.side_effect.
Le code complet de l’exemple se trouve ici :
Je patche deux appels dans la fonction testée (pyvars.vars_client.VarsClient.update) : l’un à VarsClient.get et l’autre à requests.post. Comme je patche deux appels, ma fonction de test reçoit deux arguments, que j’ai nommés mock_post et mock_get. Ce sont tous deux des objets MagicMock. Dans leur état par défaut, ils ne font pas grand-chose. Nous devons leur attribuer des comportements de réponse.
Ce test vérifie que le mécanisme de nouvelle tentative finit par fonctionner. Je vais donc appeler update plusieurs fois, ainsi que VarsClient.get et requests.post.
Je configure ici les side_effect souhaités. Je veux que tous les appels à VarsClient.get fonctionnent (renvoyer un VarsResponse vide convient pour ce test), que le premier appel à requests.post échoue en déclenchant une exception et que le deuxième appel à requests.post réussisse. Seul le mocking permet un contrôle aussi précis du comportement.
Une fois les side_effect configurés, le reste du test est simple. Le comportement attendu est le suivant : le premier appel à requests.post échoue. Le mécanisme de nouvelle tentative qui enveloppe VarsClient.update doit donc intercepter l’erreur, et tout doit fonctionner au deuxième essai. Vous pouvez vérifier davantage ce comportement en examinant l’historique des appels de mock_get et mock_post.
Conclusion
Bien utiliser les objets mock va à l’encontre de notre intuition, qui nous pousse à rendre les tests aussi réalistes et exhaustifs que possible. Pourtant, cela nous permet d’écrire des tests autonomes, rapides et sans dépendances. Nous pouvons ainsi tester la gestion des exceptions et les cas limites qu’il serait autrement impossible de tester. Surtout, nous sommes libres de concentrer nos efforts sur les fonctionnalités de notre code plutôt que sur la configuration d’un environnement de test. En nous concentrant sur les éléments importants, nous pouvons améliorer la couverture des tests et la fiabilité de notre code, ce qui est bien la raison pour laquelle nous effectuons des tests.
Liens vers la documentation
https://docs.python.org/3/library/unittest.mock.html
Une sécurité IaC pensée pour les développeurs
Snyk sécurise votre infrastructure en tant que code, du cycle de développement logiciel à l’exécution dans le cloud, grâce à un moteur unifié de politiques sous forme de code. Chaque équipe peut ainsi développer, déployer et exploiter ses applications en toute sécurité.



