Skip to content

Commit 13f993d

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 04dee27 commit 13f993d

3 files changed

Lines changed: 84 additions & 7 deletions

File tree

‎Lib/unittest/mock.py‎

Lines changed: 32 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,8 @@ def checksig(_mock_self, *args, **kwargs):
103103
_copy_func_details(func, checksig)
104104
type(mock)._mock_check_sig = checksig
105105

106+
return sig
107+
106108

107109
def _copy_func_details(func, funcopy):
108110
# we explicitly don't copy func.__dict__ into this copy as it would
@@ -373,7 +375,8 @@ def __new__(cls, *args, **kw):
373375
def __init__(
374376
self, spec=None, wraps=None, name=None, spec_set=None,
375377
parent=None, _spec_state=None, _new_name='', _new_parent=None,
376-
_spec_as_instance=False, _eat_self=None, unsafe=False, **kwargs
378+
_spec_as_instance=False, _eat_self=None, unsafe=False,
379+
autospec=None, **kwargs
377380
):
378381
if _new_parent is None:
379382
_new_parent = parent
@@ -384,10 +387,15 @@ def __init__(
384387
__dict__['_mock_new_name'] = _new_name
385388
__dict__['_mock_new_parent'] = _new_parent
386389
__dict__['_mock_sealed'] = False
390+
__dict__['_autospec'] = autospec
387391

388392
if spec_set is not None:
389393
spec = spec_set
390394
spec_set = True
395+
if autospec is not None:
396+
# autospec is even stricter than spec_set.
397+
spec = autospec
398+
autospec = True
391399
if _eat_self is None:
392400
_eat_self = parent is not None
393401

@@ -428,12 +436,18 @@ def attach_mock(self, mock, attribute):
428436
setattr(self, attribute, mock)
429437

430438

431-
def mock_add_spec(self, spec, spec_set=False):
439+
def mock_add_spec(self, spec, spec_set=False, autospec=None):
432440
"""Add a spec to a mock. `spec` can either be an object or a
433441
list of strings. Only attributes on the `spec` can be fetched as
434442
attributes from the mock.
435443
436-
If `spec_set` is True then only attributes on the spec can be set."""
444+
If `spec_set` is True then only attributes on the spec can be set.
445+
If `autospec` is True then only attributes on the spec can be accessed
446+
and set, and if a method in the `spec` is called, it's signature is
447+
checked.
448+
"""
449+
if autospec is not None:
450+
self.__dict__['_autospec'] = autospec
437451
self._mock_add_spec(spec, spec_set)
438452

439453

@@ -447,9 +461,9 @@ def _mock_add_spec(self, spec, spec_set, _spec_as_instance=False,
447461
_spec_class = spec
448462
else:
449463
_spec_class = _get_class(spec)
450-
res = _get_signature_object(spec,
451-
_spec_as_instance, _eat_self)
452-
_spec_signature = res and res[1]
464+
465+
_spec_signature = _check_signature(spec, self, _eat_self,
466+
_spec_as_instance)
453467

454468
spec = dir(spec)
455469

@@ -594,9 +608,20 @@ def __getattr__(self, name):
594608
# execution?
595609
wraps = getattr(self._mock_wraps, name)
596610

611+
kwargs = {}
612+
if self.__dict__.get('_autospec') is not None:
613+
# get the mock's spec attribute with the same name and
614+
# pass it to the child.
615+
spec_class = self.__dict__.get('_spec_class')
616+
spec = getattr(spec_class, name, None)
617+
is_type = isinstance(spec_class, type)
618+
eat_self = _must_skip(spec_class, name, is_type)
619+
kwargs['_eat_self'] = eat_self
620+
kwargs['autospec'] = spec
621+
597622
result = self._get_child_mock(
598623
parent=self, name=name, wraps=wraps, _new_name=name,
599-
_new_parent=self
624+
_new_parent=self, **kwargs
600625
)
601626
self._mock_children[name] = result
602627

‎Lib/unittest/test/testmock/testmock.py‎

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -510,6 +510,48 @@ def test_only_allowed_methods_exist(self):
510510
)
511511

512512

513+
def _check_autospeced_something(self, something):
514+
for method_name in ['meth', 'cmeth', 'smeth']:
515+
mock_method = getattr(something, method_name)
516+
517+
# check that the methods are callable with correct args.
518+
mock_method(sentinel.a, sentinel.b, sentinel.c)
519+
mock_method(sentinel.a, sentinel.b, sentinel.c, d=sentinel.d)
520+
mock_method.assert_has_calls([
521+
call(sentinel.a, sentinel.b, sentinel.c),
522+
call(sentinel.a, sentinel.b, sentinel.c, d=sentinel.d)])
523+
524+
# assert that TypeError is raised if the method signature is not
525+
# respected.
526+
self.assertRaises(TypeError, mock_method)
527+
self.assertRaises(TypeError, mock_method, sentinel.a)
528+
self.assertRaises(TypeError, mock_method, a=sentinel.a)
529+
self.assertRaises(TypeError, mock_method, sentinel.a, sentinel.b,
530+
sentinel.c, e=sentinel.e)
531+
532+
# assert that AttributeError is raised if the method does not exist.
533+
self.assertRaises(AttributeError, getattr, something, 'foolish')
534+
535+
536+
def test_mock_autospec_all_members(self):
537+
for spec in [Something, Something()]:
538+
mock_something = Mock(autospec=spec)
539+
self._check_autospeced_something(mock_something)
540+
541+
542+
def test_mock_spec_function(self):
543+
def foo(lish):
544+
pass
545+
546+
mock_foo = Mock(spec=foo)
547+
548+
mock_foo(sentinel.lish)
549+
mock_foo.assert_called_once_with(sentinel.lish)
550+
self.assertRaises(TypeError, mock_foo)
551+
self.assertRaises(TypeError, mock_foo, sentinel.foo, sentinel.lish)
552+
self.assertRaises(TypeError, mock_foo, foo=sentinel.foo)
553+
554+
513555
def test_from_spec(self):
514556
class Something(object):
515557
x = 3
@@ -1376,6 +1418,13 @@ def test_mock_add_spec_magic_methods(self):
13761418
self.assertRaises(TypeError, lambda: mock['foo'])
13771419

13781420

1421+
def test_mock_add_spec_autospec_all_members(self):
1422+
for spec in [Something, Something()]:
1423+
mock_something = Mock()
1424+
mock_something.mock_add_spec(spec, autospec=True)
1425+
self._check_autospeced_something(mock_something)
1426+
1427+
13791428
def test_adding_child_mock(self):
13801429
for Klass in NonCallableMock, Mock, MagicMock, NonCallableMagicMock:
13811430
mock = Klass()
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)