Skip to content

Commit 633159f

Browse files
authored
feat(warnings): add RedundantVerifyWarning (#36)
Closes #34
1 parent 0b12237 commit 633159f

24 files changed

+751
-396
lines changed

decoy/call_handler.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33

44
from .call_stack import CallStack
55
from .stub_store import StubStore
6-
from .spy import SpyCall
6+
from .spy_calls import SpyCall
77

88

99
class CallHandler:

decoy/call_stack.py

Lines changed: 20 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"""Spy creation and storage."""
22
from typing import List, Sequence
33

4-
from .spy import SpyCall, SpyRehearsal
4+
from .spy_calls import BaseSpyCall, SpyCall, WhenRehearsal, VerifyRehearsal
55
from .errors import MissingRehearsalError
66

77

@@ -10,43 +10,52 @@ class CallStack:
1010

1111
def __init__(self) -> None:
1212
"""Initialize a stack for all SpyCalls."""
13-
self._stack: List[SpyCall] = []
13+
self._stack: List[BaseSpyCall] = []
1414

1515
def push(self, spy_call: SpyCall) -> None:
1616
"""Add a new spy call to the stack."""
1717
self._stack.append(spy_call)
1818

19-
def consume_rehearsal(self) -> SpyRehearsal:
20-
"""Consume the last call to a Spy as a rehearsal.
19+
def consume_when_rehearsal(self) -> WhenRehearsal:
20+
"""Consume the last call to a Spy as a `when` rehearsal.
2121
2222
This marks a call as a rehearsal but does not remove it from the stack.
2323
"""
24-
return self.consume_rehearsals(count=1)[0]
24+
try:
25+
call = self._stack[-1]
26+
except KeyError:
27+
raise MissingRehearsalError()
28+
if not isinstance(call, SpyCall):
29+
raise MissingRehearsalError()
30+
31+
rehearsal = WhenRehearsal(*call)
32+
self._stack[-1] = rehearsal
33+
return rehearsal
2534

26-
def consume_rehearsals(self, count: int) -> List[SpyRehearsal]:
35+
def consume_verify_rehearsals(self, count: int) -> List[VerifyRehearsal]:
2736
"""Consume the last `count` calls to Spies as rehearsals.
2837
2938
This marks calls as rehearsals but does not remove them from the stack.
3039
"""
3140
calls = self._stack[-count:]
3241

33-
if len(calls) != count or any(isinstance(call, SpyRehearsal) for call in calls):
42+
if len(calls) != count or not all(isinstance(call, SpyCall) for call in calls):
3443
raise MissingRehearsalError()
3544

36-
rehearsals = [SpyRehearsal(*call) for call in calls]
45+
rehearsals = [VerifyRehearsal(*call) for call in calls]
3746
self._stack[-count:] = rehearsals
3847
return rehearsals
3948

40-
def get_by_rehearsals(self, rehearsals: Sequence[SpyRehearsal]) -> List[SpyCall]:
49+
def get_by_rehearsals(self, rehearsals: Sequence[VerifyRehearsal]) -> List[SpyCall]:
4150
"""Get a list of all non-rehearsal calls to the given Spy IDs."""
4251
return [
4352
call
4453
for call in self._stack
45-
if not isinstance(call, SpyRehearsal)
54+
if isinstance(call, SpyCall)
4655
and any(rehearsal == call for rehearsal in rehearsals)
4756
]
4857

49-
def get_all(self) -> List[SpyCall]:
58+
def get_all(self) -> List[BaseSpyCall]:
5059
"""Get a list of all calls and rehearsals made."""
5160
return list(self._stack)
5261

decoy/core.py

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,13 @@
22
from __future__ import annotations
33
from typing import Any, Optional
44

5+
from .spy import SpyConfig, SpyFactory, create_spy as default_create_spy
6+
from .spy_calls import WhenRehearsal
57
from .call_stack import CallStack
68
from .stub_store import StubStore, StubBehavior
7-
from .spy import SpyConfig, SpyFactory, SpyRehearsal, create_spy as default_create_spy
89
from .call_handler import CallHandler
910
from .verifier import Verifier
11+
from .warning_checker import WarningChecker
1012
from .types import ReturnT
1113

1214

@@ -17,13 +19,15 @@ def __init__(
1719
self,
1820
create_spy: Optional[SpyFactory] = None,
1921
verifier: Optional[Verifier] = None,
22+
warning_checker: Optional[WarningChecker] = None,
2023
stub_store: Optional[StubStore] = None,
2124
call_stack: Optional[CallStack] = None,
2225
call_handler: Optional[CallHandler] = None,
2326
) -> None:
2427
"""Initialize the DecoyCore with its dependencies."""
2528
self._create_spy = create_spy or default_create_spy
2629
self._verifier = verifier or Verifier()
30+
self._warning_checker = warning_checker or WarningChecker()
2731
self._stub_store = stub_store or StubStore()
2832
self._call_stack = call_stack or CallStack()
2933
self._call_hander = call_handler or CallHandler(
@@ -42,28 +46,28 @@ def mock(self, *, spec: Optional[Any] = None, is_async: bool = False) -> Any:
4246

4347
def when(self, _rehearsal: ReturnT) -> StubCore:
4448
"""Create a new stub from the last spy rehearsal."""
45-
rehearsal = self._call_stack.consume_rehearsal()
49+
rehearsal = self._call_stack.consume_when_rehearsal()
4650
return StubCore(rehearsal=rehearsal, stub_store=self._stub_store)
4751

4852
def verify(self, *_rehearsals: ReturnT, times: Optional[int] = None) -> None:
4953
"""Verify that a Spy or Spies were called."""
50-
rehearsals = self._call_stack.consume_rehearsals(count=len(_rehearsals))
54+
rehearsals = self._call_stack.consume_verify_rehearsals(count=len(_rehearsals))
5155
calls = self._call_stack.get_by_rehearsals(rehearsals)
5256

5357
self._verifier.verify(rehearsals=rehearsals, calls=calls, times=times)
5458

5559
def reset(self) -> None:
5660
"""Reset and remove all stored spies and stubs."""
5761
calls = self._call_stack.get_all()
58-
self._verifier.verify_no_miscalled_stubs(calls)
62+
self._warning_checker.check(calls)
5963
self._call_stack.clear()
6064
self._stub_store.clear()
6165

6266

6367
class StubCore:
6468
"""The StubCore class implements the main logic of a Decoy Stub."""
6569

66-
def __init__(self, rehearsal: SpyRehearsal, stub_store: StubStore) -> None:
70+
def __init__(self, rehearsal: WhenRehearsal, stub_store: StubStore) -> None:
6771
"""Initialize the Stub with a configuration."""
6872
self._rehearsal = rehearsal
6973
self._stub_store = stub_store

decoy/errors.py

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,25 @@
1-
"""Error value objects."""
1+
"""Errors raised by Decoy.
2+
3+
See the [errors guide][] for more details.
4+
5+
[errors guide]: ../usage/errors-and-warnings/#errors
6+
"""
27
from typing import Optional, Sequence
38

4-
from .spy import SpyCall, SpyRehearsal
9+
from .spy_calls import SpyCall, VerifyRehearsal
510
from .stringify import stringify_error_message, count
611

712

813
class MissingRehearsalError(ValueError):
914
"""An error raised when `when` or `verify` is called without rehearsal(s).
1015
11-
This error can also be triggered if you forget to include `await` with
12-
the rehearsal of an asynchronous mock.
16+
This error is raised if you use Decoy incorrectly in your tests. When
17+
using async/await, this error can be triggered if you forget to include
18+
`await` with your rehearsal.
19+
20+
See the [MissingRehearsalError guide][] for more details.
21+
22+
[MissingRehearsalError guide]: ../usage/errors-and-warnings/#missingrehearsalerror
1323
"""
1424

1525
def __init__(self) -> None:
@@ -19,19 +29,23 @@ def __init__(self) -> None:
1929
class VerifyError(AssertionError):
2030
"""An error raised when actual calls do not match rehearsals given to `verify`.
2131
32+
See [spying with verify][] for more details.
33+
34+
[spying with verify]: ../usage/verify/
35+
2236
Attributes:
2337
rehearsals: Rehearsals that were being verified.
2438
calls: Actual calls to the mock(s).
2539
times: The expected number of calls to the mock, if any.
2640
"""
2741

28-
rehearsals: Sequence[SpyRehearsal]
42+
rehearsals: Sequence[VerifyRehearsal]
2943
calls: Sequence[SpyCall]
3044
times: Optional[int]
3145

3246
def __init__(
3347
self,
34-
rehearsals: Sequence[SpyRehearsal],
48+
rehearsals: Sequence[VerifyRehearsal],
3549
calls: Sequence[SpyCall],
3650
times: Optional[int],
3751
) -> None:

decoy/spy.py

Lines changed: 2 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -6,29 +6,9 @@
66
from __future__ import annotations
77
from inspect import isclass, iscoroutinefunction, isfunction, signature
88
from functools import partial
9-
from typing import get_type_hints, Any, Callable, Dict, NamedTuple, Optional, Tuple
9+
from typing import get_type_hints, Any, Callable, Dict, NamedTuple, Optional
1010

11-
12-
class SpyCall(NamedTuple):
13-
"""A value object representing a call to a spy.
14-
15-
Attributes:
16-
spy_id: Identifier of the spy that made the call.
17-
spy_name: String name of the spy.
18-
args: Arguments list of the call.
19-
kwargs: Keyword arguments list of the call.
20-
"""
21-
22-
spy_id: int
23-
spy_name: str
24-
args: Tuple[Any, ...]
25-
kwargs: Dict[str, Any]
26-
27-
28-
class SpyRehearsal(SpyCall):
29-
"""A SpyCall that has been used as a rehearsal."""
30-
31-
pass
11+
from .spy_calls import SpyCall
3212

3313

3414
CallHandler = Callable[[SpyCall], Any]

decoy/spy_calls.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
"""Spy call value objects."""
2+
from typing import Any, Dict, NamedTuple, Tuple
3+
4+
5+
class BaseSpyCall(NamedTuple):
6+
"""A value object representing a call to a spy.
7+
8+
This base class should not be used directly.
9+
10+
Attributes:
11+
spy_id: Identifier of the spy that made the call.
12+
spy_name: String name of the spy.
13+
args: Arguments list of the call.
14+
kwargs: Keyword arguments list of the call.
15+
"""
16+
17+
spy_id: int
18+
spy_name: str
19+
args: Tuple[Any, ...]
20+
kwargs: Dict[str, Any]
21+
22+
23+
class BaseSpyRehearsal(BaseSpyCall):
24+
"""A base class for rehearsals made to `when` or `verify`.
25+
26+
This base class should not be used directly.
27+
"""
28+
29+
pass
30+
31+
32+
class SpyCall(BaseSpyCall):
33+
"""An call made to the spy by the code under test."""
34+
35+
pass
36+
37+
38+
class WhenRehearsal(BaseSpyRehearsal):
39+
"""A call that has been used as a rehearsal for `when`."""
40+
41+
pass
42+
43+
44+
class VerifyRehearsal(BaseSpyRehearsal):
45+
"""A call that has been used as a rehearsal for `when`."""
46+
47+
pass

decoy/stringify.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
"""Message string generation."""
22
import os
33
from typing import Sequence
4-
from .spy import SpyCall, SpyRehearsal
54

5+
from .spy_calls import BaseSpyCall, BaseSpyRehearsal, SpyCall
66

7-
def stringify_call(call: SpyCall) -> str:
7+
8+
def stringify_call(call: BaseSpyCall) -> str:
89
"""Stringify the call to something human readable.
910
1011
`SpyCall(spy_id=42, spy_name="name", args=(1,), kwargs={"foo": False})`
@@ -16,7 +17,7 @@ def stringify_call(call: SpyCall) -> str:
1617
return f"{call.spy_name}({', '.join(args_list + kwargs_list)})"
1718

1819

19-
def stringify_call_list(calls: Sequence[SpyCall]) -> str:
20+
def stringify_call_list(calls: Sequence[BaseSpyCall]) -> str:
2021
"""Stringify a sequence of calls into an ordered list."""
2122
return os.linesep.join(
2223
f"{i + 1}.\t{stringify_call(call)}" for i, call in enumerate(calls)
@@ -30,7 +31,7 @@ def count(count: int, noun: str) -> str:
3031

3132
def stringify_error_message(
3233
heading: str,
33-
rehearsals: Sequence[SpyRehearsal],
34+
rehearsals: Sequence[BaseSpyRehearsal],
3435
calls: Sequence[SpyCall],
3536
include_calls: bool = True,
3637
) -> str:

decoy/stub_store.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"""Stub creation and storage."""
22
from typing import Any, List, NamedTuple, Optional
33

4-
from .spy import SpyCall, SpyRehearsal
4+
from .spy_calls import SpyCall, WhenRehearsal
55

66

77
class StubBehavior(NamedTuple):
@@ -15,7 +15,7 @@ class StubBehavior(NamedTuple):
1515
class StubEntry(NamedTuple):
1616
"""An entry in the StubStore for later behavior lookup."""
1717

18-
rehearsal: SpyRehearsal
18+
rehearsal: WhenRehearsal
1919
behavior: StubBehavior
2020

2121

@@ -26,7 +26,7 @@ def __init__(self) -> None:
2626
"""Initialize a StubStore with an empty stubbings list."""
2727
self._stubs: List[StubEntry] = []
2828

29-
def add(self, rehearsal: SpyRehearsal, behavior: StubBehavior) -> None:
29+
def add(self, rehearsal: WhenRehearsal, behavior: StubBehavior) -> None:
3030
"""Create and add a new StubBehavior to the store."""
3131
self._stubs.append(StubEntry(rehearsal=rehearsal, behavior=behavior))
3232

0 commit comments

Comments
 (0)