Skip to content

Commit 5fd4e6d

Browse files
authored
feat(when): warn if stub is called with args that do not match (#15)
Closes #14
1 parent ae2827e commit 5fd4e6d

File tree

7 files changed

+149
-6
lines changed

7 files changed

+149
-6
lines changed

decoy/__init__.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,33 @@
11
"""Decoy test double stubbing and verification library."""
22
from os import linesep
33
from typing import cast, Any, Optional, Sequence, Type
4+
from warnings import warn
45

56
from .registry import Registry
67
from .spy import create_spy, SpyCall
78
from .stub import Stub
89
from .types import ClassT, FuncT, ReturnT
10+
from .warnings import MissingStubWarning
911

1012

1113
class Decoy:
1214
"""Decoy test double state container."""
1315

1416
_registry: Registry
17+
_warn_on_missing_stubs: bool
1518

16-
def __init__(self) -> None:
19+
def __init__(
20+
self,
21+
warn_on_missing_stubs: bool = True,
22+
) -> None:
1723
"""Initialize the state container for test doubles and stubs.
1824
1925
You should initialize a new Decoy instance for every test.
2026
27+
Arguments:
28+
warn_on_missing_stubs: Trigger a warning if a stub is called
29+
with arguments that do not match any of its rehearsals.
30+
2131
Example:
2232
```python
2333
import pytest
@@ -29,6 +39,7 @@ def decoy() -> Decoy:
2939
```
3040
"""
3141
self._registry = Registry()
42+
self._warn_on_missing_stubs = warn_on_missing_stubs
3243

3344
def create_decoy(self, spec: Type[ClassT], *, is_async: bool = False) -> ClassT:
3445
"""Create a class decoy for `spec`.
@@ -178,6 +189,9 @@ def _handle_spy_call(self, call: SpyCall) -> Any:
178189
if stub._rehearsal == call:
179190
return stub._act()
180191

192+
if self._warn_on_missing_stubs and len(stubs) > 0:
193+
warn(MissingStubWarning(call, stubs))
194+
181195
return None
182196

183197
def _build_verify_error(

decoy/registry.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ def get_stubs_by_spy_id(self, spy_id: int) -> List[Stub[Any]]:
4040
"""Get a spy's stub list by identifier.
4141
4242
Arguments:
43-
spy_id: The unique identifer of the Spy to look up.
43+
spy_id: The unique identifier of the Spy to look up.
4444
4545
Returns:
4646
The list of stubs matching the given Spy.
@@ -51,7 +51,7 @@ def get_calls_by_spy_id(self, *spy_id: int) -> List[SpyCall]:
5151
"""Get a spy's call list by identifier.
5252
5353
Arguments:
54-
spy_id: The unique identifer of the Spy to look up.
54+
spy_id: The unique identifier of the Spy to look up.
5555
5656
Returns:
5757
The list of calls matching the given Spy.
@@ -83,7 +83,7 @@ def register_stub(self, spy_id: int, stub: Stub[Any]) -> None:
8383
"""Register a stub for tracking.
8484
8585
Arguments:
86-
spy_id: The unique identifer of the Spy to look up.
86+
spy_id: The unique identifier of the Spy to look up.
8787
stub: The stub to track.
8888
"""
8989
stub_list = self.get_stubs_by_spy_id(spy_id)

decoy/warnings.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
"""Warnings produced by Decoy."""
2+
from os import linesep
3+
from typing import Any, Sequence
4+
5+
from .spy import SpyCall
6+
from .stub import Stub
7+
8+
9+
class MissingStubWarning(UserWarning):
10+
"""A warning raised when a configured stub is called with different arguments."""
11+
12+
def __init__(self, call: SpyCall, stubs: Sequence[Stub[Any]]) -> None:
13+
"""Initialize the warning message with the actual and expected calls."""
14+
stubs_len = len(stubs)
15+
stubs_plural = stubs_len != 1
16+
stubs_printout = linesep.join(
17+
[f"{n + 1}.\t{str(stubs[n]._rehearsal)}" for n in range(stubs_len)]
18+
)
19+
20+
message = linesep.join(
21+
[
22+
"Stub was called but no matching rehearsal found.",
23+
f"Found {stubs_len} rehearsal{'s' if stubs_plural else ''}:",
24+
stubs_printout,
25+
"Actual call:",
26+
f"\t{str(call)}",
27+
]
28+
)
29+
30+
super().__init__(message)

docs/api.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,5 @@
55
::: decoy.stub.Stub
66

77
::: decoy.matchers
8+
9+
::: decoy.warnings

tests/conftest.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,5 +5,18 @@
55

66
@pytest.fixture
77
def decoy() -> Decoy:
8-
"""Get a new instance of the Decoy state container."""
8+
"""Get a new instance of the Decoy state container.
9+
10+
Warnings are disabled for more quiet tests.
11+
"""
12+
return Decoy(warn_on_missing_stubs=False)
13+
14+
15+
@pytest.fixture
16+
def strict_decoy() -> Decoy:
17+
"""Get a new instance of the Decoy state container.
18+
19+
Warnings are left in the default enabled state. Use this fixture
20+
to test warning behavior.
21+
"""
922
return Decoy()

tests/test_warnings.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
"""Tests for warning messages."""
2+
from os import linesep
3+
from typing import Any, List
4+
5+
from decoy.spy import SpyCall
6+
from decoy.stub import Stub
7+
from decoy.warnings import MissingStubWarning
8+
9+
10+
def test_no_stubbing_found_warning() -> None:
11+
"""It should print a helpful error message if a call misses a stub."""
12+
call = SpyCall(spy_id=123, spy_name="spy", args=(1, 2), kwargs={"foo": "bar"})
13+
stub: Stub[Any] = Stub(
14+
rehearsal=SpyCall(
15+
spy_id=123,
16+
spy_name="spy",
17+
args=(3, 4),
18+
kwargs={"baz": "qux"},
19+
)
20+
)
21+
22+
result = MissingStubWarning(call=call, stubs=[stub])
23+
24+
assert str(result) == (
25+
f"Stub was called but no matching rehearsal found.{linesep}"
26+
f"Found 1 rehearsal:{linesep}"
27+
f"1.\tspy(3, 4, baz='qux'){linesep}"
28+
f"Actual call:{linesep}"
29+
"\tspy(1, 2, foo='bar')"
30+
)
31+
32+
33+
def test_no_stubbing_found_warning_plural() -> None:
34+
"""It should print a helpful message if a call misses multiple stubs."""
35+
call = SpyCall(spy_id=123, spy_name="spy", args=(1, 2), kwargs={"foo": "bar"})
36+
stubs: List[Stub[Any]] = [
37+
Stub(
38+
rehearsal=SpyCall(
39+
spy_id=123,
40+
spy_name="spy",
41+
args=(3, 4),
42+
kwargs={"baz": "qux"},
43+
)
44+
),
45+
Stub(
46+
rehearsal=SpyCall(
47+
spy_id=123,
48+
spy_name="spy",
49+
args=(5, 6),
50+
kwargs={"fizz": "buzz"},
51+
)
52+
),
53+
]
54+
55+
result = MissingStubWarning(call=call, stubs=stubs)
56+
57+
assert str(result) == (
58+
f"Stub was called but no matching rehearsal found.{linesep}"
59+
f"Found 2 rehearsals:{linesep}"
60+
f"1.\tspy(3, 4, baz='qux'){linesep}"
61+
f"2.\tspy(5, 6, fizz='buzz'){linesep}"
62+
f"Actual call:{linesep}"
63+
"\tspy(1, 2, foo='bar')"
64+
)

tests/test_when.py

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"""Tests for the Decoy double creator."""
22
import pytest
33

4-
from decoy import Decoy, matchers
4+
from decoy import Decoy, matchers, warnings
55
from .common import some_func, SomeClass, SomeAsyncClass, SomeNestedClass
66

77

@@ -159,3 +159,23 @@ def _async_child(self) -> SomeAsyncClass:
159159
decoy.when(await stub._async_child.foo("hello")).then_return("world")
160160

161161
assert await stub._async_child.foo("hello") == "world"
162+
163+
164+
def test_no_stubbing_found_warning(strict_decoy: Decoy) -> None:
165+
"""It should raise a warning if a stub is configured and then called incorrectly."""
166+
stub = strict_decoy.create_decoy_func(spec=some_func)
167+
168+
strict_decoy.when(stub("hello")).then_return("world")
169+
170+
with pytest.warns(warnings.MissingStubWarning):
171+
stub("h3110")
172+
173+
174+
@pytest.mark.filterwarnings("error::UserWarning")
175+
def test_no_stubbing_found_warnings_disabled(decoy: Decoy) -> None:
176+
"""It should not raise a warning if warn_on_missing_stub is disabled."""
177+
stub = decoy.create_decoy_func(spec=some_func)
178+
179+
decoy.when(stub("hello")).then_return("world")
180+
181+
stub("h3110")

0 commit comments

Comments
 (0)