Skip to content

gh-75988: Fix issues with autospec ignoring wrapped object #115223

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 21 commits into from
Mar 8, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
ad78379
Fix #75988
infohash Feb 9, 2024
53c54e0
Merge branch 'main' into fix-issue-75988
infohash Feb 9, 2024
41e8999
set default return value of functional types as _mock_return_value
infohash Feb 9, 2024
507c285
Merge branch 'main' into fix-issue-75988
infohash Feb 9, 2024
6bf3e13
Merge remote-tracking branch 'upstream/main' into fix-issue-75988
infohash Feb 10, 2024
194242f
added test of wrapping child attributes
infohash Feb 10, 2024
8442dcd
Merge branch 'fix-issue-75988' of https://github.com/infohash/cpython…
infohash Feb 10, 2024
21d24f4
Merge branch 'main' into fix-issue-75988
infohash Feb 11, 2024
71beacd
Merge remote-tracking branch 'upstream/main' into fix-issue-75988
infohash Feb 19, 2024
65b398f
Merge remote-tracking branch 'upstream/main' into fix-issue-75988
infohash Feb 19, 2024
e375a4d
added backward compatibility with explicit return
infohash Feb 19, 2024
ac857f7
📜🤖 Added by blurb_it.
blurb-it[bot] Feb 27, 2024
914b655
Merge remote-tracking branch 'upstream/main' into fix-issue-75988
infohash Feb 27, 2024
8e7db2d
Update testmock.py
infohash Feb 27, 2024
0b880a3
Merge remote-tracking branch 'upstream/main' into fix-issue-75988
infohash Feb 28, 2024
58b49d6
added docs on the order of precedence
infohash Feb 28, 2024
796db26
Merge remote-tracking branch 'upstream/main' into fix-issue-75988
infohash Feb 29, 2024
efd5db6
added test to check default return_value
infohash Feb 29, 2024
48a62f1
Apply suggestions from code review
infohash Mar 8, 2024
514688b
Resolved changes
infohash Mar 8, 2024
49a08dc
Merge remote-tracking branch 'upstream/main' into fix-issue-75988
infohash Mar 8, 2024
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
120 changes: 120 additions & 0 deletions Doc/library/unittest.mock.rst
Original file line number Diff line number Diff line change
Expand Up @@ -2831,3 +2831,123 @@ Sealing mocks
>>> mock.not_submock.attribute2 # This won't raise.

.. versionadded:: 3.7


Order of precedence of :attr:`side_effect`, :attr:`return_value` and *wraps*
----------------------------------------------------------------------------

The order of their precedence is:

1. :attr:`~Mock.side_effect`
2. :attr:`~Mock.return_value`
3. *wraps*

If all three are set, mock will return the value from :attr:`~Mock.side_effect`,
ignoring :attr:`~Mock.return_value` and the wrapped object altogether. If any
two are set, the one with the higher precedence will return the value.
Regardless of the order of which was set first, the order of precedence
remains unchanged.

>>> from unittest.mock import Mock
>>> class Order:
... @staticmethod
... def get_value():
... return "third"
...
>>> order_mock = Mock(spec=Order, wraps=Order)
>>> order_mock.get_value.side_effect = ["first"]
>>> order_mock.get_value.return_value = "second"
>>> order_mock.get_value()
'first'

As ``None`` is the default value of :attr:`~Mock.side_effect`, if you reassign
its value back to ``None``, the order of precedence will be checked between
:attr:`~Mock.return_value` and the wrapped object, ignoring
:attr:`~Mock.side_effect`.

>>> order_mock.get_value.side_effect = None
>>> order_mock.get_value()
'second'

If the value being returned by :attr:`~Mock.side_effect` is :data:`DEFAULT`,
it is ignored and the order of precedence moves to the successor to obtain the
value to return.

>>> from unittest.mock import DEFAULT
>>> order_mock.get_value.side_effect = [DEFAULT]
>>> order_mock.get_value()
'second'

When :class:`Mock` wraps an object, the default value of
:attr:`~Mock.return_value` will be :data:`DEFAULT`.

>>> order_mock = Mock(spec=Order, wraps=Order)
>>> order_mock.return_value
sentinel.DEFAULT
>>> order_mock.get_value.return_value
sentinel.DEFAULT

The order of precedence will ignore this value and it will move to the last
successor which is the wrapped object.

As the real call is being made to the wrapped object, creating an instance of
this mock will return the real instance of the class. The positional arguments,
if any, required by the wrapped object must be passed.

>>> order_mock_instance = order_mock()
>>> isinstance(order_mock_instance, Order)
True
>>> order_mock_instance.get_value()
'third'

>>> order_mock.get_value.return_value = DEFAULT
>>> order_mock.get_value()
'third'

>>> order_mock.get_value.return_value = "second"
>>> order_mock.get_value()
'second'

But if you assign ``None`` to it, this will not be ignored as it is an
explicit assignment. So, the order of precedence will not move to the wrapped
object.

>>> order_mock.get_value.return_value = None
>>> order_mock.get_value() is None
True

Even if you set all three at once when initializing the mock, the order of
precedence remains the same:

>>> order_mock = Mock(spec=Order, wraps=Order,
... **{"get_value.side_effect": ["first"],
... "get_value.return_value": "second"}
... )
...
>>> order_mock.get_value()
'first'
>>> order_mock.get_value.side_effect = None
>>> order_mock.get_value()
'second'
>>> order_mock.get_value.return_value = DEFAULT
>>> order_mock.get_value()
'third'

If :attr:`~Mock.side_effect` is exhausted, the order of precedence will not
cause a value to be obtained from the successors. Instead, ``StopIteration``
exception is raised.

>>> order_mock = Mock(spec=Order, wraps=Order)
>>> order_mock.get_value.side_effect = ["first side effect value",
... "another side effect value"]
>>> order_mock.get_value.return_value = "second"

>>> order_mock.get_value()
'first side effect value'
>>> order_mock.get_value()
'another side effect value'

>>> order_mock.get_value()
Traceback (most recent call last):
...
StopIteration
67 changes: 67 additions & 0 deletions Lib/test/test_unittest/testmock/testmock.py
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,65 @@ class B(object):
with mock.patch('builtins.open', mock.mock_open()):
mock.mock_open() # should still be valid with open() mocked

def test_create_autospec_wraps_class(self):
"""Autospec a class with wraps & test if the call is passed to the
wrapped object."""
result = "real result"

class Result:
def get_result(self):
return result
class_mock = create_autospec(spec=Result, wraps=Result)
# Have to reassign the return_value to DEFAULT to return the real
# result (actual instance of "Result") when the mock is called.
class_mock.return_value = mock.DEFAULT
self.assertEqual(class_mock().get_result(), result)
# Autospec should also wrap child attributes of parent.
self.assertEqual(class_mock.get_result._mock_wraps, Result.get_result)

def test_create_autospec_instance_wraps_class(self):
"""Autospec a class instance with wraps & test if the call is passed
to the wrapped object."""
result = "real result"

class Result:
@staticmethod
def get_result():
"""This is a static method because when the mocked instance of
'Result' will call this method, it won't be able to consume
'self' argument."""
return result
instance_mock = create_autospec(spec=Result, instance=True, wraps=Result)
# Have to reassign the return_value to DEFAULT to return the real
# result from "Result.get_result" when the mocked instance of "Result"
# calls "get_result".
instance_mock.get_result.return_value = mock.DEFAULT
self.assertEqual(instance_mock.get_result(), result)
# Autospec should also wrap child attributes of the instance.
self.assertEqual(instance_mock.get_result._mock_wraps, Result.get_result)

def test_create_autospec_wraps_function_type(self):
"""Autospec a function or a method with wraps & test if the call is
passed to the wrapped object."""
result = "real result"

class Result:
def get_result(self):
return result
func_mock = create_autospec(spec=Result.get_result, wraps=Result.get_result)
self.assertEqual(func_mock(Result()), result)

def test_explicit_return_value_even_if_mock_wraps_object(self):
"""If the mock has an explicit return_value set then calls are not
passed to the wrapped object and the return_value is returned instead.
"""
def my_func():
return None
func_mock = create_autospec(spec=my_func, wraps=my_func)
return_value = "explicit return value"
func_mock.return_value = return_value
self.assertEqual(func_mock(), return_value)

def test_explicit_parent(self):
parent = Mock()
mock1 = Mock(parent=parent, return_value=None)
Expand Down Expand Up @@ -622,6 +681,14 @@ def test_wraps_calls(self):
real = Mock()

mock = Mock(wraps=real)
# If "Mock" wraps an object, just accessing its
# "return_value" ("NonCallableMock.__get_return_value") should not
# trigger its descriptor ("NonCallableMock.__set_return_value") so
# the default "return_value" should always be "sentinel.DEFAULT".
self.assertEqual(mock.return_value, DEFAULT)
# It will not be "sentinel.DEFAULT" if the mock is not wrapping any
# object.
self.assertNotEqual(real.return_value, DEFAULT)
self.assertEqual(mock(), real())

real.reset_mock()
Expand Down
13 changes: 11 additions & 2 deletions Lib/unittest/mock.py
Original file line number Diff line number Diff line change
Expand Up @@ -573,7 +573,7 @@ def __get_return_value(self):
if self._mock_delegate is not None:
ret = self._mock_delegate.return_value

if ret is DEFAULT:
if ret is DEFAULT and self._mock_wraps is None:
ret = self._get_child_mock(
_new_parent=self, _new_name='()'
)
Expand Down Expand Up @@ -1234,6 +1234,9 @@ def _execute_mock_call(self, /, *args, **kwargs):
if self._mock_return_value is not DEFAULT:
return self.return_value

if self._mock_delegate and self._mock_delegate.return_value is not DEFAULT:
return self.return_value

if self._mock_wraps is not None:
return self._mock_wraps(*args, **kwargs)

Expand Down Expand Up @@ -2785,9 +2788,12 @@ def create_autospec(spec, spec_set=False, instance=False, _parent=None,
if _parent is not None and not instance:
_parent._mock_children[_name] = mock

wrapped = kwargs.get('wraps')

if is_type and not instance and 'return_value' not in kwargs:
mock.return_value = create_autospec(spec, spec_set, instance=True,
_name='()', _parent=mock)
_name='()', _parent=mock,
wraps=wrapped)

for entry in dir(spec):
if _is_magic(entry):
Expand All @@ -2809,6 +2815,9 @@ def create_autospec(spec, spec_set=False, instance=False, _parent=None,
continue

kwargs = {'spec': original}
# Wrap child attributes also.
if wrapped and hasattr(wrapped, entry):
kwargs.update(wraps=original)
if spec_set:
kwargs = {'spec_set': original}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Fixed :func:`unittest.mock.create_autospec` to pass the call through to the wrapped object to return the real result.