Skip to content

Commit 4c4c70f

Browse files
committed
Teach unstub how to do partial unstubbing
E.g. support: ``` unstub(os.path.exists) unstub("os.path.exists") unstub(cat.meow) ```
1 parent c0bc58d commit 4c4c70f

8 files changed

Lines changed: 198 additions & 7 deletions

File tree

docs/walk-through.rst

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ When patching, you **MUST** **not** forget to :func:`unstub` of course! You can
5252
::
5353

5454
from mockito import unstub
55-
unstub() # restore os.path module
55+
unstub() # restore all patched/stubbed objects
5656

5757
Usually you do this unconditionally in your `teardown` function. If you're using `pytest`, you could define a fixture instead
5858

mockito/mock_registry.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -88,13 +88,14 @@ def mock_for(self, obj: object) -> Mock | None:
8888
def obj_for(self, mock: Mock) -> object | None:
8989
return self.mocks.lookup(mock)
9090

91-
def unstub(self, obj: object) -> None:
91+
def unstub(self, obj: object) -> bool:
9292
try:
9393
mock = self.mocks.pop(obj)
9494
except KeyError:
95-
pass
95+
return False
9696
else:
9797
mock.unstub()
98+
return True
9899

99100
def unstub_mock(self, mock: Mock) -> None:
100101
self.mocks.pop_value(mock)

mockito/mocking.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -540,6 +540,24 @@ def forget_stubbed_invocation(
540540

541541
mock_registry.unstub(self.mocked_obj)
542542

543+
def unstub_method(self, method_name: str) -> None:
544+
invocations = [
545+
invoc
546+
for invoc in self.stubbed_invocations
547+
if invoc.method_name == method_name
548+
]
549+
if not invocations:
550+
return
551+
552+
for invoc in invocations:
553+
invoc.forget_self()
554+
555+
self.invocations = [
556+
invocation
557+
for invocation in self.invocations
558+
if invocation.method_name != method_name
559+
]
560+
543561
def unstub(self) -> None:
544562
while self._methods_to_unstub:
545563
_, patch = self._methods_to_unstub.popitem()

mockito/mockito.py

Lines changed: 53 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -448,6 +448,15 @@ def unstub(*objs):
448448
If you don't pass in any argument, *all* registered mocks and
449449
patched modules, classes etc. will be unstubbed.
450450
451+
You can also unstub a single method/function target, e.g.::
452+
453+
unstub(os.path.exists)
454+
unstub("os.path.exists")
455+
unstub(cat.meow)
456+
457+
In these cases only that one attribute is restored, while other stubs on
458+
the same object stay active.
459+
451460
Note that additionally, the underlying registry will be cleaned.
452461
After an `unstub` you can't :func:`verify` anymore because all
453462
interactions will be forgotten.
@@ -457,13 +466,55 @@ def unstub(*objs):
457466
for obj in objs:
458467
if isinstance(obj, str):
459468
obj = get_obj(obj)
460-
mock_registry.unstub(obj)
461-
patcher.unstub_matching(obj)
469+
470+
# mock_registry.unstub(obj)
471+
# patcher.unstub_matching(obj)
472+
if (
473+
mock_registry.unstub(obj)
474+
or patcher.unstub_matching(obj)
475+
):
476+
return
477+
478+
resolved_target = _resolve_unstub_attr_target(obj)
479+
if resolved_target is None:
480+
continue
481+
482+
host, attr_name = resolved_target
483+
host_mock = mock_registry.mock_for(host)
484+
if host_mock is not None:
485+
host_mock.unstub_method(attr_name)
462486
else:
463487
mock_registry.unstub_all()
464488
patcher.unstub_all()
465489

466490

491+
def _resolve_unstub_attr_target(target):
492+
if not callable(target):
493+
return None
494+
495+
host = getattr(target, "__self__", None)
496+
attr_name = getattr(target, "__name__", None)
497+
if host is not None and attr_name is not None:
498+
return host, attr_name
499+
500+
target_function = _unwrap_unstub_target(target)
501+
for theMock in mock_registry.get_registered_mocks():
502+
for method_name, patch in theMock._methods_to_unstub.items():
503+
replacement = getattr(patch, "replacement", None)
504+
if _unwrap_unstub_target(replacement) is target_function:
505+
return theMock.mocked_obj, method_name
506+
507+
return None
508+
509+
510+
511+
def _unwrap_unstub_target(target):
512+
if isinstance(target, (staticmethod, classmethod)):
513+
return target.__func__
514+
515+
return getattr(target, "__func__", target)
516+
517+
467518
def forget_invocations(*objs):
468519
"""Forget all invocations of given objs.
469520

mockito/patching.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,14 +68,16 @@ def patch_dictionary(
6868
self._register_patch(dict_patch)
6969
return dict_patch
7070

71-
def unstub_matching(self, obj: object) -> None:
71+
def unstub_matching(self, obj: object) -> bool:
7272
matching = [
7373
patch for patch in self._patches
7474
if patch.matches_unstub_target(obj)
7575
]
7676
for patch in reversed(matching):
7777
patch.restore_and_unregister()
7878

79+
return bool(matching)
80+
7981
def unstub_all(self) -> None:
8082
for patch in reversed(self._patches.copy()):
8183
patch.restore_and_unregister()

tests/modulefunctions_test.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,24 @@ def testUnstubsByDottedPath(self):
4545

4646
self.assertEqual(False, os.path.exists("test"))
4747

48+
def testCanUnstubSingleFunctionByFunctionTarget(self):
49+
when(os.path).exists("test").thenReturn(True)
50+
when(os.path).dirname(any(str)).thenReturn("mocked")
51+
52+
unstub(os.path.exists)
53+
54+
self.assertEqual(False, os.path.exists("test"))
55+
self.assertEqual("mocked", os.path.dirname("/tmp/file.txt"))
56+
57+
def testCanUnstubSingleFunctionByDottedFunctionPath(self):
58+
when(os.path).exists("test").thenReturn(True)
59+
when(os.path).dirname(any(str)).thenReturn("mocked")
60+
61+
unstub("os.path.exists")
62+
63+
self.assertEqual(False, os.path.exists("test"))
64+
self.assertEqual("mocked", os.path.dirname("/tmp/file.txt"))
65+
4866
def testStubs(self):
4967
when(os.path).exists("test").thenReturn(True)
5068

tests/staticmethods_test.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,15 @@ def testUnstubs(self):
5050
unstub()
5151
self.assertEqual("woof", Dog.bark())
5252

53+
def testCanUnstubSingleStaticmethodByFunctionTarget(self):
54+
when(Dog).bark().thenReturn("miau")
55+
when(Dog).barkHardly(1, 2).thenReturn("arf")
56+
57+
unstub(Dog.bark)
58+
59+
self.assertEqual("woof", Dog.bark())
60+
self.assertEqual("arf", Dog.barkHardly(1, 2))
61+
5362
# TODO decent test case please :) without testing irrelevant implementation
5463
# details
5564
def testUnstubShouldPreserveMethodType(self):

tests/unstub_test.py

Lines changed: 93 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,14 @@
11
import pytest
22

3-
from mockito import mock, when, unstub, verify, ArgumentError
3+
from mockito import (
4+
ArgumentError,
5+
ensureNoUnverifiedInteractions,
6+
mock,
7+
patch_attr,
8+
unstub,
9+
verify,
10+
when,
11+
)
412

513

614
class Dog(object):
@@ -11,6 +19,10 @@ def bark(self, sound='Wuff'):
1119
return sound
1220

1321

22+
class AttrHolder(object):
23+
value = None
24+
25+
1426
class TestUntub:
1527
def testIndependentUnstubbing(self):
1628
rex = Dog()
@@ -38,6 +50,86 @@ def testUnconfigureMock(self):
3850
unstub(m)
3951
assert m.foo() is None
4052

53+
def testPartialUnstubByMethodReference(self):
54+
cat = mock(strict=True)
55+
56+
when(cat).meow().thenReturn('Miau')
57+
when(cat).runs().thenReturn('Yip')
58+
59+
unstub(cat.meow)
60+
61+
with pytest.raises(AttributeError):
62+
cat.meow()
63+
64+
assert cat.runs() == 'Yip'
65+
66+
def testPartialUnstubByMethodReferenceKeepsDetachedChainAlive(self):
67+
cat = mock(strict=True)
68+
69+
when(cat).meow().purr().sleep().thenReturn("ok")
70+
child = cat.meow()
71+
grand = child.purr()
72+
73+
unstub(cat.meow)
74+
75+
with pytest.raises(AttributeError):
76+
cat.meow()
77+
78+
assert grand.sleep() == "ok"
79+
80+
def testPartialUnstubByMethodReferenceForgetsMethodInvocations(self):
81+
cat = mock()
82+
83+
when(cat).meow().thenReturn("Miau")
84+
when(cat).runs().thenReturn("Yip")
85+
86+
cat.meow()
87+
cat.runs()
88+
verify(cat).runs()
89+
90+
unstub(cat.meow)
91+
92+
ensureNoUnverifiedInteractions(cat)
93+
94+
def testPartialUnstubByMethodReferenceDoesNotRestoreMatchingPatchAttr(self):
95+
cat = mock(strict=True)
96+
holder = AttrHolder()
97+
98+
when(cat).meow().thenReturn("Miau")
99+
replacement = cat.meow
100+
patch_attr(holder, "value", replacement)
101+
102+
assert holder.value is replacement
103+
104+
unstub(cat.meow)
105+
106+
assert holder.value is replacement
107+
with pytest.raises(AttributeError):
108+
cat.meow()
109+
110+
@pytest.mark.xfail(
111+
strict=False,
112+
reason=(
113+
"Characterization only: detached bound-method aliases currently "
114+
"prefer patch_attr replacement matching before method-level unstub "
115+
"resolution. This may change."
116+
),
117+
)
118+
def testCurrentBehaviorMethodAliasCanUnpatchWithoutUnstubbingMethod(self):
119+
cat = mock(strict=True)
120+
holder = AttrHolder()
121+
122+
when(cat).meow().thenReturn("Miau")
123+
replacement = cat.meow
124+
patch_attr(holder, "value", replacement)
125+
126+
assert holder.value is replacement
127+
128+
unstub(replacement)
129+
130+
assert holder.value is None
131+
assert cat.meow() == "Miau"
132+
41133

42134
class TestContextManagerUnstubStrategy:
43135

0 commit comments

Comments
 (0)