1

I'm writing some tests for a function which passes an instance of collections.Counter to another function which I need to mock. Here is a small example representing my code:

# 'bar' module

def callback(counter):
    """Do something with the *counter*."""
    pass


def update():
    """Update counter and pass it to callback."""
    counter = collections.Counter()

    for _ in range(3):
        counter.update(["foo"])
        callback(counter)

The following test does not pass as the same counter instance has been mutated and passed to the 'callback' function. Therefore, the assertion will only pass when all calls take the latest value of the counter, which is collections.Counter({"foo": 3})

@pytest.fixture()
def mocked_callback(mocker):
    return mocker.patch("bar.callback")


def test_update(mocker, mocked_callback):
    update()

    assert mocked_callback.call_args_list == [
        mocker.call(collections.Counter({"foo": 1})),
        mocker.call(collections.Counter({"foo": 2})),
        mocker.call(collections.Counter({"foo": 3})),
    ]

Does anyone know a good way to analyze the exact state of the mutated object when it is passed to the mocked function?

MrBean Bremen
  • 14,916
  • 3
  • 26
  • 46
jeremyr
  • 425
  • 4
  • 12
  • Are you sure you're mocking the right function? The `__name__` int the test case might likely be different from the name of the module you're trying to test... – pepoluan Oct 12 '20 at 05:19
  • There are a couple of errors in the code here (the mentioned `__name__`, and a mixup of `callback` and `do_something`), but the basic problem is that the calls refer to a dict that is updated during the calls, so that after the calls all calls point to the same (final) dict `{"foo": 3}` (not `{"foo": 1}`). I'm not sure how to solve this... – MrBean Bremen Oct 12 '20 at 10:02
  • Thanks, I fixed the typo you spotted. Also my _real_ tests are not mixed in the same module as the code it is testing, I just used `__name__` for providing a copy-pastable example :) – jeremyr Oct 12 '20 at 15:41
  • @MrBeanBremen This is precisely the issue I'm trying to solve yeah. Right now my only workaround is to [spy](https://github.com/pytest-dev/pytest-mock/blob/9e1464bb87d551b1a242104b68bad8d8a1429316/src/pytest_mock/plugin.py#L86) on `collections.Counter.update` instead of checking callback's arguments, but I feel there must be a better way.. – jeremyr Oct 12 '20 at 15:46

1 Answers1

2

This issue is actually referenced in the unittest documentation.

Another situation is rare, but can bite you, is when your mock is called with mutable arguments. call_args and call_args_list store references to the arguments. If the arguments are mutated by the code under test then you can no longer make assertions about what the values were when the mock was called.

The suggested workaround seems to work perfectly!

@pytest.fixture()
def mocked_callback(mocker):

    class _CopyingMock(mocker.MagicMock):
        """Extended Mocker to copy arguments on each call."""

        def __call__(self, *args, **kwargs):
            args = copy.deepcopy(args)
            kwargs = copy.deepcopy(kwargs)
            return super(_MagicMock, self).__call__(*args, **kwargs)

    return mocker.patch("bar.callback", _CopyingMock())

There are tickets to include this feature in the core library:

jeremyr
  • 425
  • 4
  • 12
  • Nice! I actually thought about this as a workaround (was to write a respective comment just now), but I didn't know that it's in the documentation. – MrBean Bremen Oct 12 '20 at 16:59
  • I was actually about to ask on the pytest-mock repo when I stubbled on a [similar question](https://github.com/pytest-dev/pytest-mock/issues/210), this is where I found the link :) – jeremyr Oct 12 '20 at 17:24