Skip to content

Commit

Permalink
feat(verify): add proper assertion messages (#7)
Browse files Browse the repository at this point in the history
Closes #4
  • Loading branch information
mcous authored Dec 4, 2020
1 parent 3b68fe7 commit c1d89b2
Show file tree
Hide file tree
Showing 7 changed files with 405 additions and 267 deletions.
24 changes: 22 additions & 2 deletions decoy/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Decoy test double stubbing and verification library."""
from typing import cast, Any, Optional, Type
from os import linesep
from typing import cast, Any, Optional, Sequence, Type

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

assert rehearsal in self._registry.get_calls_by_spy_id(rehearsal.spy_id)
assert rehearsal in all_calls, self._build_verify_error(rehearsal, all_calls)

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

return None

def _build_verify_error(
self, rehearsal: SpyCall, all_calls: Sequence[SpyCall]
) -> str:
all_calls_len = len(all_calls)
all_calls_plural = all_calls_len != 1
all_calls_printout = linesep.join(
[f"{n + 1}.\t{str(all_calls[n])}" for n in range(all_calls_len)]
)

return linesep.join(
[
"Expected call:",
f"\t{str(rehearsal)}",
f"Found {all_calls_len} call{'s' if all_calls_plural else ''}:",
all_calls_printout,
]
)
4 changes: 2 additions & 2 deletions decoy/matchers.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ def __eq__(self, target: object) -> bool:

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


def IsNot(value: object) -> Any:
Expand Down Expand Up @@ -138,7 +138,7 @@ def __eq__(self, target: object) -> bool:

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


def StringMatching(match: str) -> str:
Expand Down
36 changes: 29 additions & 7 deletions decoy/spy.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from __future__ import annotations
from dataclasses import dataclass
from inspect import isclass, iscoroutinefunction
from typing import get_type_hints, Any, Callable, Dict, Optional, Tuple
from typing import get_type_hints, Any, Callable, Dict, Optional, Tuple, Type


@dataclass(frozen=True)
Expand All @@ -20,9 +20,21 @@ class SpyCall:
"""

spy_id: int
spy_name: str
args: Tuple[Any, ...]
kwargs: Dict[str, Any]

def __str__(self) -> str:
"""Stringify the call to something human readable.
`SpyCall(spy_id=42, spy_name="name", args=(1,), kwargs={"foo": False})`
would stringify as `"name(1, foo=False)"`
"""
args_list = [repr(arg) for arg in self.args]
kwargs_list = [f"{key}={repr(val)}" for key, val in self.kwargs.items()]

return f"{self.spy_name}({', '.join(args_list + kwargs_list)})"


CallHandler = Callable[[SpyCall], Any]

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

def __init__(self, handle_call: CallHandler, spec: Optional[Any] = None) -> None:
def __init__(
self,
handle_call: CallHandler,
spec: Optional[Any] = None,
name: Optional[str] = None,
) -> None:
"""Initialize a BaseSpy from a call handler and an optional spec object."""
self._name = name or (spec.__name__ if spec is not None else "spy")
self._spec = spec
self._handle_call: CallHandler = handle_call
self._spy_children: Dict[str, BaseSpy] = {}
Expand Down Expand Up @@ -73,6 +91,7 @@ def __getattr__(self, name: str) -> Any:
spy = create_spy(
handle_call=self._handle_call,
spec=child_spec,
name=f"{self._name}.{name}",
)

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

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


class AsyncSpy(Spy):
class AsyncSpy(BaseSpy):
"""An object that records all async. calls made to itself and its children."""

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


def create_spy(
handle_call: CallHandler,
spec: Optional[Any] = None,
is_async: bool = False,
name: Optional[str] = None,
) -> Any:
"""Create a Spy from a spec.
Functions and classes passed to `spec` will be inspected (and have any type
annotations inspected) to ensure `AsyncSpy`'s are returned where necessary.
"""
_SpyCls: Type[BaseSpy] = Spy

if iscoroutinefunction(spec) or is_async is True:
return AsyncSpy(handle_call)
_SpyCls = AsyncSpy

return Spy(handle_call=handle_call, spec=spec)
return _SpyCls(handle_call=handle_call, spec=spec, name=name)
Loading

0 comments on commit c1d89b2

Please sign in to comment.