web-dev-qa-db-fra.com

Puis-je patcher un décorateur Python avant qu'il enveloppe une fonction?

J'ai une fonction avec un décorateur que j'essaye de tester avec l'aide de la bibliothèque Python Mock . Je voudrais utiliser mock.patch pour remplacer le véritable décorateur par un décorateur simulé "Bypass" qui appelle simplement la fonction. Ce que je n'arrive pas à comprendre, c'est comment appliquer le patch avant que le véritable décorateur n'englobe la fonction. J'ai essayé différentes variantes de la cible de correctif et réorganisé les instructions de correctif et d'importation, mais sans succès. Des idées?

53
Chris Sears

Les décorateurs sont appliqués au moment de la définition de la fonction. Pour la plupart des fonctions, il s’agit du chargement du module. (Les fonctions définies dans d'autres fonctions ont le décorateur appliqué chaque fois que la fonction englobante est appelée.)

Donc, si vous voulez patcher un décorateur, ce que vous devez faire est:

  1. Importer le module qui le contient
  2. Définir la fonction de décorateur factice
  3. Set par exemple module.decorator = mymockdecorator
  4. Importez le ou les modules qui utilisent le décorateur ou utilisez-le dans votre propre module

Si le module contenant le décorateur contient également des fonctions qui l'utilisent, celles-ci sont déjà décorées au moment où vous les voyez et vous êtes probablement S.O.L. 

Modifiez pour refléter les modifications apportées à Python depuis que j’écrivais initialement ceci: Si le décorateur utilise functools.wraps() et que la version de Python est suffisamment nouvelle, vous pourrez peut-être extraire la fonction d’origine à l’aide du __wrapped__ attritube et la re-décorer, mais aucun moyen n'est garanti, et le décorateur que vous souhaitez remplacer peut également ne pas être le seul décorateur appliqué.

42
kindall

Il convient de noter que plusieurs des réponses proposées ici appliqueront des correctifs au décorateur pour la totalité de la session de test plutôt que pour 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é au cours d'un test, le décorateur indésirable a déjà été appliqué à notre unité testée (car cela se produit au moment de l'importation). Afin de s'en débarrasser, nous devrons remplacer manuellement le décorateur dans son module, puis réimporter le module contenant notre unité d'utilisation.

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 le réapplique à l'unité testée. De cette façon, notre correctif ne persiste que pendant un seul test plutôt que pendant toute la session - ce qui est exactement le comportement de tout autre correctif. De plus, depuis le nettoyage des appels à patch.stopall (), nous pouvons démarrer tous les correctifs de setUp () dont nous avons besoin et ils seront nettoyés au même endroit.

La chose importante à comprendre à propos de cette méthode est comment le rechargement affectera les choses. Si un module prend trop de temps ou si sa logique est exécutée à l'importation, il vous suffira peut-être de hausser les épaules et de tester le décorateur dans le cadre de l'unité. :( J'espère que votre code est mieux écrit que ça. Bien?

Si on ne se soucie pas de savoir si le correctif est appliqué à l'ensemble de la session de test , le moyen le plus simple de le faire est de le placer tout 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 qu'avec le périmètre local de l'UUT et de démarrer le patch avant d'importer l'unité avec le décorateur. 

Fait intéressant, même si le correctif est arrêté, tous les fichiers déjà importés auront toujours le correctif appliqué au décorateur, ce qui est l'inverse de la situation avec laquelle nous avons commencé. Sachez que cette méthode corrige tous les autres fichiers du cycle de test importés ultérieurement, même s'ils ne déclarent pas eux-mêmes un correctif.

31
user2859458

Quand je suis tombé sur ce problème pour la première fois, je me creusais la tête pendant des heures. J'ai trouvé un moyen beaucoup plus facile de gérer cela.

Cela va complètement contourner le décorateur, comme la cible n'était même pas décorée en premier lieu.

Ceci est divisé en deux parties. Je suggère de lire l'article suivant.

http://alexmarandon.com/articles/python_mock_gotchas/

Deux pièges que je rencontrais sans cesse:

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, le mock sera ignoré. Après le chargement, vous devez faire un étrange mock.patch.object, qui devient encore plus frustrant.

2.) Assurez-vous que vous vous moquez du chemin correct vers le décorateur.

N'oubliez pas que le patch du décorateur dont vous vous moquez dépend de la façon dont votre module charge le décorateur, et non de la façon dont votre test charge le décorateur. C'est pourquoi je suggère de toujours utiliser des chemins complets pour les importations. Cela rend les choses beaucoup plus faciles pour les tests.

Pas:

1.) La fonction factice:

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.setUp

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 TestCase ou dans ses méthodes/tests.

from mymodule import myfunction

2.) Utilisez une fonction distincte en tant qu'effet secondaire du mock.patch.

Vous pouvez maintenant utiliser mock_decorator pour chaque décorateur que vous voulez simuler. Vous devrez vous moquer de chaque décorateur séparément, alors faites attention à ceux qui vous manquent.

3
user7815681

Ce qui suit a fonctionné pour moi:

  1. Éliminez l'instruction d'importation qui charge la cible de test.
  2. Appliquez une correction au démarrage du test tel qu’appliqué ci-dessus.
  3. Appelez importlib.import_module () immédiatement après le correctif pour charger la cible de test.
  4. Exécutez les tests normalement.

Ça a marché comme sur des roulettes.

1
Eric Mintz

Peut-être pouvez-vous appliquer un autre décorateur aux définitions de tous vos décorateurs qui vérifie fondamentalement une variable de configuration pour voir si le mode test doit être utilisé.
Si oui, il remplace le décorateur, il est décoré avec un décorateur factice qui ne fait rien.
.__ Sinon, cela laisse passer ce décorateur.

0
Aditya Mukherji

Concept

Cela peut sembler un peu étrange, mais vous pouvez corriger sys.path, avec une copie de lui-même, et effectuer une importation dans les limites 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 ensuite être remplacé par le module que vous testez. (Cela fonctionne dans Python 3.6 avec MODULE remplacé par xml par exemple)

OP

Dans votre cas, supposons que la fonction décorateur réside dans le module pretty et que la fonction décorée réside dans present, puis vous corrigeriez pretty.decorator en utilisant le mécanisme fictif et remplaceriez MODULE par present. Quelque chose comme ce qui suit devrait fonctionner (non testé).

classe TestDecorator (unittest.TestCase): ...

  @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 sys.path actuel du module de test. Cette copie est faite lors de la première analyse du module, garantissant un sys.path cohérent pour tous les tests.

Nuances

Il y a cependant quelques implications. Si la structure de test exécute plusieurs modules de test sous la même session python, tout module de test qui importe MODULE rompt globalement tout module de test qui l'importe localement. Cela oblige à effectuer l'importation localement partout. Si la structure 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 une importation particulière du module disponible pour toutes les fonctions de test de la classe.

Built Ins

Ceux qui jouent avec les importations builtin trouveront que le remplacement de MODULE par sys, os etc. échouera, car ils sont déjà lus sur sys.path lorsque vous essayez de le copier. Le truc ici est d’appeler 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 à l'aide de import builtins, IIRC.

0
Carel