Skip to content

Commit 82818be

Browse files
committed
Fail fast on captor identity clashes across chain root branches
When a chain branch resolves to an existing sameish root but binds different captor instances, stubbing now raises an InvocationError with guidance to reuse the same captor object. This avoids silent branch shadowing where one leaf becomes unreachable while still preserving strict behavior. Tests were expanded to cover sameish semantics for typed and untyped captor wrappers, as well as rejection of distinct typed *captor(any_(int)) chain roots.
1 parent 3d0ffe6 commit 82818be

File tree

4 files changed

+143
-14
lines changed

4 files changed

+143
-14
lines changed

mockito/invocation.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727
from collections import deque
2828
from typing import TYPE_CHECKING, Union
2929

30-
from . import matchers, signature
30+
from . import matchers, sameish, signature
3131
from . import verification as verificationModule
3232
from .mock_registry import mock_registry
3333
from .utils import contains_strict
@@ -628,6 +628,21 @@ def transition_to_chain(self) -> ChainContinuation:
628628
continuation = self.get_continuation()
629629

630630
if isinstance(continuation, ChainContinuation):
631+
if (
632+
continuation.invocation is not self
633+
and sameish.invocations_have_distinct_captors(
634+
self,
635+
continuation.invocation,
636+
)
637+
):
638+
self.forget_self()
639+
raise InvocationError(
640+
"'%s' is already configured with a different captor "
641+
"instance for the same selector. Reuse the same "
642+
"captor() / call_captor() object across chain branches."
643+
% self.method_name
644+
)
645+
631646
self.rollback_if_not_configured_by(continuation)
632647
return continuation
633648

mockito/sameish.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,26 @@ def invocations_are_sameish(
2727
)
2828

2929

30+
def invocations_have_distinct_captors(
31+
left: StubbedInvocation,
32+
right: StubbedInvocation,
33+
) -> bool:
34+
"""Return True when equivalent selectors bind different captor instances."""
35+
36+
for left_value, right_value in zip(left.params, right.params):
37+
if _values_bind_distinct_captors(left_value, right_value):
38+
return True
39+
40+
for key in set(left.named_params) & set(right.named_params):
41+
if _values_bind_distinct_captors(
42+
left.named_params[key],
43+
right.named_params[key],
44+
):
45+
return True
46+
47+
return False
48+
49+
3050
def _params_are_sameish(left: tuple, right: tuple) -> bool:
3151
if len(left) != len(right):
3252
return False
@@ -141,6 +161,33 @@ def _matchers_are_sameish( # noqa: C901
141161
return _equals_or_identity(left, right)
142162

143163

164+
def _values_bind_distinct_captors(left: object, right: object) -> bool:
165+
left_binding = _captor_binding(left)
166+
right_binding = _captor_binding(right)
167+
168+
return (
169+
left_binding is not None
170+
and right_binding is not None
171+
and left_binding is not right_binding
172+
)
173+
174+
175+
def _captor_binding(value: object) -> object | None:
176+
if matchers.is_call_captor(value):
177+
return value
178+
179+
if isinstance(value, matchers.ArgumentCaptor):
180+
return value
181+
182+
if matchers.is_captor_args_sentinel(value):
183+
return value.captor
184+
185+
if matchers.is_captor_kwargs_sentinel(value):
186+
return value.captor
187+
188+
return None
189+
190+
144191
def _equals_or_identity(left: object, right: object) -> bool:
145192
try:
146193
return left == right

tests/chaining_test.py

Lines changed: 52 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -60,37 +60,76 @@ def test_multiple_chain_branches_with_same_arg_that_matcher_share_root():
6060
assert cat_that_meowed.roll() == "playful"
6161

6262

63-
def test_multiple_chain_branches_with_call_captor_roots_share_root():
63+
def test_multiple_chain_branches_with_same_call_captor_instance_share_root():
6464
cat = mock()
65+
call = call_captor()
6566

66-
when(cat).meow(call_captor()).purr().thenReturn("friendly")
67-
when(cat).meow(call_captor()).roll().thenReturn("playful")
67+
when(cat).meow(call).purr().thenReturn("friendly")
68+
when(cat).meow(call).roll().thenReturn("playful")
6869

6970
cat_that_meowed = cat.meow(1)
7071
assert cat_that_meowed.purr() == "friendly"
7172
assert cat_that_meowed.roll() == "playful"
7273

7374

74-
def test_multiple_chain_branches_with_args_captor_roots_share_root():
75+
def test_multiple_chain_branches_with_distinct_call_captor_roots_are_rejected():
76+
cat = mock()
77+
78+
when(cat).meow(call_captor()).purr().thenReturn("friendly")
79+
80+
with pytest.raises(InvocationError) as exc:
81+
when(cat).meow(call_captor()).roll().thenReturn("playful")
82+
83+
assert str(exc.value) == (
84+
"'meow' is already configured with a different captor instance for "
85+
"the same selector. Reuse the same captor() / call_captor() object "
86+
"across chain branches."
87+
)
88+
89+
90+
def test_multiple_chain_branches_with_distinct_args_captor_roots_are_rejected():
7591
cat = mock()
7692

7793
when(cat).meow(*captor()).purr().thenReturn("friendly")
78-
when(cat).meow(*captor()).roll().thenReturn("playful")
7994

80-
cat_that_meowed = cat.meow(1, 2)
81-
assert cat_that_meowed.purr() == "friendly"
82-
assert cat_that_meowed.roll() == "playful"
95+
with pytest.raises(InvocationError) as exc:
96+
when(cat).meow(*captor()).roll().thenReturn("playful")
97+
98+
assert str(exc.value) == (
99+
"'meow' is already configured with a different captor instance for "
100+
"the same selector. Reuse the same captor() / call_captor() object "
101+
"across chain branches."
102+
)
83103

84104

85-
def test_multiple_chain_branches_with_kwargs_captor_roots_share_root():
105+
def test_multiple_chain_branches_with_distinct_kwargs_captor_roots_are_rejected():
86106
cat = mock()
87107

88108
when(cat).meow(**captor()).purr().thenReturn("friendly")
89-
when(cat).meow(**captor()).roll().thenReturn("playful")
90109

91-
cat_that_meowed = cat.meow(volume=1)
92-
assert cat_that_meowed.purr() == "friendly"
93-
assert cat_that_meowed.roll() == "playful"
110+
with pytest.raises(InvocationError) as exc:
111+
when(cat).meow(**captor()).roll().thenReturn("playful")
112+
113+
assert str(exc.value) == (
114+
"'meow' is already configured with a different captor instance for "
115+
"the same selector. Reuse the same captor() / call_captor() object "
116+
"across chain branches."
117+
)
118+
119+
120+
def test_multiple_chain_branches_with_distinct_typed_args_captor_roots_are_rejected():
121+
cat = mock()
122+
123+
when(cat).meow(*captor(any_(int))).purr().thenReturn("friendly")
124+
125+
with pytest.raises(InvocationError) as exc:
126+
when(cat).meow(*captor(any_(int))).roll().thenReturn("playful")
127+
128+
assert str(exc.value) == (
129+
"'meow' is already configured with a different captor instance for "
130+
"the same selector. Reuse the same captor() / call_captor() object "
131+
"across chain branches."
132+
)
94133

95134

96135
def test_unstub_child_chain_then_reconfigure_does_not_leave_stale_root_stub():

tests/sameish_test.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,34 @@ def test_kwargs_argument_captor_instances_are_sameish_for_root_deduping():
164164
)
165165

166166

167+
def test_star_argument_captors_with_different_matchers_are_not_sameish():
168+
assert not sameish.invocations_are_sameish(
169+
bar(1, *captor(any_(int))),
170+
bar(1, *captor(any_(str))),
171+
)
172+
173+
174+
def test_kwargs_argument_captors_with_different_matchers_are_not_sameish():
175+
assert not sameish.invocations_are_sameish(
176+
bar(1, **captor(any_(int))),
177+
bar(1, **captor(any_(str))),
178+
)
179+
180+
181+
def test_star_argument_captor_any_and_typed_any_are_not_sameish():
182+
assert not sameish.invocations_are_sameish(
183+
bar(1, *captor()),
184+
bar(1, *captor(any_(int))),
185+
)
186+
187+
188+
def test_kwargs_argument_captor_any_and_typed_any_are_not_sameish():
189+
assert not sameish.invocations_are_sameish(
190+
bar(1, **captor()),
191+
bar(1, **captor(any_(int))),
192+
)
193+
194+
167195
def test_argument_captor_instances_with_different_matchers_are_not_sameish():
168196
assert not sameish.invocations_are_sameish(
169197
bar(captor(any_(int))),

0 commit comments

Comments
 (0)