我可以在包装函数之前修补 Python 装饰器吗?

Posted

技术标签:

【中文标题】我可以在包装函数之前修补 Python 装饰器吗?【英文标题】:Can I patch a Python decorator before it wraps a function? 【发布时间】:2011-12-01 19:50:00 【问题描述】:

我有一个带有装饰器的函数,我正在尝试在 Python Mock 库的帮助下进行测试。我想使用mock.patch 将真正的装饰器替换为只调用函数的模拟“绕过”装饰器。

我想不通的是如何在真正的装饰器包装函数之前应用补丁。我在补丁目标上尝试了一些不同的变体,并对补丁和导入语句重新排序,但没有成功。有什么想法吗?

【问题讨论】:

【参考方案1】:

需要注意的是,这里的几个答案会为整个测试会话而不是单个测试实例修补装饰器;这可能是不可取的。以下是如何修补仅通过单个测试持续存在的装饰器。

我们的单元要使用不受欢迎的装饰器进行测试:

# app/uut.py

from app.decorators import func_decor

@func_decor
def unit_to_be_tested():
    # Do stuff
    pass

来自装饰器模块:

# 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

在测试运行期间收集到我们的测试时,不需要的装饰器已经应用于我们的测试单元(因为这发生在导入时)。为了摆脱这种情况,我们需要手动替换装饰器模块中的装饰器,然后重新导入包含我们的 UUT 的模块。

我们的测试模块:

#  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

清理回调 kill_patches 恢复原始装饰器并将其重新应用于我们正在测试的单元。这样,我们的补丁只在一个测试而不是整个会话中持续存在——这正是任何其他补丁应该表现的方式。此外,由于清理调用了 patch.stopall(),我们可以在 setUp() 中启动我们需要的任何其他补丁,它们将在一个地方全部清理。

了解此方法的重要一点是重新加载将如何影响事物。如果一个模块花费的时间太长或具有在导入时运行的逻辑,您可能只需要耸耸肩并将装饰器作为单元的一部分进行测试。 :( 希望你的代码比那写得更好。对吧?

如果不关心补丁是否应用于整个测试会话,最简单的方法就是在测试文件的顶部:

# 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

确保使用装饰器而不是 UUT 的本地范围修补文件,并在使用装饰器导入单元之前启动修补程序。

有趣的是,即使补丁停止,所有已经导入的文件仍然会将补丁应用到装饰器,这与我们开始的情况相反。请注意,此方法将修补测试运行中随后导入的任何其他文件——即使它们自己没有声明修补程序。

【讨论】:

user2859458,这对我有很大帮助。接受的答案很好,但这以一种有意义的方式为我说明了事情,并且包括了多个用例,您可能想要一些稍微不同的东西。 感谢您的回复!如果这对其他人有用,我做了一个补丁扩展,它仍然可以作为上下文管理器并为你重新加载:gist.github.com/Geekfish/aa43368ceade131b8ed9c822d2163373 这解决了我的问题,谢谢!! lambda *x, **y: lambda f: f【参考方案2】:

装饰器在函数定义时应用。对于大多数功能,这是加载模块的时间。 (在其他函数中定义的函数每次调用封闭函数时都会应用装饰器。)

所以如果你想给装饰器打猴子补丁,你需要做的是:

    导入包含它的模块 定义模拟装饰器函数 设置例如 module.decorator = mymockdecorator 导入使用装饰器的模块,或在您自己的模块中使用它

如果包含装饰器的模块还包含使用它的函数,那么当你看到它们时,它们已经被装饰了,你可能是 S.O.L.

编辑以反映自从我最初编写此内容以来对 Python 的更改:如果装饰器使用 functools.wraps() 并且 Python 的版本足够新,您也许可以使用 __wrapped__ 属性挖掘出原始函数并重新装饰它,但这绝不是保证,您要替换的装饰器也可能不是唯一应用的装饰器。

【讨论】:

以下内容浪费了我不少时间:请记住 Python 只导入一次模块。如果您正在运行一套测试,试图在您的一个测试中模拟一个装饰器,并且装饰的函数被导入到其他地方,那么模拟装饰器将没有任何效果。 使用内置的reload函数来重新生成python二进制代码docs.python.org/2/library/functions.html#reload和monkeypatch你的装饰器 遇到了@Paragon 报告的问题,并通过在测试目录的__init__ 中修补我的装饰器来解决它。这确保了在任何测试文件之前加载补丁。我们有一个独立的测试文件夹,因此该策略适用于我们,但这可能不适用于每个文件夹布局。 看了几遍,还是一头雾水。这需要一个代码示例! 使用 wrapped 属性是我的最佳选择,谢谢!我用while hasattr(target_func,'__wrapped__'): target_func=target_func.__wrapped__挖出核心功能,测试一下。【参考方案3】:

当我第一次遇到这个问题时,我常常绞尽脑汁好几个小时。我找到了一种更简单的方法来处理这个问题。

这将完全绕过装饰器,就像一开始甚至没有装饰目标一样。

这分为两部分。我建议阅读以下文章。

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

我一直遇到的两个陷阱:

1.) 在导入函数/模块之前模拟装饰器。

装饰器和函数是在模块加载时定义的。 如果您在导入前不模拟,它将忽略模拟。加载后,还要做一个奇怪的mock.patch.object,这就更让人郁闷了。

2.) 确保您正在模拟到装饰器的正确路径。

请记住,您正在模拟的装饰器补丁是基于您的模块如何加载装饰器,而不是您的测试如何加载装饰器。这就是为什么我建议始终使用完整路径进行导入。这使得测试变得更加容易。

步骤:

1.) Mock 函数:

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.) 模拟装饰器:

2a.) 里面的路径。

with mock.patch('path.to.my.decorator', mock_decorator):
     from mymodule import myfunction

2b.) 文件顶部或 TestCase.setUp 中的补丁

mock.patch('path.to.my.decorator', mock_decorator).start()

这两种方法中的任何一种都可以让您随时在 TestCase 或其方法/测试用例中导入您的函数。

from mymodule import myfunction

2.) 使用单独的函数作为 mock.patch 的副作用。

现在您可以对要模拟的每个装饰器使用 mock_decorator。您将不得不分别模拟每个装饰器,因此请注意您错过的那些。

【讨论】:

你引用的博文帮助我更好地理解了这一点!【参考方案4】:

以下内容对我有用:

    消除加载测试目标的导入语句。 如上所述在测试启动时修补装饰器。 修补后立即调用 importlib.import_module() 以加载测试目标。 正常运行测试。

它就像一个魅力。

【讨论】:

【参考方案5】:

要修补装饰器,您需要在修补后导入或重新加载使用该装饰器的模块,或者重新定义模块对该装饰器的引用。 p>

装饰器在导入模块时应用。这就是为什么如果你导入一个使用了你想在文件顶部打补丁的装饰器的模块,并在以后尝试打补丁而不重新加载它,补丁将不起作用。

这里是提到的第一种方法的示例 - 在修补它使用的装饰器后重新加载模块:

import moduleA
...

  # 1. patch the decorator
  @patch('decoratorWhichIsUsedInModuleA', examplePatchValue)
  def setUp(self)
    # 2. reload the module which uses the decorator
    reload(moduleA)

  def testFunctionA(self):
    # 3. tests...
    assert(moduleA.functionA()...

有用的参考资料:

Python 3 documentation for imp.reload Python 2.7 documentation for reload http://alexmarandon.com/articles/python_mock_gotchas/#patching-decorators

【讨论】:

【参考方案6】:

我们试图模拟一个装饰器,它有时会获取另一个参数,如字符串,有时不会,例如:

@myDecorator('my-str')
def function()

OR

@myDecorator
def function()

感谢上面的一个答案,我们编写了一个模拟函数并用这个模拟函数修补装饰器:

from mock import patch

def mock_decorator(f):

    def decorated_function(g):
        return g

    if callable(f): # if no other parameter, just return the decorated function
        return decorated_function(f)
    return decorated_function # if there is a parametr (eg. string), ignore it and return the decorated function

patch('path.to.myDecorator', mock_decorator).start()

from mymodule import myfunction

请注意,此示例适用于不运行装饰函数的装饰器,仅在实际运行之前执行一些操作。 如果装饰器也运行被装饰的函数,因此它需要传递函数的参数,那么 mock_decorator 函数必须有点不同。

希望这对其他人有所帮助...

【讨论】:

【参考方案7】:

我似乎对此有所了解。重要的是,测试应该始终保持找到的东西......但这里只有一个其他答案似乎解决了这一点:如果你用模拟或假货代替真正的装饰器,你必须恢复那个真正的装饰器测试后。

在模块 thread_check.py 中,我有一个名为 thread_check 的装饰器(这是 PyQt5 上下文)检查是否在“右线程”(即 Gui 或非 Gui)中调用了函数或方法。它看起来像这样:

def thread_check(gui_thread: bool):
    def pseudo_decorator(func):
        if not callable(func):
            raise Exception(f'func is type type(func)')
        def inner_function(*args, **kwargs):
            if QtWidgets.QApplication.instance() != None: 
                app_thread = QtWidgets.QApplication.instance().thread()
                curr_thread = QtCore.QThread.currentThread()
                if gui_thread != None:
                    if (curr_thread == app_thread) != gui_thread:
                        raise Exception(f'method func.__qualname__ should have been called in "GUI thread" if gui_thread else "non-GUI thread"')
            return func(*args, **kwargs)
        return inner_function
    return pseudo_decorator

在实践中,就我而言,在大多数情况下,对于所有测试,在每次运行开始时使用“无操作装饰器”完全修补这个装饰器会更有意义。但是为了说明如何在每个测试的基础上完成它,请参见下文。

提出的问题是is_thread_interrupt_reqAbstractLongRunningTask 的方法(实际上它不是抽象的:您可以实例化它)必须在非Gui 线程中运行。所以方法看起来是这样的:

@thread_check(False) # i.e. non-Gui thread
def is_thread_interrupt_req(self):
    return self.thread.isInterruptionRequested()

这就是我解决修补 thread_check 装饰器问题的方法,以清理“模块空间”以恢复真正的装饰器以进行下一次测试:

@pytest.fixture    
def restore_tm_classes():
    yield
    importlib.reload(task_manager_classes)

@pytest.mark.parametrize('is_ir_result', [True, False]) # return value from QThread.isInterruptionRequested()
@mock.patch('PyQt5.QtCore.QThread.isInterruptionRequested')    
def test_ALRT_is_thread_interrupt_req_returns_val_of_thread_isInterruptionRequested(mock_is_ir, request, qtbot, is_ir_result, restore_tm_classes):
    print(f'\n>>>>>> test: request.node.nodeid')
    print(f'thread_check.thread_check thread_check.thread_check')

    def do_nothing_decorator(gui_thread):
        def pseudo_decorator(func):
            return func
        return pseudo_decorator
    
    with mock.patch('thread_check.thread_check', side_effect=do_nothing_decorator):
        importlib.reload(task_manager_classes)
    with mock.patch('PyQt5.QtCore.QThread.start'): # NB the constructor calls QThread.start(): must be mocked!
        tm = task_manager_classes.TaskManager(task_manager_classes.AbstractLongRunningTask)
    mock_is_ir.return_value = is_ir_result
    assert tm.task.is_thread_interrupt_req() == is_ir_result
    
    
def test_another(request): 
    print(f'\n>>>>>> test: request.node.nodeid')
    print(f'thread_check.thread_check thread_check.thread_check')

...在test_another 中,我们打印出以下内容:

thread_check.thread_check <function thread_check at 0x000002234BEABE50>

...与test_ALRT... 测试开始时打印的对象相同。

这里的关键是在你的补丁中使用side_effect 结合importlib.reload 来重新加载你的模块,它本身将使用装饰器。

注意这里的上下文管理器缩进:thread_check.thread_check 上的补丁只需要应用到reload... 在调用实际方法(is_thread_interrupt_req)时,假装饰器就位。

如果你使用这个拆解装置restore_tm_classes,这里会发生一些非常奇怪的事情:事实上,在下一个测试方法中,然后(根据我的实验)看起来装饰器既不是真实的也不是do_nothing_decorator,正如我通过在两者中放入print 语句确定的那样。因此,如果您不通过重新加载调整后的模块来恢复,那么在测试套件期间,task_manager_classes 模块中的应用程序代码似乎会留下一个“僵尸装饰器”(似乎什么都不做)。

警告 在测试运行过程中使用importlib.reload 时会出现很大的潜在问题。

特别是它可以证明应用程序代码正在使用具有特定 id 值(即id(MyClass))的类 X,但测试代码(在此模块和随后运行的模块中)据称使用的是相同的类 X,但具有另一个id值!有时这可能无关紧要,有时它会导致一些相当莫名其妙的失败测试,​​这可能可以解决,但可能需要您解决

    宁愿避免 mock.patching 在测试中实际未创建的对象:例如,当类本身时(我在这里不是在考虑 类的对象,而是 类作为变量本身)在任何测试之外导入或创建,因此在测试收集阶段创建:在这种情况下,类对象将和重装后的不一样。

    甚至可以在以前没有此功能的各种模块中的一些固定装置中使用importlib.reload(...)

始终使用pytest-random-order(多次运行)来揭示此类(和其他)问题的全部范围。

正如我所说,装饰器可以在运行开始时简单地修补。因此是否值得这样做是另一回事。事实上,我已经实现了相反的情况:thread_check 装饰器在运行开始时被修补,但随后 又被修补,使用上面的 @987654347 @ 技术,用于需要装饰器运行的一两个测试。

【讨论】:

【参考方案8】:

也许您可以将另一个装饰器应用到所有装饰器的定义上,这些装饰器基本上会检查一些配置变量以查看是否要使用测试模式。 如果是,它会用一个什么都不做的虚拟装饰器替换它正在装饰的装饰器。 否则,它会让这个装饰器通过。

【讨论】:

【参考方案9】:

概念

这听起来可能有点奇怪,但可以修补sys.path,使用它自己的副本,并在测试函数的范围内执行导入。下面的代码展示了这个概念。

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 然后可以替换为您正在测试的模块。 (这适用于 Python 3.6,例如 MODULE 替换为 xml

操作

对于您的情况,假设装饰器函数位于模块 pretty 中,而装饰器函数位于 present 中,那么您将使用模拟机制修补 pretty.decorator 并将 MODULE 替换为 present。像下面这样的东西应该可以工作(未经测试)。

类 TestDecorator(unittest.TestCase) : ...

  @patch(`pretty.decorator`, decorator)
  @patch(`sys.path`, sys.path.copy())
  def testFunction(self, decorator) :
   import present
   ...

说明

这通过使用测试模块的当前sys.path 的副本为每个测试功能提供一个“干净的”sys.path 来工作。首次解析模块时会生成此副本,以确保所有测试的sys.path 一致。

细微差别

但是,有一些含义。如果测试框架在同一个 python 会话下运行多个测试模块,则任何全局导入 MODULE 的测试模块都会破坏任何在本地导入它的测试模块。这迫使人们在任何地方都在本地执行导入。如果框架在单独的 python 会话下运行每个测试模块,那么这应该可以工作。同样,您不能在本地导入MODULE 的测试模块中全局导入MODULE

必须为unittest.TestCase 的子类中的每个测试函数执行本地导入。或许可以将其直接应用于unittest.TestCase 子类,从而使该模块的特定导入可用于类中的所有测试函数。

内置插件

那些搞乱builtin 导入的人会发现用sysos 等替换MODULE 会失败,因为当您尝试复制它时,这些已经在sys.path 上。这里的技巧是在禁用内置导入的情况下调用 Python,我认为 python -X test.py 会这样做,但我忘记了适当的标志(参见 python --help)。这些可以随后使用import builtins, IIRC 在本地导入。

【讨论】:

【参考方案10】:

那些搞乱内置导入的人会发现用 sys、os 等替换 MODULE 会失败,因为当你尝试复制它时,这些已经在 sys.path 上。这里的技巧是在禁用内置导入的情况下调用 Python,我认为 python -X test.py 会这样做,但我忘记了适当的标志(参见 python --help)。随后可以使用 import builtins IIRC 在本地导入这些。

【讨论】:

【参考方案11】:

我喜欢让一个技巧更简单、更容易理解。利用装饰器的功能并创建旁路。

模拟函数:

from functools import wraps

def the_call(*args, **kwargs):
    def decorator(function):
        @wraps(function)
        def wrapper(*args, **kwargs):
            if kwargs.pop("bypass", None) is True:
                return function(*args, **kwargs)
            # You will probably do something that will change the response or the arguments here below
            args = ("bar")
            kwargs = "stuff": "bar"
            return function(*args, **kwargs)
        return wrapper
    return decorator

装饰器的功能:

@the_call()
def my_simple_function(stuff: str):
    return stuff


print(my_simple_function(stuff="Hello World"))

将返回:

“酒吧”

所以在你的测试中,只需传递参数 bypass = True

print(my_simple_function(stuff="Hello World", bypass=True))

将返回:

“你好世界”

【讨论】:

测试和测试不得影响生产代码。在生产代码中必须只有需要的代码。这不是一个好的设计。必须改为使用模拟。【参考方案12】:

对于@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

如果使用没有参数的装饰器,你应该:

def MockAuthenticated(func): return func

from tornado import web web.authenticated = MockAuthenticated

【讨论】:

我在这个答案中看到了很多问题。第一个(也是更大的一个)是如果它被装饰了,你就不能访问原始函数(这是 OP 问题)。此外,您不会在测试完成后删除补丁,这可能会在您在测试套件中运行时导致问题。

以上是关于我可以在包装函数之前修补 Python 装饰器吗?的主要内容,如果未能解决你的问题,请参考以下文章

Python - 任何人都有一个可以处理不可散列参数的记忆装饰器吗?

如何修补由 Python 数据形状中的装饰器注册的方法?

我可以为任何可以放入 Extern C 的 C++ 向量制作一个 C 包装器吗

python包装函数在装饰器中接受参数

Python 装饰器强制显式传递参数

Python 联合 - 在另一个装饰器中收集多个 @patch 装饰器