Skip to content

Commit 59250bc

Browse files
committed
bpo-30587: Adds autospec argument for Mock / MagicMock / AsyncMock
Mock can accept a spec object / class as an 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 NonCallableMock and its mock_add_spec method. These changes are inherited by Mock, MagicMock, AsyncMock. 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 only if the given spec is used via autospec, so that passing plain spec/spec_set does not change existing signature-checking behavior. Adds unit tests to validate the fact that the autospecced method signatures are enforced. Signed-off-by: Claudiu Belu <cbelu@cloudbasesolutions.com>
1 parent f429fb3 commit 59250bc

3 files changed

Lines changed: 129 additions & 6 deletions

File tree

Lib/test/test_unittest/testmock/testmock.py

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import asyncio
12
import copy
23
import re
34
import sys
@@ -38,6 +39,16 @@ def cmeth(cls, a, b, c, d=None): pass
3839
def smeth(a, b, c, d=None): pass
3940

4041

42+
class SomethingAsync(object):
43+
async def meth(self, a, b, c, d=None): pass
44+
45+
@classmethod
46+
async def cmeth(cls, a, b, c, d=None): pass
47+
48+
@staticmethod
49+
async def smeth(a, b, c, d=None): pass
50+
51+
4152
class SomethingElse(object):
4253
def __init__(self):
4354
self._instance = None
@@ -695,6 +706,71 @@ def test_attributes(mock):
695706
test_attributes(Mock(spec=Something()))
696707

697708

709+
def _check_autospeced_something(self, something):
710+
# assert that AttributeError is raised if the method does not exist.
711+
self.assertRaises(AttributeError, getattr, something, 'foolish')
712+
713+
self._check_autospeced_something_method(something.meth)
714+
self._check_autospeced_something_method(something.cmeth)
715+
self._check_autospeced_something_method(something.smeth)
716+
717+
718+
def _check_autospeced_something_method(self, mock_method):
719+
# check that the methods are callable with correct args.
720+
mock_method(sentinel.a, sentinel.b, sentinel.c)
721+
mock_method(sentinel.a, sentinel.b, sentinel.c, d=sentinel.d)
722+
mock_method.assert_has_calls([
723+
call(sentinel.a, sentinel.b, sentinel.c),
724+
call(sentinel.a, sentinel.b, sentinel.c, d=sentinel.d)])
725+
726+
# assert that TypeError is raised if the method signature is not
727+
# respected.
728+
self.assertRaises(TypeError, mock_method)
729+
self.assertRaises(TypeError, mock_method, sentinel.a)
730+
self.assertRaises(TypeError, mock_method, a=sentinel.a)
731+
self.assertRaises(TypeError, mock_method, sentinel.a, sentinel.b,
732+
sentinel.c, e=sentinel.e)
733+
734+
735+
def test_mock_autospec_all_members(self):
736+
for spec in [Something, Something()]:
737+
mock_something = Mock(autospec=spec)
738+
self._check_autospeced_something(mock_something)
739+
740+
741+
def _check_autospeced_something_async(self, something):
742+
# assert that AttributeError is raised if the method does not exist.
743+
self.assertRaises(AttributeError, getattr, something, 'foolish')
744+
745+
self._check_autospeced_something_method_async(something.meth)
746+
self._check_autospeced_something_method_async(something.cmeth)
747+
self._check_autospeced_something_method_async(something.smeth)
748+
749+
750+
def _check_autospeced_something_method_async(self, mock_method):
751+
# check that the methods are callable with correct args.
752+
asyncio.run(mock_method(sentinel.a, sentinel.b, sentinel.c))
753+
asyncio.run(mock_method(sentinel.a, sentinel.b, sentinel.c,
754+
d=sentinel.d))
755+
mock_method.assert_has_calls([
756+
call(sentinel.a, sentinel.b, sentinel.c),
757+
call(sentinel.a, sentinel.b, sentinel.c, d=sentinel.d)])
758+
759+
# assert that TypeError is raised if the method signature is not
760+
# respected.
761+
self.assertRaises(TypeError, mock_method)
762+
self.assertRaises(TypeError, mock_method, sentinel.a)
763+
self.assertRaises(TypeError, mock_method, a=sentinel.a)
764+
self.assertRaises(TypeError, mock_method, sentinel.a, sentinel.b,
765+
sentinel.c, e=sentinel.e)
766+
767+
768+
def test_mock_autospec_all_members_async(self):
769+
for spec in [SomethingAsync, SomethingAsync()]:
770+
mock_something = AsyncMock(autospec=spec)
771+
self._check_autospeced_something_async(mock_something)
772+
773+
698774
def test_wraps_calls(self):
699775
real = Mock()
700776

@@ -1974,6 +2050,20 @@ def test_mock_add_spec_magic_methods(self):
19742050
self.assertRaises(TypeError, lambda: mock['foo'])
19752051

19762052

2053+
def test_mock_add_spec_autospec_all_members(self):
2054+
for spec in [Something, Something()]:
2055+
mock_something = Mock()
2056+
mock_something.mock_add_spec(spec, autospec=True)
2057+
self._check_autospeced_something(mock_something)
2058+
2059+
2060+
def test_mock_add_spec_autospec_all_members_async(self):
2061+
for spec in [SomethingAsync, SomethingAsync()]:
2062+
mock_something = Mock()
2063+
mock_something.mock_add_spec(spec, autospec=True)
2064+
self._check_autospeced_something_async(mock_something)
2065+
2066+
19772067
def test_adding_child_mock(self):
19782068
for Klass in (NonCallableMock, Mock, MagicMock, NonCallableMagicMock,
19792069
AsyncMock):

Lib/unittest/mock.py

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

139+
return func, sig
140+
139141

140142
def _copy_func_details(func, funcopy):
141143
# 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,8 +557,13 @@ 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)
560+
561+
if self.__dict__.get('_autospec') is None:
562+
res = _get_signature_object(spec, _spec_as_instance, _eat_self)
563+
else:
564+
res = _check_signature(spec, self, _eat_self,
565+
_spec_as_instance)
566+
548567
_spec_signature = res and res[1]
549568

550569
spec_list = dir(spec)
@@ -712,9 +731,20 @@ def __getattr__(self, name):
712731
# execution?
713732
wraps = getattr(self._mock_wraps, name)
714733

734+
kwargs = {}
735+
if self.__dict__.get('_autospec') is not None:
736+
# get the mock's spec attribute with the same name and
737+
# pass it to the child.
738+
spec_class = self.__dict__.get('_spec_class')
739+
spec = getattr(spec_class, name, None)
740+
is_type = isinstance(spec_class, type)
741+
eat_self = _must_skip(spec_class, name, is_type)
742+
kwargs['_eat_self'] = eat_self
743+
kwargs['autospec'] = spec
744+
715745
result = self._get_child_mock(
716746
parent=self, name=name, wraps=wraps, _new_name=name,
717-
_new_parent=self
747+
_new_parent=self, **kwargs
718748
)
719749
self._mock_children[name] = result
720750

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 Mock constructors and mock_add_spec.
2+
Passing the autospec argument will also check the method signatures of the mocked
3+
methods.

0 commit comments

Comments
 (0)