0

Today I spent pretty much time on some tricky unit test issue, where I tried to properly assert two calls on the same method and getting very strange behavior from unittest.mock's assert_has_calls method.

Here there is very simplified example how I tried to assert some calls:

class Foo():
    def __init__(self):
        pass

    # Method that I testing!
    def bar(self, d):
        # doing something with dictionary
        print(d)


def baz():
    f = Foo()
    d = {1: 2}

    # first call
    f.bar(d)

    # updated dictionary
    d[3] = 4

    # second call, after dictionary mutation
    f.bar(d)


@mock.patch('foo.Foo')
def test_baz(foo_mock):
    baz()

    foo_mock.return_value.bar.assert_has_calls(
        [
            mock.call({1: 2}),
            mock.call({1: 2, 3: 4})
        ]
    )

Above very simple test (i.e. test_baz) failing with error:

E               AssertionError: Calls not found.
E               Expected: [call({1: 2}), call({1: 2, 3: 4})]
E               Actual: [call({1: 2, 3: 4}), call({1: 2, 3: 4})]

Reason is mutation of d dictionary in tested method between two calls and assert_has_calls somehow doesn't capture calls history properly, i.e. it just captures last dictionary state for all calls!

This looks to me like a bug in unittest.mock, but maybe I'm missing something here (i.e. using test framework improperly or so)?

It's pretty trivial unit test, but I have no other way to properly assert output of tested method (otherwise test would be useless). Does anybody faced with something like this and maybe have some workaround to propose?

The only solution that I see here is to change tested code (i.e. baz function) and create copy of mutated dictionary (d) prior to passing to method, but I would like to avoid that because it could be pretty large.

Wolf
  • 98
  • 7
  • 1
    Python unittest (not pytest, this is part of the unittest mock behavior) does not make a copy of the call objects, so if the objects change this will be reflected there. So this is the expected behavior, and I'm pretty sure I have seen a similar question here. You may check if you really need to see these call args, as what you want to test is usually the end result of a call, but I don't know your use case. – MrBean Bremen Jul 05 '22 at 18:06
  • @MrBeanBremen Yes, my bad, it's not `pytest` but Python's `mock` behavior. May be that I didn't found similar question (and answer) because of wrong search keywords (but I did search, many times). I will update question. However, if it's my design, it makes `assert_has_calls` pretty useless for such use-cases. Thanks a lot for response. – Wolf Jul 05 '22 at 18:11
  • 1
    I couldn't find the question either, but the answer boils down to: it is by design, check if you really need it, and if you do, you probably have to roll your own mock object that collects copies of the call args, and use that instead of a standard mock. It has not be elaborated, just do what you need. – MrBean Bremen Jul 05 '22 at 18:30
  • 1
    You gave me idea, will try to work in that direction and find some workaround for my use-case. Thanks @MrBeanBremen. – Wolf Jul 05 '22 at 18:44

1 Answers1

0

To answer myself (maybe it will be helpful to others, too) - I found solution thanks to idea that I got in comments (thanks @MrBeanBremen).

So, this is not a bug in unittest.mock (as expected), but described behavior is by design. More on this problematic can be found in mock getting started guide.

Depending on the specific use case, there are multiple workarounds (as described in the linked guide), and for my use case I decided to use helper function and side effects:

import mock
from copy import deepcopy

from pylib.pytest_examples.foo import baz


def copy_call_args(original_mock):
    new_mock = mock.Mock()

    def side_effect(*args, **kwargs):
        args = deepcopy(args)
        kwargs = deepcopy(kwargs)
        new_mock(*args, **kwargs)
        return mock.DEFAULT
    original_mock.side_effect = side_effect
    return new_mock


@mock.patch('foo.Foo')
def test_baz(foo_mock):
    bar_mock = copy_call_args(foo_mock.return_value.bar)
    baz()

    bar_mock.assert_has_calls(
        [
            mock.call({1: 2}),
            mock.call({1: 2, 3: 4})
        ]
    )

This way assert_has_calls assertion works properly.

Wolf
  • 98
  • 7