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

126 投票
12 回答
80988 浏览
提问于 2025-04-17 03:45

我有一个带装饰器的函数,想用Python的Mock库来测试它。我想用mock.patch来替换掉真实的装饰器,换成一个假的“绕过”装饰器,这个装饰器只是直接调用函数。

我搞不明白的是,怎么在真实的装饰器包裹函数之前应用这个替换。我试过几种不同的替换目标和调整替换和导入的顺序,但都没有成功。有没有什么好的建议?

12 个回答

25

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

这个方法可以完全绕过装饰器,就好像目标根本没有被装饰过一样。

这个过程分为两个部分。我建议你阅读以下文章。

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

我遇到的两个常见问题:

1.) 在导入你的函数或模块之前,先对装饰器进行模拟。

装饰器和函数是在模块加载时定义的。如果你不在导入之前进行模拟,它会忽略这个模拟。加载后,你就得用一种奇怪的方式来模拟,这会让人更加沮丧。

2.) 确保你模拟的是装饰器的正确路径。

记住,你模拟的装饰器的路径是基于你的模块如何加载装饰器,而不是你的测试如何加载装饰器。这就是为什么我建议总是使用完整路径进行导入,这样测试会简单很多。

步骤:

1.) 模拟函数:

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。你需要单独模拟每个装饰器,所以要注意不要漏掉。

92

需要注意的是,这里有几个回答会把装饰器的修改应用到整个测试会话,而不是单个测试实例;这可能不是我们想要的。下面是如何只在单个测试中应用装饰器修改的方法。

我们要测试的单元,带有不想要的装饰器:

# 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

在我们的测试被收集到测试运行中时,不想要的装饰器已经应用到我们要测试的单元上了(因为这在导入时就发生了)。为了去掉这个装饰器,我们需要手动替换装饰器所在模块中的装饰器,然后重新导入包含我们要测试单元的模块。

我们的测试模块:

#  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
from importlib import reload # 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()
            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
        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

确保修改的是带有装饰器的文件,而不是我们要测试单元的局部范围,并且在导入带有装饰器的单元之前就开始修改。

有趣的是,即使修改被停止,所有已经导入的文件仍然会对装饰器应用这个修改,这和我们开始时的情况正好相反。要注意,这种方法会对测试运行中之后导入的任何其他文件进行修改——即使它们自己没有声明要进行修改。

67

装饰器是在定义函数的时候就被应用的。对于大多数函数来说,这个时间点就是模块被加载的时候。(如果函数是在其他函数里面定义的,那么每次调用外层函数时,装饰器都会被应用一次。)

所以,如果你想要修改一个装饰器,你需要做的步骤是:

  1. 导入包含这个装饰器的模块
  2. 定义一个假的装饰器函数
  3. 设置,比如说 module.decorator = mymockdecorator
  4. 导入使用这个装饰器的模块,或者在你自己的模块中使用它

如果包含装饰器的模块里也有使用这个装饰器的函数,那么在你看到这些函数的时候,它们已经被装饰过了,你可能就没办法再修改了。

补充一下,自从我最开始写这个内容以来,Python有了一些变化:如果装饰器使用了 functools.wraps(),而且你的Python版本足够新,你可能可以通过 __wrapped__ 属性找到原始函数并重新装饰它,但这并不是一定能成功的,而且你想替换的装饰器可能也不是唯一被应用的装饰器。

撰写回答