Skip to content

Commit 67e6136

Browse files
bpo-35226: Fix equality for nested unittest.mock.call objects. (GH-10555)
Also refactor the call recording implementation and add some notes about its limitations. (cherry picked from commit 8ca0fa9) Co-authored-by: Chris Withers <chris@withers.org>
1 parent 0f9b668 commit 67e6136

File tree

6 files changed

+124
-23
lines changed

6 files changed

+124
-23
lines changed

Doc/library/unittest.mock-examples.rst

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,15 @@ You use the :data:`call` object to construct lists for comparing with
153153
>>> mock.mock_calls == expected
154154
True
155155

156+
However, parameters to calls that return mocks are not recorded, which means it is not
157+
possible to track nested calls where the parameters used to create ancestors are important:
158+
159+
>>> m = Mock()
160+
>>> m.factory(important=True).deliver()
161+
<Mock name='mock.factory().deliver()' id='...'>
162+
>>> m.mock_calls[-1] == call.factory(important=False).deliver()
163+
True
164+
156165

157166
Setting Return Values and Attributes
158167
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

Doc/library/unittest.mock.rst

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -680,6 +680,19 @@ the *new_callable* argument to :func:`patch`.
680680
unpacked as tuples to get at the individual arguments. See
681681
:ref:`calls as tuples <calls-as-tuples>`.
682682

683+
.. note::
684+
685+
The way :attr:`mock_calls` are recorded means that where nested
686+
calls are made, the parameters of ancestor calls are not recorded
687+
and so will always compare equal:
688+
689+
>>> mock = MagicMock()
690+
>>> mock.top(a=3).bottom()
691+
<MagicMock name='mock.top().bottom()' id='...'>
692+
>>> mock.mock_calls
693+
[call.top(a=3), call.top().bottom()]
694+
>>> mock.mock_calls[-1] == call.top(a=-1).bottom()
695+
True
683696

684697
.. attribute:: __class__
685698

Lib/unittest/mock.py

Lines changed: 32 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -943,46 +943,51 @@ def _mock_call(_mock_self, *args, **kwargs):
943943
self = _mock_self
944944
self.called = True
945945
self.call_count += 1
946-
_new_name = self._mock_new_name
947-
_new_parent = self._mock_new_parent
948946

947+
# handle call_args
949948
_call = _Call((args, kwargs), two=True)
950949
self.call_args = _call
951950
self.call_args_list.append(_call)
952-
self.mock_calls.append(_Call(('', args, kwargs)))
953951

954952
seen = set()
955-
skip_next_dot = _new_name == '()'
953+
954+
# initial stuff for method_calls:
956955
do_method_calls = self._mock_parent is not None
957-
name = self._mock_name
958-
while _new_parent is not None:
959-
this_mock_call = _Call((_new_name, args, kwargs))
960-
if _new_parent._mock_new_name:
961-
dot = '.'
962-
if skip_next_dot:
963-
dot = ''
956+
method_call_name = self._mock_name
964957

965-
skip_next_dot = False
966-
if _new_parent._mock_new_name == '()':
967-
skip_next_dot = True
958+
# initial stuff for mock_calls:
959+
mock_call_name = self._mock_new_name
960+
is_a_call = mock_call_name == '()'
961+
self.mock_calls.append(_Call(('', args, kwargs)))
968962

969-
_new_name = _new_parent._mock_new_name + dot + _new_name
963+
# follow up the chain of mocks:
964+
_new_parent = self._mock_new_parent
965+
while _new_parent is not None:
970966

967+
# handle method_calls:
971968
if do_method_calls:
972-
if _new_name == name:
973-
this_method_call = this_mock_call
974-
else:
975-
this_method_call = _Call((name, args, kwargs))
976-
_new_parent.method_calls.append(this_method_call)
977-
969+
_new_parent.method_calls.append(_Call((method_call_name, args, kwargs)))
978970
do_method_calls = _new_parent._mock_parent is not None
979971
if do_method_calls:
980-
name = _new_parent._mock_name + '.' + name
972+
method_call_name = _new_parent._mock_name + '.' + method_call_name
981973

974+
# handle mock_calls:
975+
this_mock_call = _Call((mock_call_name, args, kwargs))
982976
_new_parent.mock_calls.append(this_mock_call)
977+
978+
if _new_parent._mock_new_name:
979+
if is_a_call:
980+
dot = ''
981+
else:
982+
dot = '.'
983+
is_a_call = _new_parent._mock_new_name == '()'
984+
mock_call_name = _new_parent._mock_new_name + dot + mock_call_name
985+
986+
# follow the parental chain:
983987
_new_parent = _new_parent._mock_new_parent
984988

985-
# use ids here so as not to call __hash__ on the mocks
989+
# check we're not in an infinite loop:
990+
# ( use ids here so as not to call __hash__ on the mocks)
986991
_new_parent_id = id(_new_parent)
987992
if _new_parent_id in seen:
988993
break
@@ -2018,6 +2023,10 @@ def __eq__(self, other):
20182023
else:
20192024
self_name, self_args, self_kwargs = self
20202025

2026+
if (getattr(self, 'parent', None) and getattr(other, 'parent', None)
2027+
and self.parent != other.parent):
2028+
return False
2029+
20212030
other_name = ''
20222031
if len_other == 0:
20232032
other_args, other_kwargs = (), {}

Lib/unittest/test/testmock/testhelpers.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,22 @@ def test_extended_call(self):
269269
self.assertEqual(mock.mock_calls, last_call.call_list())
270270

271271

272+
def test_extended_not_equal(self):
273+
a = call(x=1).foo
274+
b = call(x=2).foo
275+
self.assertEqual(a, a)
276+
self.assertEqual(b, b)
277+
self.assertNotEqual(a, b)
278+
279+
280+
def test_nested_calls_not_equal(self):
281+
a = call(x=1).foo().bar
282+
b = call(x=2).foo().bar
283+
self.assertEqual(a, a)
284+
self.assertEqual(b, b)
285+
self.assertNotEqual(a, b)
286+
287+
272288
def test_call_list(self):
273289
mock = MagicMock()
274290
mock(1)

Lib/unittest/test/testmock/testmock.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -916,6 +916,57 @@ def test_mock_calls(self):
916916
call().__int__().call_list())
917917

918918

919+
def test_child_mock_call_equal(self):
920+
m = Mock()
921+
result = m()
922+
result.wibble()
923+
# parent looks like this:
924+
self.assertEqual(m.mock_calls, [call(), call().wibble()])
925+
# but child should look like this:
926+
self.assertEqual(result.mock_calls, [call.wibble()])
927+
928+
929+
def test_mock_call_not_equal_leaf(self):
930+
m = Mock()
931+
m.foo().something()
932+
self.assertNotEqual(m.mock_calls[1], call.foo().different())
933+
self.assertEqual(m.mock_calls[0], call.foo())
934+
935+
936+
def test_mock_call_not_equal_non_leaf(self):
937+
m = Mock()
938+
m.foo().bar()
939+
self.assertNotEqual(m.mock_calls[1], call.baz().bar())
940+
self.assertNotEqual(m.mock_calls[0], call.baz())
941+
942+
943+
def test_mock_call_not_equal_non_leaf_params_different(self):
944+
m = Mock()
945+
m.foo(x=1).bar()
946+
# This isn't ideal, but there's no way to fix it without breaking backwards compatibility:
947+
self.assertEqual(m.mock_calls[1], call.foo(x=2).bar())
948+
949+
950+
def test_mock_call_not_equal_non_leaf_attr(self):
951+
m = Mock()
952+
m.foo.bar()
953+
self.assertNotEqual(m.mock_calls[0], call.baz.bar())
954+
955+
956+
def test_mock_call_not_equal_non_leaf_call_versus_attr(self):
957+
m = Mock()
958+
m.foo.bar()
959+
self.assertNotEqual(m.mock_calls[0], call.foo().bar())
960+
961+
962+
def test_mock_call_repr(self):
963+
m = Mock()
964+
m.foo().bar().baz.bob()
965+
self.assertEqual(repr(m.mock_calls[0]), 'call.foo()')
966+
self.assertEqual(repr(m.mock_calls[1]), 'call.foo().bar()')
967+
self.assertEqual(repr(m.mock_calls[2]), 'call.foo().bar().baz.bob()')
968+
969+
919970
def test_subclassing(self):
920971
class Subclass(Mock):
921972
pass
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
Recursively check arguments when testing for equality of
2+
:class:`unittest.mock.call` objects and add note that tracking of parameters
3+
used to create ancestors of mocks in ``mock_calls`` is not possible.

0 commit comments

Comments
 (0)