Skip to content

Commit 7eca2ca

Browse files
committed
bpo-30587: Adds signature checking for mock autospec object method calls
Mock can accept an spec object / class as argument, making sure that accessing attributes that do not exist in the spec will cause an AttributeError to be raised, but there is no guarantee that the spec's methods signatures are respected in any way. This creates the possibility to have faulty code with passing unittests and assertions. Example: from unittest import mock class Something(object): def foo(self, a, b, c, d): pass m = mock.Mock(spec=Something) m.foo() Adds the autospec argument to Mock, and its mock_add_spec method. Passes the spec's attribute with the same name to the child mock (spec-ing the child), if the mock's autospec is True. Sets _mock_check_sig if the given spec is callable. Adds unit tests to validate the fact that the autospec method signatures are respected.
1 parent e451a89 commit 7eca2ca

File tree

4 files changed

+84
-8
lines changed

4 files changed

+84
-8
lines changed

Lib/test/test_unittest/testmock/testasync.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -450,7 +450,7 @@ async def test_add_return_value(self):
450450
async def addition(self, var): pass
451451

452452
mock = AsyncMock(addition, return_value=10)
453-
output = await mock(5)
453+
output = await mock(self, 5)
454454

455455
self.assertEqual(output, 10)
456456

Lib/test/test_unittest/testmock/testmock.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -668,6 +668,47 @@ def test_only_allowed_methods_exist(self):
668668
getattr, mock, 'something_else'
669669
)
670670

671+
def _check_autospeced_something(self, something):
672+
for method_name in ['meth', 'cmeth', 'smeth']:
673+
mock_method = getattr(something, method_name)
674+
675+
# check that the methods are callable with correct args.
676+
mock_method(sentinel.a, sentinel.b, sentinel.c)
677+
mock_method(sentinel.a, sentinel.b, sentinel.c, d=sentinel.d)
678+
mock_method.assert_has_calls([
679+
call(sentinel.a, sentinel.b, sentinel.c),
680+
call(sentinel.a, sentinel.b, sentinel.c, d=sentinel.d)])
681+
682+
# assert that TypeError is raised if the method signature is not
683+
# respected.
684+
self.assertRaises(TypeError, mock_method)
685+
self.assertRaises(TypeError, mock_method, sentinel.a)
686+
self.assertRaises(TypeError, mock_method, a=sentinel.a)
687+
self.assertRaises(TypeError, mock_method, sentinel.a, sentinel.b,
688+
sentinel.c, e=sentinel.e)
689+
690+
# assert that AttributeError is raised if the method does not exist.
691+
self.assertRaises(AttributeError, getattr, something, 'foolish')
692+
693+
694+
def test_mock_autospec_all_members(self):
695+
for spec in [Something, Something()]:
696+
mock_something = Mock(autospec=spec)
697+
self._check_autospeced_something(mock_something)
698+
699+
700+
def test_mock_spec_function(self):
701+
def foo(lish):
702+
pass
703+
704+
mock_foo = Mock(spec=foo)
705+
706+
mock_foo(sentinel.lish)
707+
mock_foo.assert_called_once_with(sentinel.lish)
708+
self.assertRaises(TypeError, mock_foo)
709+
self.assertRaises(TypeError, mock_foo, sentinel.foo, sentinel.lish)
710+
self.assertRaises(TypeError, mock_foo, foo=sentinel.foo)
711+
671712

672713
def test_from_spec(self):
673714
class Something(object):
@@ -1967,6 +2008,13 @@ def test_mock_add_spec_magic_methods(self):
19672008
self.assertRaises(TypeError, lambda: mock['foo'])
19682009

19692010

2011+
def test_mock_add_spec_autospec_all_members(self):
2012+
for spec in [Something, Something()]:
2013+
mock_something = Mock()
2014+
mock_something.mock_add_spec(spec, autospec=True)
2015+
self._check_autospeced_something(mock_something)
2016+
2017+
19702018
def test_adding_child_mock(self):
19712019
for Klass in (NonCallableMock, Mock, MagicMock, NonCallableMagicMock,
19722020
AsyncMock):

Lib/unittest/mock.py

Lines changed: 32 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,8 @@ def checksig(self, /, *args, **kwargs):
135135
type(mock)._mock_check_sig = checksig
136136
type(mock).__signature__ = sig
137137

138+
return sig
139+
138140

139141
def _copy_func_details(func, funcopy):
140142
# we explicitly don't copy func.__dict__ into this copy as it would
@@ -463,7 +465,8 @@ def __new__(
463465
def __init__(
464466
self, spec=None, wraps=None, name=None, spec_set=None,
465467
parent=None, _spec_state=None, _new_name='', _new_parent=None,
466-
_spec_as_instance=False, _eat_self=None, unsafe=False, **kwargs
468+
_spec_as_instance=False, _eat_self=None, unsafe=False,
469+
autospec=None, **kwargs
467470
):
468471
if _new_parent is None:
469472
_new_parent = parent
@@ -474,10 +477,15 @@ def __init__(
474477
__dict__['_mock_new_name'] = _new_name
475478
__dict__['_mock_new_parent'] = _new_parent
476479
__dict__['_mock_sealed'] = False
480+
__dict__['_autospec'] = autospec
477481

478482
if spec_set is not None:
479483
spec = spec_set
480484
spec_set = True
485+
if autospec is not None:
486+
# autospec is even stricter than spec_set.
487+
spec = autospec
488+
autospec = True
481489
if _eat_self is None:
482490
_eat_self = parent is not None
483491

@@ -520,12 +528,18 @@ def attach_mock(self, mock, attribute):
520528
setattr(self, attribute, mock)
521529

522530

523-
def mock_add_spec(self, spec, spec_set=False):
531+
def mock_add_spec(self, spec, spec_set=False, autospec=None):
524532
"""Add a spec to a mock. `spec` can either be an object or a
525533
list of strings. Only attributes on the `spec` can be fetched as
526534
attributes from the mock.
527535
528-
If `spec_set` is True then only attributes on the spec can be set."""
536+
If `spec_set` is True then only attributes on the spec can be set.
537+
If `autospec` is True then only attributes on the spec can be accessed
538+
and set, and if a method in the `spec` is called, it's signature is
539+
checked.
540+
"""
541+
if autospec is not None:
542+
self.__dict__['_autospec'] = autospec
529543
self._mock_add_spec(spec, spec_set)
530544

531545

@@ -543,9 +557,9 @@ def _mock_add_spec(self, spec, spec_set, _spec_as_instance=False,
543557
_spec_class = spec
544558
else:
545559
_spec_class = type(spec)
546-
res = _get_signature_object(spec,
547-
_spec_as_instance, _eat_self)
548-
_spec_signature = res and res[1]
560+
561+
_spec_signature = _check_signature(spec, self, _eat_self,
562+
_spec_as_instance)
549563

550564
spec_list = dir(spec)
551565

@@ -705,9 +719,20 @@ def __getattr__(self, name):
705719
# execution?
706720
wraps = getattr(self._mock_wraps, name)
707721

722+
kwargs = {}
723+
if self.__dict__.get('_autospec') is not None:
724+
# get the mock's spec attribute with the same name and
725+
# pass it to the child.
726+
spec_class = self.__dict__.get('_spec_class')
727+
spec = getattr(spec_class, name, None)
728+
is_type = isinstance(spec_class, type)
729+
eat_self = _must_skip(spec_class, name, is_type)
730+
kwargs['_eat_self'] = eat_self
731+
kwargs['autospec'] = spec
732+
708733
result = self._get_child_mock(
709734
parent=self, name=name, wraps=wraps, _new_name=name,
710-
_new_parent=self
735+
_new_parent=self, **kwargs
711736
)
712737
self._mock_children[name] = result
713738

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
mock: Added autospec argument to the constructors and mock_add_spec. Passing
2+
the autospec argument, will also check the method signatures of the mocked
3+
methods.

0 commit comments

Comments
 (0)