Skip to content

Commit 3f8c8ff

Browse files
committed
bpo-32092: Consume self when autospeccing unbound instance methods
Currently, when patching methods with autospec, their self arguments are not consumed, causing call asserts to fail (they expect an instance reference as the first argument). Example: ``` from unittest import mock class Something(object): def foo(self, a): pass patcher = mock.patch.object(Something, 'foo', autospec=True) patcher.start() s = Something() s.foo("foo") s.foo.assert_called_once_with("foo") ``` The assertion above will fail, expecting an instance as a first argument. This issue **only** occurs in this scenario, and **not** in any of the following scenarios: - `autospec=False` - if the autospecced method is a `classmethod` or a `staticmethod` - if an instance is patched instead of a class - if `mock.create_autospec(Something)` is used instead - if `mock.Mock(autospec=Something)` is used instead This issue affects all patch variants. self is now consumed by the generated wrapper and excluded from call recording and signature checking, in both the sync and async paths. wraps and side_effect still get `self` bound for the call if needed, since they need to proxy through to a potentially unbound callable (e.g.: `wraps=getattr(cls, name)`. Adds test coverage across instance methods, classmethods, staticmethods, sync and async, for both wraps and side_effect, including a recursive-wraps regression test. Signed-off-by: Claudiu Belu <cbelu@cloudbasesolutions.com>
1 parent f429fb3 commit 3f8c8ff

5 files changed

Lines changed: 403 additions & 27 deletions

File tree

Lib/test/test_unittest/testmock/testasync.py

Lines changed: 144 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,10 @@
99
support.requires_working_socket(module=True)
1010

1111
from asyncio import run
12+
from test.test_unittest.testmock.testmock import SomethingAsync
1213
from unittest import IsolatedAsyncioTestCase
1314
from unittest.mock import (ANY, call, AsyncMock, patch, MagicMock, Mock,
14-
create_autospec, sentinel, _CallList, seal)
15+
create_autospec, sentinel, _CallList, seal, DEFAULT)
1516

1617

1718
def tearDownModule():
@@ -298,6 +299,148 @@ async def test_async():
298299

299300
run(test_async())
300301

302+
# gh-76273 / bpo-32092
303+
def _check_autospeced_something(self, something):
304+
self._check_autospeced_something_method(something.meth)
305+
self._check_autospeced_something_method(something.cmeth)
306+
self._check_autospeced_something_method(something.smeth)
307+
308+
def _check_autospeced_something_method(self, mock_method):
309+
# check that the methods are callable with correct args.
310+
asyncio.run(mock_method(sentinel.a, sentinel.b, sentinel.c))
311+
asyncio.run(mock_method(sentinel.a, sentinel.b, sentinel.c,
312+
d=sentinel.d))
313+
mock_method.assert_has_calls([
314+
call(sentinel.a, sentinel.b, sentinel.c),
315+
call(sentinel.a, sentinel.b, sentinel.c, d=sentinel.d)])
316+
317+
# assert that TypeError is raised if the method signature is not
318+
# respected.
319+
self._assert_call_raises_typeerror(mock_method)
320+
self._assert_call_raises_typeerror(mock_method, sentinel.a)
321+
self._assert_call_raises_typeerror(mock_method, a=sentinel.a)
322+
self._assert_call_raises_typeerror(
323+
mock_method, sentinel.a, sentinel.b, sentinel.c, e=sentinel.e)
324+
325+
def _assert_call_raises_typeerror(self, mock_method, *args, **kwargs):
326+
# classmethod / staticmethod autospecs check the signature
327+
# synchronously at call time (they go through _check_signature,
328+
# not _set_async_signature); plain instance methods only check it
329+
# once the returned coroutine is awaited, since the check runs
330+
# inside the coroutine body. Accept either.
331+
try:
332+
awaitable = mock_method(*args, **kwargs)
333+
except TypeError:
334+
return
335+
336+
self.assertRaises(TypeError, asyncio.run, awaitable)
337+
338+
def test_patch_autospec_obj(self):
339+
something = SomethingAsync()
340+
with patch.multiple(something, meth=DEFAULT, cmeth=DEFAULT,
341+
smeth=DEFAULT, autospec=True):
342+
self._check_autospeced_something(something)
343+
344+
def test_patch_autospec_obj_wraps(self):
345+
something = SomethingAsync()
346+
with (
347+
patch.object(something, "meth", autospec=True, wraps=something.meth),
348+
patch.object(something, "cmeth", autospec=True, wraps=something.cmeth),
349+
patch.object(something, "smeth", autospec=True, wraps=something.smeth),
350+
):
351+
self._check_autospeced_something(something)
352+
353+
@patch.object(SomethingAsync, 'smeth', autospec=True)
354+
@patch.object(SomethingAsync, 'cmeth', autospec=True)
355+
@patch.object(SomethingAsync, 'meth', autospec=True)
356+
def test_patch_autospec_class(self, mock_meth, mock_cmeth, mock_smeth):
357+
something = SomethingAsync()
358+
self._check_autospeced_something(something)
359+
360+
@patch.object(SomethingAsync, 'smeth', autospec=True,
361+
wraps=SomethingAsync.smeth)
362+
@patch.object(SomethingAsync, 'cmeth', autospec=True,
363+
wraps=SomethingAsync.cmeth)
364+
@patch.object(SomethingAsync, 'meth', autospec=True,
365+
wraps=SomethingAsync.meth)
366+
def test_patch_autospec_class_wraps(self, mock_meth, mock_cmeth,
367+
mock_smeth):
368+
something = SomethingAsync()
369+
self._check_autospeced_something(something)
370+
371+
def test_patch_autospec_obj_side_effect(self):
372+
for method in ["meth", "cmeth", "smeth"]:
373+
seen = []
374+
def side_effect(a, b, c, d=None):
375+
seen.append((a, b, c, d))
376+
377+
async def test_async():
378+
something = SomethingAsync()
379+
with patch.object(something, method,
380+
autospec=True) as mock_method:
381+
mock_method.side_effect = side_effect
382+
383+
await getattr(something, method)(
384+
sentinel.a, sentinel.b, sentinel.c)
385+
386+
self.assertEqual(
387+
seen, [(sentinel.a, sentinel.b, sentinel.c, None)])
388+
mock_method.assert_called_once_with(
389+
sentinel.a, sentinel.b, sentinel.c)
390+
mock_method.assert_awaited_once_with(
391+
sentinel.a, sentinel.b, sentinel.c)
392+
393+
run(test_async())
394+
395+
def test_patch_autospec_class_side_effect(self):
396+
for method in ["cmeth", "smeth"]:
397+
seen = []
398+
async def side_effect(a, b, c, d=None):
399+
seen.append((a, b, c, d))
400+
401+
async def test_async():
402+
with patch.object(SomethingAsync, method,
403+
autospec=True) as mock_method:
404+
mock_method.side_effect = side_effect
405+
something = SomethingAsync()
406+
407+
await getattr(something, method)(
408+
sentinel.a, sentinel.b, sentinel.c)
409+
410+
expected = (sentinel.a, sentinel.b, sentinel.c, None)
411+
self.assertEqual(seen, [expected])
412+
mock_method.assert_called_once_with(
413+
sentinel.a, sentinel.b, sentinel.c)
414+
mock_method.assert_awaited_once_with(
415+
sentinel.a, sentinel.b, sentinel.c)
416+
417+
run(test_async())
418+
419+
# `meth` is a plain instance method patched via the class; it will
420+
# goes through the self-consuming funcopy path: `side_effect` gets
421+
# `self` rebound onto it, just like `wraps` does. This doesn't apply
422+
# to classmethods.
423+
seen = []
424+
async def self_side_effect(self, a, b, c, d=None):
425+
seen.append((self, a, b, c, d))
426+
427+
async def test_async():
428+
with patch.object(SomethingAsync, "meth", autospec=True) as mock_method:
429+
mock_method.side_effect = self_side_effect
430+
something = SomethingAsync()
431+
432+
await something.meth(sentinel.a, sentinel.b, sentinel.c)
433+
434+
expected = (something, sentinel.a, sentinel.b, sentinel.c, None)
435+
self.assertEqual(seen, [expected])
436+
mock_method.assert_called_once_with(
437+
sentinel.a, sentinel.b, sentinel.c)
438+
mock_method.assert_awaited_once_with(
439+
sentinel.a, sentinel.b, sentinel.c)
440+
441+
run(test_async())
442+
443+
301444

302445
class AsyncSpecTest(unittest.TestCase):
303446
def test_spec_normal_methods_on_class(self):

Lib/test/test_unittest/testmock/testmock.py

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,16 @@ def cmeth(cls, a, b, c, d=None): pass
3838
def smeth(a, b, c, d=None): pass
3939

4040

41+
class SomethingAsync(object):
42+
async def meth(self, a, b, c, d=None): pass
43+
44+
@classmethod
45+
async def cmeth(cls, a, b, c, d=None): pass
46+
47+
@staticmethod
48+
async def smeth(a, b, c, d=None): pass
49+
50+
4151
class SomethingElse(object):
4252
def __init__(self):
4353
self._instance = None
@@ -2196,9 +2206,9 @@ def test_attach_mock_patch_autospec_signature(self):
21962206
manager.attach_mock(mocked, 'attach_meth')
21972207
obj = Something()
21982208
obj.meth(1, 2, 3, d=4)
2199-
manager.assert_has_calls([call.attach_meth(mock.ANY, 1, 2, 3, d=4)])
2200-
obj.meth.assert_has_calls([call(mock.ANY, 1, 2, 3, d=4)])
2201-
mocked.assert_has_calls([call(mock.ANY, 1, 2, 3, d=4)])
2209+
manager.assert_has_calls([call.attach_meth(1, 2, 3, d=4)])
2210+
obj.meth.assert_has_calls([call(1, 2, 3, d=4)])
2211+
mocked.assert_has_calls([call(1, 2, 3, d=4)])
22022212

22032213
with mock.patch(f'{__name__}.something', autospec=True) as mocked:
22042214
manager = Mock()

Lib/test/test_unittest/testmock/testpatch.py

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import test
1111
from test.test_unittest.testmock import support
1212
from test.test_unittest.testmock.support import SomeClass, is_instance
13+
from test.test_unittest.testmock.testmock import Something
1314

1415
from test.support.import_helper import DirsOnSysPath
1516
from test.test_importlib.util import uncache
@@ -1075,6 +1076,155 @@ def class_method(cls, a, b=10, *, c): pass
10751076
self.assertRaises(TypeError, method, 1)
10761077
self.assertRaises(TypeError, method, 1, 2, 3, c=4)
10771078

1079+
# gh-76273 / bpo-32092
1080+
def _check_autospeced_something(self, something):
1081+
self._check_autospeced_something_meth(something.meth)
1082+
self._check_autospeced_something_meth(something.cmeth)
1083+
self._check_autospeced_something_meth(something.smeth)
1084+
1085+
1086+
def _check_autospeced_something_meth(self, mock_method):
1087+
# check that the methods are callable with correct args.
1088+
mock_method(sentinel.a, sentinel.b, sentinel.c)
1089+
mock_method(sentinel.a, sentinel.b, sentinel.c, d=sentinel.d)
1090+
mock_method.assert_has_calls([
1091+
call(sentinel.a, sentinel.b, sentinel.c),
1092+
call(sentinel.a, sentinel.b, sentinel.c, d=sentinel.d)])
1093+
1094+
# assert that TypeError is raised if the method signature is not
1095+
# respected.
1096+
self.assertRaises(TypeError, mock_method)
1097+
self.assertRaises(TypeError, mock_method, sentinel.a)
1098+
self.assertRaises(TypeError, mock_method, a=sentinel.a)
1099+
self.assertRaises(TypeError, mock_method, sentinel.a, sentinel.b,
1100+
sentinel.c, e=sentinel.e)
1101+
1102+
1103+
def test_patch_autospec_obj(self):
1104+
something = Something()
1105+
with patch.multiple(something, meth=DEFAULT, cmeth=DEFAULT,
1106+
smeth=DEFAULT, autospec=True):
1107+
self._check_autospeced_something(something)
1108+
1109+
1110+
def test_patch_autospec_obj_wraps(self):
1111+
something = Something()
1112+
with (
1113+
patch.object(something, "meth", autospec=True, wraps=something.meth),
1114+
patch.object(something, "cmeth", autospec=True, wraps=something.cmeth),
1115+
patch.object(something, "smeth", autospec=True, wraps=something.smeth),
1116+
):
1117+
self._check_autospeced_something(something)
1118+
1119+
1120+
@patch.object(Something, 'smeth', autospec=True)
1121+
@patch.object(Something, 'cmeth', autospec=True)
1122+
@patch.object(Something, 'meth', autospec=True)
1123+
def test_patch_autospec_class(self, mock_meth, mock_cmeth, mock_smeth):
1124+
# patching a single instance method with autospec should not require
1125+
# `self` to be passed to assertion methods (bpo-32092)
1126+
something = Something()
1127+
self._check_autospeced_something(something)
1128+
1129+
1130+
@patch.object(Something, 'smeth', autospec=True, wraps=Something.smeth)
1131+
@patch.object(Something, 'cmeth', autospec=True, wraps=Something.cmeth)
1132+
@patch.object(Something, 'meth', autospec=True, wraps=Something.meth)
1133+
def test_patch_autospec_class_wraps(self, mock_meth, mock_cmeth,
1134+
mock_smeth):
1135+
something = Something()
1136+
self._check_autospeced_something(something)
1137+
1138+
1139+
def test_patch_autospec_obj_side_effect(self):
1140+
for method in ["meth", "cmeth", "smeth"]:
1141+
seen = []
1142+
def side_effect(a, b, c, d=None):
1143+
seen.append((a, b, c, d))
1144+
1145+
something = Something()
1146+
with patch.object(something, method, autospec=True) as mock_method:
1147+
mock_method.side_effect = side_effect
1148+
1149+
getattr(something, method)(sentinel.a, sentinel.b, sentinel.c)
1150+
1151+
self.assertEqual(seen, [(sentinel.a, sentinel.b, sentinel.c, None)])
1152+
mock_method.assert_called_once_with(sentinel.a, sentinel.b,
1153+
sentinel.c)
1154+
1155+
1156+
def test_patch_autospec_class_side_effect(self):
1157+
for method in ["cmeth", "smeth"]:
1158+
seen = []
1159+
def side_effect(a, b, c, d=None):
1160+
seen.append((a, b, c, d))
1161+
1162+
with patch.object(Something, method, autospec=True) as mock_method:
1163+
mock_method.side_effect = side_effect
1164+
something = Something()
1165+
1166+
getattr(something, method)(sentinel.a, sentinel.b, sentinel.c)
1167+
1168+
expected = (sentinel.a, sentinel.b, sentinel.c, None)
1169+
self.assertEqual(seen, [expected])
1170+
mock_method.assert_called_once_with(sentinel.a, sentinel.b,
1171+
sentinel.c)
1172+
1173+
# `meth` is a plain instance method patched via the class; it will
1174+
# goes through the self-consuming funcopy path: `side_effect` gets
1175+
# `self` rebound onto it, just like `wraps` does. This doesn't apply
1176+
# to classmethods.
1177+
seen = []
1178+
def self_side_effect(self, a, b, c, d=None):
1179+
seen.append((self, a, b, c, d))
1180+
1181+
with patch.object(Something, "meth", autospec=True) as mock_method:
1182+
mock_method.side_effect = self_side_effect
1183+
something = Something()
1184+
1185+
something.meth(sentinel.a, sentinel.b, sentinel.c)
1186+
1187+
expected = (something, sentinel.a, sentinel.b, sentinel.c, None)
1188+
self.assertEqual(seen, [expected])
1189+
mock_method.assert_called_once_with(sentinel.a, sentinel.b, sentinel.c)
1190+
1191+
1192+
def test_autospec_wraps_getattr_idiom(self):
1193+
# mirrors test_hmac.py's watch_method() helper exactly.
1194+
class Foo:
1195+
def f(self, a, b):
1196+
return ('real', a, b)
1197+
1198+
def watch(cls, name):
1199+
wraps = getattr(cls, name)
1200+
return patch.object(cls, name, autospec=True, wraps=wraps)
1201+
1202+
with watch(Foo, 'f') as method:
1203+
foo = Foo()
1204+
self.assertEqual(foo.f(1, 2), ('real', 1, 2))
1205+
method.assert_called_once_with(1, 2)
1206+
1207+
1208+
def test_autospec_wraps_recursive_call(self):
1209+
# a wrapped method that calls itself (through the mock) should not
1210+
# corrupt call recording or the `self` rebound onto `wraps`.
1211+
class Foo:
1212+
def f(self, n): pass
1213+
1214+
def unbound_f(self, n):
1215+
if n <= 0:
1216+
return 0
1217+
return n + self.f(n - 1)
1218+
1219+
with patch.object(Foo, 'f', autospec=True, wraps=unbound_f) as method:
1220+
foo = Foo()
1221+
self.assertEqual(foo.f(3), 6)
1222+
self.assertEqual(method.call_count, 4)
1223+
self.assertEqual(
1224+
method.call_args_list,
1225+
[call(3), call(2), call(1), call(0)],
1226+
)
1227+
10781228

10791229
def test_autospec_with_new(self):
10801230
patcher = patch('%s.function' % __name__, new=3, autospec=True)

0 commit comments

Comments
 (0)