Skip to content

Commit c1d89b2

Browse files
authored
feat(verify): add proper assertion messages (#7)
Closes #4
1 parent 3b68fe7 commit c1d89b2

File tree

7 files changed

+405
-267
lines changed

7 files changed

+405
-267
lines changed

decoy/__init__.py

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""Decoy test double stubbing and verification library."""
2-
from typing import cast, Any, Optional, Type
2+
from os import linesep
3+
from typing import cast, Any, Optional, Sequence, Type
34

45
from .registry import Registry
56
from .spy import create_spy, SpyCall
@@ -139,8 +140,9 @@ def test_create_something(decoy: Decoy):
139140
call stack, which will end up being the call inside `verify`.
140141
"""
141142
rehearsal = self._pop_last_rehearsal()
143+
all_calls = self._registry.get_calls_by_spy_id(rehearsal.spy_id)
142144

143-
assert rehearsal in self._registry.get_calls_by_spy_id(rehearsal.spy_id)
145+
assert rehearsal in all_calls, self._build_verify_error(rehearsal, all_calls)
144146

145147
def _pop_last_rehearsal(self) -> SpyCall:
146148
rehearsal = self._registry.pop_last_call()
@@ -160,3 +162,21 @@ def _handle_spy_call(self, call: SpyCall) -> Any:
160162
return stub._act()
161163

162164
return None
165+
166+
def _build_verify_error(
167+
self, rehearsal: SpyCall, all_calls: Sequence[SpyCall]
168+
) -> str:
169+
all_calls_len = len(all_calls)
170+
all_calls_plural = all_calls_len != 1
171+
all_calls_printout = linesep.join(
172+
[f"{n + 1}.\t{str(all_calls[n])}" for n in range(all_calls_len)]
173+
)
174+
175+
return linesep.join(
176+
[
177+
"Expected call:",
178+
f"\t{str(rehearsal)}",
179+
f"Found {all_calls_len} call{'s' if all_calls_plural else ''}:",
180+
all_calls_printout,
181+
]
182+
)

decoy/matchers.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ def __eq__(self, target: object) -> bool:
104104

105105
def __repr__(self) -> str:
106106
"""Return a string representation of the matcher."""
107-
return f"<IsNot {self._reject_value}>"
107+
return f"<IsNot {repr(self._reject_value)}>"
108108

109109

110110
def IsNot(value: object) -> Any:
@@ -138,7 +138,7 @@ def __eq__(self, target: object) -> bool:
138138

139139
def __repr__(self) -> str:
140140
"""Return a string representation of the matcher."""
141-
return f"<StringMatching {self._pattern.pattern}>"
141+
return f"<StringMatching {repr(self._pattern.pattern)}>"
142142

143143

144144
def StringMatching(match: str) -> str:

decoy/spy.py

Lines changed: 29 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from __future__ import annotations
77
from dataclasses import dataclass
88
from inspect import isclass, iscoroutinefunction
9-
from typing import get_type_hints, Any, Callable, Dict, Optional, Tuple
9+
from typing import get_type_hints, Any, Callable, Dict, Optional, Tuple, Type
1010

1111

1212
@dataclass(frozen=True)
@@ -20,9 +20,21 @@ class SpyCall:
2020
"""
2121

2222
spy_id: int
23+
spy_name: str
2324
args: Tuple[Any, ...]
2425
kwargs: Dict[str, Any]
2526

27+
def __str__(self) -> str:
28+
"""Stringify the call to something human readable.
29+
30+
`SpyCall(spy_id=42, spy_name="name", args=(1,), kwargs={"foo": False})`
31+
would stringify as `"name(1, foo=False)"`
32+
"""
33+
args_list = [repr(arg) for arg in self.args]
34+
kwargs_list = [f"{key}={repr(val)}" for key, val in self.kwargs.items()]
35+
36+
return f"{self.spy_name}({', '.join(args_list + kwargs_list)})"
37+
2638

2739
CallHandler = Callable[[SpyCall], Any]
2840

@@ -34,8 +46,14 @@ class BaseSpy:
3446
- Lazily constructs child spies when an attribute is accessed
3547
"""
3648

37-
def __init__(self, handle_call: CallHandler, spec: Optional[Any] = None) -> None:
49+
def __init__(
50+
self,
51+
handle_call: CallHandler,
52+
spec: Optional[Any] = None,
53+
name: Optional[str] = None,
54+
) -> None:
3855
"""Initialize a BaseSpy from a call handler and an optional spec object."""
56+
self._name = name or (spec.__name__ if spec is not None else "spy")
3957
self._spec = spec
4058
self._handle_call: CallHandler = handle_call
4159
self._spy_children: Dict[str, BaseSpy] = {}
@@ -73,6 +91,7 @@ def __getattr__(self, name: str) -> Any:
7391
spy = create_spy(
7492
handle_call=self._handle_call,
7593
spec=child_spec,
94+
name=f"{self._name}.{name}",
7695
)
7796

7897
self._spy_children[name] = spy
@@ -85,28 +104,31 @@ class Spy(BaseSpy):
85104

86105
def __call__(self, *args: Any, **kwargs: Any) -> Any:
87106
"""Handle a call to the spy."""
88-
return self._handle_call(SpyCall(id(self), args, kwargs))
107+
return self._handle_call(SpyCall(id(self), self._name, args, kwargs))
89108

90109

91-
class AsyncSpy(Spy):
110+
class AsyncSpy(BaseSpy):
92111
"""An object that records all async. calls made to itself and its children."""
93112

94113
async def __call__(self, *args: Any, **kwargs: Any) -> Any:
95114
"""Handle a call to the spy asynchronously."""
96-
return self._handle_call(SpyCall(id(self), args, kwargs))
115+
return self._handle_call(SpyCall(id(self), self._name, args, kwargs))
97116

98117

99118
def create_spy(
100119
handle_call: CallHandler,
101120
spec: Optional[Any] = None,
102121
is_async: bool = False,
122+
name: Optional[str] = None,
103123
) -> Any:
104124
"""Create a Spy from a spec.
105125
106126
Functions and classes passed to `spec` will be inspected (and have any type
107127
annotations inspected) to ensure `AsyncSpy`'s are returned where necessary.
108128
"""
129+
_SpyCls: Type[BaseSpy] = Spy
130+
109131
if iscoroutinefunction(spec) or is_async is True:
110-
return AsyncSpy(handle_call)
132+
_SpyCls = AsyncSpy
111133

112-
return Spy(handle_call=handle_call, spec=spec)
134+
return _SpyCls(handle_call=handle_call, spec=spec, name=name)

0 commit comments

Comments
 (0)