Puis-je patcher un décorateur Python avant d'envelopper une fonction?
J'ai une fonction avec un décorateur que j'essaie de tester à l'aide de la bibliothèque Python Mock. J'aimerais utiliser la maquette.patch pour remplacer le vrai décorateur par un décorateur "bypass" qui appelle simplement la fonction. Ce que je ne peux pas comprendre, c'est comment appliquer le patch avant que le vrai décorateur enveloppe la fonction. J'ai essayé quelques variations différentes sur la cible de patch et réorganiser les instructions de patch et d'importation, mais sans succès. Des idées?
7 réponses
Les décorateurs sont appliqués au moment de la définition de la fonction. Pour la plupart des fonctions, c'est lorsque le module est chargé. (Les fonctions qui sont définies dans d'autres fonctions ont le décorateur appliqué chaque fois que la fonction englobante est appelée.)
Donc, si vous voulez singe-patch un décorateur, ce que vous devez faire est:
- importez le module qui le contient
- définir la fonction de décorateur fictif
- définir par exemple
module.decorator = mymockdecorator
- importez le(S) module (s) qui utilise le décorateur, ou utilisez-le dans votre propre module
Si le module qui contient le décorateur contient également des fonctions qui l'utilisent, celles-ci sont déjà décorées au moment où vous pouvez les voir, et vous êtes probablement S. O. L.
Modifier pour refléter les modifications apportées à Python depuis que j'ai écrit ceci à l'origine: si le décorateur utilise functools.wraps()
et que la version de Python est assez nouvelle, vous pourrez peut-être extraire la fonction d'origine en utilisant l'attritube __wrapped__
et la décorer, mais ce n'est en aucun cas garanti, et le décorateur que vous souhaitez remplacer peut également ne pas être le seul décorateur appliqué.
Il convient de noter que plusieurs des réponses ici vont patcher le décorateur pour toute la session de test plutôt qu'une seule instance de test; ce qui peut être indésirable. Voici comment patcher un décorateur qui ne persiste que par un seul test.
Notre unité à tester avec le décorateur indésirable:
# app/uut.py
from app.decorators import func_decor
@func_decor
def unit_to_be_tested():
# Do stuff
pass
Du module décorateurs:
# app/decorators.py
def func_decor(func):
def inner(*args, **kwargs):
print "Do stuff we don't want in our test"
return func(*args, **kwargs)
return inner
Au moment où notre test est collecté lors d'un test, le décorateur indésirable a déjà été appliqué à notre unité testée (parce que cela se produit au moment de l'importation). Afin de se débarrasser de cela, nous devrons remplacer manuellement le décorateur dans le module du décorateur, puis réimporter le module contenant notre UUT.
Notre module de test:
# test_uut.py
from unittest import TestCase
from app import uut # Module with our thing to test
from app import decorators # Module with the decorator we need to replace
import imp # Library to help us reload our UUT module
from mock import patch
class TestUUT(TestCase):
def setUp(self):
# Do cleanup first so it is ready if an exception is raised
def kill_patches(): # Create a cleanup callback that undoes our patches
patch.stopall() # Stops all patches started with start()
imp.reload(uut) # Reload our UUT module which restores the original decorator
self.addCleanup(kill_patches) # We want to make sure this is run so we do this in addCleanup instead of tearDown
# Now patch the decorator where the decorator is being imported from
patch('app.decorators.func_decor', lambda x: x).start() # The lambda makes our decorator into a pass-thru. Also, don't forget to call start()
# HINT: if you're patching a decor with params use something like:
# lambda *x, **y: lambda f: f
imp.reload(uut) # Reloads the uut.py module which applies our patched decorator
Le rappel de nettoyage, kill_patches, restaure le décorateur d'origine et l'applique de nouveau à l'unité que nous testions. De cette façon, notre patch ne persiste que via un seul test plutôt que toute la session-ce qui est exactement la façon dont tout autre patch devrait se comporter. En outre, depuis le nettoyage appelle patch.stopall (), nous pouvons commencer tous les autres correctifs dans la configuration() dont nous avons besoin et ils seront nettoyés en un seul endroit.
La chose importante à comprendre à propos de cette méthode est de savoir comment le rechargement affectera les choses. Si un module prend trop de temps ou a une logique qui s'exécute à l'importation, vous devrez peut-être hausser les épaules et tester le décorateur dans le cadre de l'unité. : (Espérons que votre code est mieux écrit que cela. Droit?
Si on ne se soucie pas si le patch est appliqué à toute la session de test , le moyen le plus simple de le faire est en haut du fichier de test:
# test_uut.py
from mock import patch
patch('app.decorators.func_decor', lambda x: x).start() # MUST BE BEFORE THE UUT GETS IMPORTED ANYWHERE!
from app import uut
Assurez-vous de patcher le fichier avec le décorateur plutôt que la portée locale de L'UUT et de commencer le patch avant d'importer l'unité avec le décorateur.
Fait intéressant, même si le patch est arrêté, tous les fichiers déjà importés auront toujours le patch appliqué au Décorateur, ce qui est l'inverse de la situation avec laquelle nous avons commencé. Soyez conscient que cela méthode va Patcher tous les autres fichiers dans l'exécution du test qui sont importés par la suite-même s'ils ne déclarent pas un patch eux-mêmes.
Quand j'ai rencontré ce problème pour la première fois, j'utilise mon cerveau pendant des heures. J'ai trouvé un moyen beaucoup plus facile de gérer cela.
Cela contournera complètement le décorateur, comme si la cible n'était même pas décorée en premier lieu.
Ceci est divisé en deux parties. Je suggère la lecture de l'article suivant.
Http://alexmarandon.com/articles/python_mock_gotchas/
Deux pièges que je n'arrêtais pas de rencontrer:
1.) Mock le décorateur avant l'importation de votre fonction/module.
Les décorateurs et les fonctions sont définis au moment du chargement du module. Si vous ne vous moquez pas avant l'importation, cela ne tiendra pas compte de la simulation. Après le chargement, vous devez faire une simulation étrange.patch.objet, ce qui devient encore plus frustrant.
2.) Assurez-vous que vous vous moquez du chemin correct vers le décorateur.
Rappelez-vous que le patch du décorateur que vous moquez est basé sur la façon dont votre module charge le décorateur, pas sur la façon dont votre test charge le décorateur. Ce c'est pourquoi je suggère toujours d'utiliser des chemins complets pour les importations. Cela rend les choses beaucoup plus faciles pour les tests.
Étapes:
1.) La fonction simulée:
from functools import wraps
def mock_decorator(*args, **kwargs):
def decorator(f):
@wraps(f)
def decorated_function(*args, **kwargs):
return f(*args, **kwargs)
return decorated_function
return decorator
2.) Se moquer du décorateur:
2a.) Chemin à l'intérieur avec.
with mock.patch('path.to.my.decorator', mock_decorator):
from mymodule import myfunction
2b.) Patch en haut du fichier, ou dans TestCase.configuration
mock.patch('path.to.my.decorator', mock_decorator).start()
L'une ou l'autre de ces méthodes vous permettra d'importer votre fonction à tout moment dans le TestCase ou ses cas de méthode/test.
from mymodule import myfunction
2.) Utiliser une fonction distincte comme un effet secondaire de maquette.patch.
Maintenant, vous pouvez utiliser mock_decorator pour chaque décorateur que vous voulez moquer. Vous devrez vous moquer de chaque décorateur séparément, alors faites attention à ceux que vous manquez.
Peut-être que vous pouvez appliquer un autre décorateur sur les définitions de tous vos décorateurs qui vérifie essentiellement une variable de configuration pour voir si le mode de test est destiné à être utilisé.
Si oui, il remplace le décorateur qu'il décore par un décorateur factice qui ne fait rien.
Sinon, il laisse passer ce décorateur.
Ce qui suit a fonctionné pour moi:
- supprime l'instruction import qui charge la cible de test.
- patcher le décorateur au démarrage du test comme appliqué ci-dessus.
- appelez importlib.import_module () immédiatement après le correctif pour charger la cible de test.
- Exécutez les tests normalement.
Ça a marché comme un charme.
Concept
Cela peut sembler un peu étrange mais on peut Patcher sys.path
, avec une copie de lui-même, et effectuer une importation dans le cadre de la fonction de test. Le code suivant montre le concept.
from unittest.mock import patch
import sys
@patch('sys.modules', sys.modules.copy())
def testImport():
oldkeys = set(sys.modules.keys())
import MODULE
newkeys = set(sys.modules.keys())
print((newkeys)-(oldkeys))
oldkeys = set(sys.modules.keys())
testImport() -> ("MODULE") # Set contains MODULE
newkeys = set(sys.modules.keys())
print((newkeys)-(oldkeys)) -> set() # An empty set
MODULE
peut alors être remplacé par le module de test. (Cela fonctionne en Python 3.6 avec MODULE
substitué par xml
par exemple)
OP
Pour votre cas, disons que la fonction décoratrice réside dans le module pretty
et que la fonction décorée réside dans present
, vous corrigeriez pretty.decorator
en utilisant la machine fictive et remplaceriez MODULE
par present
. Quelque chose comme ce qui suit devrait fonctionner (non testé).
Classe TestDecorator (unittest.Cas de test) : ...
@patch(`pretty.decorator`, decorator)
@patch(`sys.path`, sys.path.copy())
def testFunction(self, decorator) :
import present
...
Explication
Cela fonctionne en fournissant un sys.path
" propre " pour chaque fonction de test, en utilisant une copie du courant sys.path
du module de test. Cette copie est faite lorsque le module est d'abord analysé en assurant un sys.path
cohérent pour tous les test.
Nuances
Il y a cependant quelques implications. Si le framework de test exécute plusieurs modules de test sous la même session python, tout module de test qui importe MODULE
globalement casse tout module de test qui l'importe localement. Cela oblige à effectuer l'importation localement partout. Si le framework exécute chaque module de test sous une session Python distincte, cela devrait fonctionner. De même, vous ne pouvez pas importer MODULE
globalement dans un module de test où vous importez MODULE
localement.
Les importations locales doivent être effectuées pour chaque fonction de test dans une sous-classe de unittest.TestCase
. Il est peut-être possible de l'appliquer à la sous-classe unittest.TestCase
en rendant directement disponible une importation particulière du module pour toutes les fonctions de test de la classe.
Intégré
Ceux qui jouent avec les importations builtin
trouveront le remplacement de MODULE
par sys
, os
etc. échouera, car ceux-ci sont déjà lus sur sys.path
lorsque vous essayez de le copier. L'astuce ici est d'invoquer Python avec les importations intégrées désactivées, je pense que python -X test.py
le fera mais j'oublie le drapeau approprié(Voir python --help
). Ceux-ci peuvent ensuite être importés localement en utilisant import builtins
, IIRC.
Pour @lru_cache (max_size=1000)
class MockedLruCache(object):
def __init__(self, maxsize=0, timeout=0): pass def __call__(self, func): return func
Cache.LruCache = MockedLruCache
Si vous utilisez un décorateur qui n'a pas params, vous devriez:
def MockAuthenticated(func):
return func
From tornado import web
web.authenticated = MockAuthenticated