我可以在Python装饰器包裹函数之前修改它吗?
我有一个带装饰器的函数,想用Python的Mock库来测试它。我想用mock.patch
来替换掉真实的装饰器,换成一个假的“绕过”装饰器,这个装饰器只是直接调用函数。
我搞不明白的是,怎么在真实的装饰器包裹函数之前应用这个替换。我试过几种不同的替换目标和调整替换和导入的顺序,但都没有成功。有没有什么好的建议?
12 个回答
当我第一次遇到这个问题时,我曾经绞尽脑汁好几个小时。后来我发现了一种更简单的方法来处理这个问题。
这个方法可以完全绕过装饰器,就好像目标根本没有被装饰过一样。
这个过程分为两个部分。我建议你阅读以下文章。
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。你需要单独模拟每个装饰器,所以要注意不要漏掉。
需要注意的是,这里有几个回答会把装饰器的修改应用到整个测试会话,而不是单个测试实例;这可能不是我们想要的。下面是如何只在单个测试中应用装饰器修改的方法。
我们要测试的单元,带有不想要的装饰器:
# 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
确保修改的是带有装饰器的文件,而不是我们要测试单元的局部范围,并且在导入带有装饰器的单元之前就开始修改。
有趣的是,即使修改被停止,所有已经导入的文件仍然会对装饰器应用这个修改,这和我们开始时的情况正好相反。要注意,这种方法会对测试运行中之后导入的任何其他文件进行修改——即使它们自己没有声明要进行修改。
装饰器是在定义函数的时候就被应用的。对于大多数函数来说,这个时间点就是模块被加载的时候。(如果函数是在其他函数里面定义的,那么每次调用外层函数时,装饰器都会被应用一次。)
所以,如果你想要修改一个装饰器,你需要做的步骤是:
- 导入包含这个装饰器的模块
- 定义一个假的装饰器函数
- 设置,比如说
module.decorator = mymockdecorator
- 导入使用这个装饰器的模块,或者在你自己的模块中使用它
如果包含装饰器的模块里也有使用这个装饰器的函数,那么在你看到这些函数的时候,它们已经被装饰过了,你可能就没办法再修改了。
补充一下,自从我最开始写这个内容以来,Python有了一些变化:如果装饰器使用了 functools.wraps()
,而且你的Python版本足够新,你可能可以通过 __wrapped__
属性找到原始函数并重新装饰它,但这并不是一定能成功的,而且你想替换的装饰器可能也不是唯一被应用的装饰器。