Python单元测试模拟,获取被模拟函数的输入参数

78

我希望有一个单元测试来断言函数内变量action被设置为预期值,这个变量仅在被传递给库的调用中使用。

Class Monolith(object):
    def foo(self, raw_event):
        action =  # ... Parse Event
        # Middle of function
        lib.event.Event(METADATA, action)
        # Continue on to use the build event.

我之前的想法是,我可以模拟lib.event.Event,并获取其输入参数并断言它们的特定值。

这不是模拟的工作方式吗?模拟文档让我感到沮丧,因为它不一致,只有一半的示例与我想要做的事情相关,而其他示例又太多了。


1
你在哪里使用了 mock?你可以对 lib.event.Event 进行模拟并进行断言。 - vks
回想起来,现在这种补丁和模拟方法似乎比当时更自然/默认。 - ThorSummoner
4个回答

87
你可以使用call_args 或者 call_args_list

一个简单的例子:

import mock
import unittest

class TestExample(unittest.TestCase):

    @mock.patch('lib.event.Event')
    def test_example1(self, event_mocked):
        args, kwargs = event_mocked.call_args
        args = event_mocked.call_args.args  # alternatively 
        self.assertEqual(args, ['metadata_example', 'action_example'])
我只是快速地为可能需要这个示例的人编写了它 - 我实际上没有测试过,因此可能会有一些小错误。

3
对我来说,我必须执行 self.assertEqual(event_mocked.call_args[0], <expected_args>)event_mocked.call_args.args 对我无效。也许是版本问题? - Josmoor98
@Josmoor98 如果这是一个版本问题,如果您能分享您的Python版本,那么可能会帮助到其他人。 - akki
确实,正如评论中指出的那样,Python版本为“Python 3.6.12 - [PyPy 7.3.3 with GCC 9.3.0]”。 - Josmoor98

21

你可以使用 patch 装饰器,然后像下面这样调用 assert_called_with 函数来对该模拟对象进行检查:

如果你有这个结构:

example.py
tests.py
lib/__init__.py
lib/event.py

example.py 的内容是:

import lib

METADATA = 'metadata_example'

class Monolith(object):

    def foo(self, raw_event):
        action =  'action_example' # ... Parse Event
        # Middle of function
        lib.event.Event(METADATA, action)
        # Continue on to use the build event.

lib/event.py 的内容为:

class Event(object):

    def __init__(self, metadata, action):
        pass

tests.py的代码应该像这样:

import mock
import unittest

from lib.event import Event
from example import Monolith


class TestExample(unittest.TestCase):

    @mock.patch('lib.event.Event')
    def test_example1(self, event_mocked):
        # Setup
        m = Monolith()

        # Exercise
        m.foo('raw_event')

        # Verify
        event_mocked.assert_called_with('metadata_example', 'action_example')

6
如果我想将传递给 Mock 的参数作为字典获取,该怎么办? - dopatraman
19
你可以使用call_args或者call_args_list - Craig Anderson
@CraigAnderson,你能把那个发表为答案吗? - Stevoisiak
@StevenVascellaro 这不是一个真正的回答原问题,是吗?我提出了一个编辑来说明它们可能如何使用。 - Craig Anderson

10
如果你想直接访问参数,可以这样做。虽然有点冗余... 请参考https://docs.python.org/3.6/library/unittest.mock.html#unittest.mock.call.call_list
import mock
import unittest

from lib.event import Event
from example import Monolith


class TestExample(unittest.TestCase):

    @mock.patch('lib.event.Event')
    def test_example1(self, event_mocked):
        # Setup
        m = Monolith()

        # Exercise
        m.foo('raw_event')

        # Verify
        name, args, kwargs = m.mock_calls[0]
        self.assertEquals(name, "foo")
        self.assertEquals(args, ['metadata_example', 'action_example'])
        self.assertEquals(kwargs, {})

1
上面的答案很有帮助,但我想要一种简单的方法来编写单元测试,当被测试的代码改变了模拟函数调用的方式时,不需要重构测试代码,而且功能上也没有任何改变。
例如,如果我选择通过关键字部分或完全调用函数(或构建一个kwargs字典并将其插入),而不改变传入的值:
def function_being_mocked(x, y):
  pass

# Initial code
def function_being_tested():
  # other stuff
  function_being_mocked(42, 150)

# After refactor
def function_being_tested():
  # other stuff
  function_being_mocked(x=42, y=150)
  # or say kwargs = {'x': 42, 'y': 150} and function_being_mocked(**kwargs)

这可能有点过头了,但我希望我的单元测试不必担心函数调用格式的变化,只要预期的值传递给了函数调用(甚至包括指定或不指定默认值)。
以下是我想出的解决方案。希望这能简化您的测试体验:
from inspect import Parameter, Signature, signature

class DefaultValue(object):
    def __init__(self, value):
        self.value = value

    def __eq__(self, other_value) -> bool:
        if isinstance(other_value, DefaultValue):
            return self.value == other_value.value
        return self.value == other_value

    def __repr__(self) -> str:
        return f'<DEFAULT_VALUE: {self.value}>'

def standardize_func_args(func_sig, args, kwargs, is_method):
    kwargs = kwargs.copy()

    # Remove self/cls from kwargs if is_method=True
    parameters = list(func_sig.parameters.values())
    if is_method:
        parameters = list(parameters)[1:]

    # Positional arguments passed in need to line up index-wise
    # with the function signature.
    for (i, arg_value) in enumerate(args):
        kwargs[parameters[i].name] = arg_value

    kwargs.update({
        param.name: DefaultValue(param.default)
        for param in parameters
        if param.name not in kwargs
    })

    # Order the resulting kwargs by the function signature parameter order
    # so that the stringification in assert error message is consistent on
    # the objects being compared.
    return {
        param.name: kwargs[param.name]
        for param in parameters
    }

def _validate_func_signature(func_sig: Signature):
    assert not any(
        p.kind == Parameter.VAR_KEYWORD or p.kind == Parameter.VAR_POSITIONAL
        for p in func_sig.parameters.values()
    ), 'Functions with *args or **kwargs not supported'

def __assert_called_with(mock, func, is_method, *args, **kwargs):
    func_sig = signature(func)
    _validate_func_signature(func_sig)

    mock_args = standardize_func_args(
        func_sig, mock.call_args.args, mock.call_args.kwargs, is_method)
    func_args = standardize_func_args(func_sig, args, kwargs, is_method)

    assert mock_args == func_args, f'Expected {func_args} but got {mock_args}'

def assert_called_with(mock, func, *args, **kwargs):
    __assert_called_with(mock, func, False, *args, **kwargs)

def assert_method_called_with(mock, func, *args, **kwargs):
    __assert_called_with(mock, func, True, *args, **kwargs)

使用方法:

from unittest.mock import MagicMock

def bar(x, y=5, z=25):
    pass

mock = MagicMock()
mock(42)

assert_called_with(mock, bar, 42) # passes
assert_called_with(mock, bar, 42, 5) # passes
assert_called_with(mock, bar, x=42) # passes
assert_called_with(mock, bar, 42, z=25) # passes
assert_called_with(mock, bar, z=25, x=42, y=5) # passes

# AssertionError: Expected {'x': 51, 'y': <DEFAULT_VALUE: 5>, 'z': <DEFAULT_VALUE: 25>} but got {'x': 42, 'y': <DEFAULT_VALUE: 5>, 'z': <DEFAULT_VALUE: 25>}
assert_called_with(mock, bar, 51)

# AssertionError: Expected {'x': 42, 'y': 51, 'z': <DEFAULT_VALUE: 25>} but got {'x': 42, 'y': <DEFAULT_VALUE: 5>, 'z': <DEFAULT_VALUE: 25>}
assert_called_with(mock, bar, 42, 51)

在使用之前,请注意一个警告。`assert_called_with()`需要引用原始函数。如果您在单元测试中使用装饰器`@unittest.mock.patch`,可能会出现问题,因为您尝试查找函数签名时可能会选择模拟对象而不是原始函数。
from unittest import mock

class Tester(unittest.TestCase):
    @unittest.mock.patch('module.function_to_patch')
    def test_function(self, mock):
        function_to_be_tested()
        # Here module.function_to_patch has already been replaced by mock,
        # leading to error from _validate_func_signature. If this is your
        # intended usage, don't use assert_called_with()
        assert_called_with(mock, module.function_to_patch, *args, **kwargs)


我建议使用unittest.mock.patch.object,这需要你导入被修补的函数,因为我的代码需要引用该函数。
class Tester(unittest.TestCase):
    def test_function(self):
        orig_func_patched = module.function_to_patch
        with unittest.mock.patch.object(module, 'function_to_patch') as mock:
            function_to_be_tested()
        
            assert_called_with(mock, orig_func_patched, *args, **kwargs)

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接