Python: 是否可以包装“@patch(path)”以供重复使用? (单元测试)

Python: is it possible to wrap "@patch(path)" for re-use? (unittest)

正如文档 "Where to patch" 所说,我们需要修补查找对象的位置(而不是定义对象的位置);所以我知道不可能 - 比方说 - 为特定路径创建可重复使用的补丁

假设您有多个模块导入您想要模拟的对象

# file_a.py
from foo.goo.hoo import settings
# file_b.py
from foo.goo.hoo import settings
# file_c.py
from foo.goo.hoo import settings

我想知道是否有办法创建装饰器,例如:

@mock_settings
def test_whatever(self, settings_mock):
    ...

而不是这个解决方案:

@patch("some_module.file_a.settings")
def test_whatever(self, settings_mock):
    ...
@patch("some_module.file_b.settings")
def test_whatever(self, settings_mock):
    ...
@patch("some_module.file_c.settings")
def test_whatever(self, settings_mock):
    ...

如问题中所述,要修补一个对象,您必须修补其在要测试的模块中的引用(如果它是使用 from ...import 导入的)。
要在多个模块中修补它,您可以使用相同的 mock 修补所有这些模块,并使用该 mock。如果您事先知道要修补哪些模块,则可以这样做。如果您事先知道它们,您必须尝试在所有加载的模块中修补该对象——这可能会有点复杂。

我将展示一个使用 pytest 和 pytest fixture 的示例,因为它更紧凑;您可以将其包装在装饰器中以便在 unittest 中使用,但这不会改变基础知识。假设我们有一个 class 需要在多个模块中模拟:

class_to_mock.py

class ClassToMock:
    def foo(self, msg):
        return msg

module1.py

from class_to_mock import ClassToMock

def do_something():
    inst = ClassToMock()
    return inst.foo("module1")

module2.py

from class_to_mock import ClassToMock

def do_something_else():
    inst = ClassToMock()
    return inst.foo("module2")

您现在可以编写一个 fixture 来同时模拟所有这些模块中的 class(为简单起见,此处使用 pytest-mock):

@pytest.fixture
def mocked_class(mocker):
    mocked = Mock()
    for module in ('module1', 'module2'):
        mocker.patch(module + '.ClassToMock', mocked)
    yield mocked

这可用于测试两个模块:

def test_module1(mocked_class):
    mocked_class.return_value.foo.return_value = 'mocked!'
    assert module1.do_something() == 'mocked!'

def test_module2(mocked_class):
    mocked_class.return_value.foo.return_value = 'mocked!'
    assert module2.do_something_else() == 'mocked!'

如果你想要一个模拟所有加载模块中的class的通用版本,你可以用这样的东西替换夹具:

@pytest.fixture
def mocked_class(mocker):
    mocked = Mock()
    for name, module in list(sys.modules.items()):
        if not inspect.ismodule(module):
            continue
        for cls_name, cls in module.__dict__.items():
            try:  # need that as inspect may raise for some modules
                if inspect.isclass(cls) and cls_name == "ClassToMock":
                    mocker.patch(name + ".ClassToMock", mocked)
            except Exception:
                continue
    yield mocked

这将适用于这个特定的例子——为了概括这一点,它必须考虑更多的对象类型,class 应该是可配置的,并且可能会有更多的问题——与更简单的版本相反您只需枚举要修补的模块,这将始终有效。

你可以在 unittest.setUp 中做类似的事情,方法是将模拟放在一个实例变量中,尽管这样不太优雅,因为你还负责停止模拟:

class ModulesTest(unittest.TestCase):
    def setUp(self):
        self.mocked_class = Mock()
        self.mocks = []
        for module in ('module1', 'module2'):
            mocked = mock.patch(module + '.ClassToMock', self.mocked_class)
            self.mocks.append(mocked)
            mocked.start()

    def tearDown(self):
        for mocked in self.mocks:
            mocked.stop()

    def test_module1(self):
        self.mocked_class.return_value.foo.return_value = 'mocked!'
        assert module1.do_something() == 'mocked!'

你也可以用装饰器包装它,至少部分地回答你原来的问题:

def mocked_class_to_mock(f):
    @wraps(f)
    def _mocked_class_to_mock(*args, **kwargs):
        mocked_class = Mock()
        mocks = []
        for module in ('module1', 'module2'):
            mocked = mock.patch(module + '.ClassToMock', mocked_class)
            mocks.append(mocked)
            mocked.start()
        kwargs['mocked_class'] = mocked_class  # use a keyword arg for simplicity
        f(*args, **kwargs)
        for mocked in mocks:
            mocked.stop()

    return _mocked_class_to_mock

...
    @mocked_class_to_mock
    def test_module3(self, mocked_class):
        mocked_class.return_value.foo.return_value = 'mocked!'
        assert module3.do_something() == 'mocked!'

当然,如果需要,您可以对更通用的版本执行相同的操作。

另请注意,我跳过了使用 import ... 导入对象的更简单的情况。在这种情况下,您必须修补原始模块。在通用夹具中,您可能希望始终添加这种情况。