Skip to content

Commit e8f9e47

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

6 files changed

Lines changed: 124 additions & 23 deletions

File tree

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
@@ -955,46 +955,51 @@ def _mock_call(_mock_self, *args, **kwargs):
955955
self = _mock_self
956956
self.called = True
957957
self.call_count += 1
958-
_new_name = self._mock_new_name
959-
_new_parent = self._mock_new_parent
960958

959+
# handle call_args
961960
_call = _Call((args, kwargs), two=True)
962961
self.call_args = _call
963962
self.call_args_list.append(_call)
964-
self.mock_calls.append(_Call(('', args, kwargs)))
965963

966964
seen = set()
967-
skip_next_dot = _new_name == '()'
965+
966+
# initial stuff for method_calls:
968967
do_method_calls = self._mock_parent is not None
969-
name = self._mock_name
970-
while _new_parent is not None:
971-
this_mock_call = _Call((_new_name, args, kwargs))
972-
if _new_parent._mock_new_name:
973-
dot = '.'
974-
if skip_next_dot:
975-
dot = ''
968+
method_call_name = self._mock_name
976969

977-
skip_next_dot = False
978-
if _new_parent._mock_new_name == '()':
979-
skip_next_dot = True
970+
# initial stuff for mock_calls:
971+
mock_call_name = self._mock_new_name
972+
is_a_call = mock_call_name == '()'
973+
self.mock_calls.append(_Call(('', args, kwargs)))
980974

981-
_new_name = _new_parent._mock_new_name + dot + _new_name
975+
# follow up the chain of mocks:
976+
_new_parent = self._mock_new_parent
977+
while _new_parent is not None:
982978

979+
# handle method_calls:
983980
if do_method_calls:
984-
if _new_name == name:
985-
this_method_call = this_mock_call
986-
else:
987-
this_method_call = _Call((name, args, kwargs))
988-
_new_parent.method_calls.append(this_method_call)
989-
981+
_new_parent.method_calls.append(_Call((method_call_name, args, kwargs)))
990982
do_method_calls = _new_parent._mock_parent is not None
991983
if do_method_calls:
992-
name = _new_parent._mock_name + '.' + name
984+
method_call_name = _new_parent._mock_name + '.' + method_call_name
993985

986+
# handle mock_calls:
987+
this_mock_call = _Call((mock_call_name, args, kwargs))
994988
_new_parent.mock_calls.append(this_mock_call)
989+
990+
if _new_parent._mock_new_name:
991+
if is_a_call:
992+
dot = ''
993+
else:
994+
dot = '.'
995+
is_a_call = _new_parent._mock_new_name == '()'
996+
mock_call_name = _new_parent._mock_new_name + dot + mock_call_name
997+
998+
# follow the parental chain:
995999
_new_parent = _new_parent._mock_new_parent
9961000

997-
# use ids here so as not to call __hash__ on the mocks
1001+
# check we're not in an infinite loop:
1002+
# ( use ids here so as not to call __hash__ on the mocks)
9981003
_new_parent_id = id(_new_parent)
9991004
if _new_parent_id in seen:
10001005
break
@@ -2030,6 +2035,10 @@ def __eq__(self, other):
20302035
else:
20312036
self_name, self_args, self_kwargs = self
20322037

2038+
if (getattr(self, 'parent', None) and getattr(other, 'parent', None)
2039+
and self.parent != other.parent):
2040+
return False
2041+
20332042
other_name = ''
20342043
if len_other == 0:
20352044
other_args, other_kwargs = (), {}

Lib/unittest/test/testmock/testhelpers.py

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

272272

273+
def test_extended_not_equal(self):
274+
a = call(x=1).foo
275+
b = call(x=2).foo
276+
self.assertEqual(a, a)
277+
self.assertEqual(b, b)
278+
self.assertNotEqual(a, b)
279+
280+
281+
def test_nested_calls_not_equal(self):
282+
a = call(x=1).foo().bar
283+
b = call(x=2).foo().bar
284+
self.assertEqual(a, a)
285+
self.assertEqual(b, b)
286+
self.assertNotEqual(a, b)
287+
288+
273289
def test_call_list(self):
274290
mock = MagicMock()
275291
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)