Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions Doc/library/unittest.mock-examples.rst
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,15 @@ You use the :data:`call` object to construct lists for comparing with
>>> mock.mock_calls == expected
True

However, parameters to calls that return mocks are not recorded, which means it is not
possible to track nested calls where the parameters used to create ancestors are important:

>>> m = Mock()
>>> m.factory(important=True).deliver()
<Mock name='mock.factory().deliver()' id='...'>
>>> m.mock_calls[-1] == call.factory(important=False).deliver()
True


Setting Return Values and Attributes
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Expand Down
13 changes: 13 additions & 0 deletions Doc/library/unittest.mock.rst
Original file line number Diff line number Diff line change
Expand Up @@ -702,6 +702,19 @@ the *new_callable* argument to :func:`patch`.
unpacked as tuples to get at the individual arguments. See
:ref:`calls as tuples <calls-as-tuples>`.

.. note::

The way :attr:`mock_calls` are recorded means that where nested
calls are made, the parameters of ancestor calls are not recorded

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

They are recorded, just as different calls.. :/ I think this is going to be really confusing for the reader.

@cjw296 cjw296 Dec 2, 2018

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The parameters are absolutely not recorded in mock_calls, this is the core problem that isn't solvable.

@mariocj89 mariocj89 Dec 2, 2018

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

They are! just not in the last one (each call records only its own parameters), look:

>>> mock_backend.mock_calls
[call.get_endpoint('foobar'),
 call.get_endpoint().create_call('spam', 'eggs'),
 call.get_endpoint().create_call().start_call()]

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure we're getting anywhere constructive with this, so let's wait for Michael to take a look.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

...except when get_endpoint is called more than once.

and so will always compare equal:

>>> mock = MagicMock()
>>> mock.top(a=3).bottom()
<MagicMock name='mock.top().bottom()' id='...'>
>>> mock.mock_calls
[call.top(a=3), call.top().bottom()]
>>> mock.mock_calls[-1] == call.top(a=-1).bottom()
True

.. attribute:: __class__

Expand Down
55 changes: 32 additions & 23 deletions Lib/unittest/mock.py
Original file line number Diff line number Diff line change
Expand Up @@ -977,46 +977,51 @@ def _mock_call(_mock_self, *args, **kwargs):
self = _mock_self
self.called = True
self.call_count += 1
_new_name = self._mock_new_name
_new_parent = self._mock_new_parent

# handle call_args
_call = _Call((args, kwargs), two=True)
self.call_args = _call
self.call_args_list.append(_call)
self.mock_calls.append(_Call(('', args, kwargs)))

seen = set()
skip_next_dot = _new_name == '()'

# initial stuff for method_calls:
do_method_calls = self._mock_parent is not None
name = self._mock_name
while _new_parent is not None:
this_mock_call = _Call((_new_name, args, kwargs))
if _new_parent._mock_new_name:
dot = '.'
if skip_next_dot:
dot = ''
method_call_name = self._mock_name

skip_next_dot = False
if _new_parent._mock_new_name == '()':
skip_next_dot = True
# initial stuff for mock_calls:
mock_call_name = self._mock_new_name
is_a_call = mock_call_name == '()'
self.mock_calls.append(_Call(('', args, kwargs)))

_new_name = _new_parent._mock_new_name + dot + _new_name
# follow up the chain of mocks:
_new_parent = self._mock_new_parent
while _new_parent is not None:

# handle method_calls:
if do_method_calls:
if _new_name == name:
this_method_call = this_mock_call
else:
this_method_call = _Call((name, args, kwargs))
_new_parent.method_calls.append(this_method_call)

_new_parent.method_calls.append(_Call((method_call_name, args, kwargs)))
do_method_calls = _new_parent._mock_parent is not None
if do_method_calls:
name = _new_parent._mock_name + '.' + name
method_call_name = _new_parent._mock_name + '.' + method_call_name

# handle mock_calls:
this_mock_call = _Call((mock_call_name, args, kwargs))
_new_parent.mock_calls.append(this_mock_call)

if _new_parent._mock_new_name:
if is_a_call:
dot = ''
else:
dot = '.'
is_a_call = _new_parent._mock_new_name == '()'

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this var used?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

indeed, I quickly overview it and it is used within the loop.

mock_call_name = _new_parent._mock_new_name + dot + mock_call_name

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes.


# follow the parental chain:
_new_parent = _new_parent._mock_new_parent

# use ids here so as not to call __hash__ on the mocks
# check we're not in an infinite loop:
# ( use ids here so as not to call __hash__ on the mocks)
_new_parent_id = id(_new_parent)
if _new_parent_id in seen:
break
Expand Down Expand Up @@ -2054,6 +2059,10 @@ def __eq__(self, other):
else:
self_name, self_args, self_kwargs = self

if (getattr(self, 'parent', None) and getattr(other, 'parent', None)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

self should always have a parent attribute, no need to use getattr, isn't it?

You are using gettattr to ignore the value and then compare it. What if it has a parent but it evaluates Falsy? Might be better to just use hasattr.

if hasattr(other, 'parent') and self.parent != other.parent:
    return False

I think it reads easier

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The implementation was suggested in the bug discussion, @voidspace can make a call here.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think @mariocj89 has a point. On call() the parent is None.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is hasattr safe to use here or does it still risk swallowing exceptions other than AttributeError on Python 3.6+? The grumpy old python dev in me still prefers getattr(other, 'parent', None) out of paranoia...

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hasattr was fixed quite some time ago I believe :-)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Surprising indeed!

Look at this:

>>> from unittest.mock import Mock
>>> m = Mock()
>>> m.abc.cde()
<Mock name='mock.abc.cde()' id='139944355306408'>
>>> m.mock_calls
[call.abc.cde()]
>>> print(m.mock_calls[0].parent)
None

Seems parent is not set at all, which is causing a lot of tests to fail with my proposal :/

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wow. That seems like a bug too, let me add some cases and see about a fix.
@voidspace - open question: shall we keep getattr(other, 'parent', None) to cover the porting-to-testing-cabal version issue?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(just as a note, we can follow this up indeed in another issue as I am not sure about the fix, seems we are not passing the parent here): https://github.com/python/cpython/blob/master/Lib/unittest/mock.py#L986)

@cjw296 cjw296 Nov 15, 2018

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm afraid it's intrinsically linked to this issue, call children are designed to be compared to items in Mock().method_calls and Mock().mock_calls and I don't think that currently works safely... They'll generally always compare as equal, which isn't what we want with nested/generative calls...

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Where there's a single call there's no parent call. So having parent not set in that example looks correct.

and self.parent != other.parent):
return False

other_name = ''
if len_other == 0:
other_args, other_kwargs = (), {}
Expand Down
16 changes: 16 additions & 0 deletions Lib/unittest/test/testmock/testhelpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,22 @@ def test_extended_call(self):
self.assertEqual(mock.mock_calls, last_call.call_list())


def test_extended_not_equal(self):
a = call(x=1).foo

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I know it works due to the implementation, but I'd also validate this works recursively for future changes to the module with

a = call(x=1).foo().bar
b = call(x=2).foo().bar

b = call(x=2).foo
self.assertEqual(a, a)
self.assertEqual(b, b)
self.assertNotEqual(a, b)


def test_nested_calls_not_equal(self):
a = call(x=1).foo().bar
b = call(x=2).foo().bar
self.assertEqual(a, a)
self.assertEqual(b, b)
self.assertNotEqual(a, b)


def test_call_list(self):
mock = MagicMock()
mock(1)
Expand Down
51 changes: 51 additions & 0 deletions Lib/unittest/test/testmock/testmock.py
Original file line number Diff line number Diff line change
Expand Up @@ -925,6 +925,57 @@ def test_mock_calls(self):
call().__int__().call_list())


def test_child_mock_call_equal(self):
m = Mock()
result = m()
result.wibble()
# parent looks like this:
self.assertEqual(m.mock_calls, [call(), call().wibble()])
# but child should look like this:
self.assertEqual(result.mock_calls, [call.wibble()])


def test_mock_call_not_equal_leaf(self):
m = Mock()
m.foo().something()
self.assertNotEqual(m.mock_calls[1], call.foo().different())
self.assertEqual(m.mock_calls[0], call.foo())


def test_mock_call_not_equal_non_leaf(self):
m = Mock()
m.foo().bar()
self.assertNotEqual(m.mock_calls[1], call.baz().bar())
self.assertNotEqual(m.mock_calls[0], call.baz())


def test_mock_call_not_equal_non_leaf_params_different(self):
m = Mock()
m.foo(x=1).bar()
# This isn't ideal, but there's no way to fix it without breaking backwards compatibility:
self.assertEqual(m.mock_calls[1], call.foo(x=2).bar())


def test_mock_call_not_equal_non_leaf_attr(self):
m = Mock()
m.foo.bar()
self.assertNotEqual(m.mock_calls[0], call.baz.bar())


def test_mock_call_not_equal_non_leaf_call_versus_attr(self):
m = Mock()
m.foo.bar()
self.assertNotEqual(m.mock_calls[0], call.foo().bar())


def test_mock_call_repr(self):
m = Mock()
m.foo().bar().baz.bob()
self.assertEqual(repr(m.mock_calls[0]), 'call.foo()')
self.assertEqual(repr(m.mock_calls[1]), 'call.foo().bar()')
self.assertEqual(repr(m.mock_calls[2]), 'call.foo().bar().baz.bob()')


def test_subclassing(self):
class Subclass(Mock):
pass
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
Recursively check arguments when testing for equality of
:class:`unittest.mock.call` objects and add note that tracking of parameters
used to create ancestors of mocks in ``mock_calls`` is not possible.