Skip to content

Commit

Permalink
fix: replace unittest.mock usage with custom spy (#3)
Browse files Browse the repository at this point in the history
MagicMock behavior in creation and usage didn't end up fitting cleanly into the Decoy API,
especially with asynchronous fakes and fakes that involve several layers of classes. This commit
replaces MagicMock with a very similar Spy class, that basically takes exactly what Decoy needs from
unittest.mock.
  • Loading branch information
mcous authored Nov 30, 2020
1 parent a736d8e commit d1b742d
Show file tree
Hide file tree
Showing 19 changed files with 763 additions and 408 deletions.
40 changes: 20 additions & 20 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ def decoy() -> Decoy:
return Decoy()
```

Why is this important? The `Decoy` container tracks every test double that is created during a test so that you can define assertions using fully-typed rehearsals of your test double. It's important to wipe this slate clean for every test so you don't leak memory or have any state preservation between tests.
Why is this important? The `Decoy` container tracks every fake that is created during a test so that you can define assertions using fully-typed rehearsals of your test double. It's important to wipe this slate clean for every test so you don't leak memory or have any state preservation between tests.

[pytest]: https://docs.pytest.org/

Expand Down Expand Up @@ -115,16 +115,16 @@ from decoy import Decoy, verify
from .logger import Logger

def log_warning(msg: str, logger: Logger) -> None:
logger.warn(msg)
logger.warn(msg)

def test_log_warning(decoy: Decoy):
logger = decoy.create_decoy(spec=Logger)
logger = decoy.create_decoy(spec=Logger)

# call code under test
some_result = log_warning("oh no!", logger)
# call code under test
some_result = log_warning("oh no!", logger)

# verify double called correctly
decoy.verify(logger.warn("oh no!"))
# verify double called correctly
decoy.verify(logger.warn("oh no!"))
```

### Matchers
Expand All @@ -141,19 +141,19 @@ from decoy import Decoy, matchers
from .logger import Logger

def log_warning(msg: str, logger: Logger) -> None:
logger.warn(msg)
logger.warn(msg)

def test_log_warning(decoy: Decoy):
logger = decoy.create_decoy(spec=Logger)

# call code under test
some_result = log_warning(
"Oh no, something horrible went wrong with request ID abc123efg456",
logger=logger
)

# verify double called correctly
decoy.verify(
mock_logger.warn(matchers.StringMatching("something went wrong"))
)
logger = decoy.create_decoy(spec=Logger)

# call code under test
some_result = log_warning(
"Oh no, something horrible went wrong with request ID abc123efg456",
logger=logger
)

# verify double called correctly
decoy.verify(
mock_logger.warn(matchers.StringMatching("something went wrong"))
)
```
105 changes: 51 additions & 54 deletions decoy/__init__.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,16 @@
"""Decoy test double stubbing and verification library."""
from typing import cast, Any, Callable, Mapping, Optional, Sequence, Tuple, Type
from typing import cast, Any, Optional, Type

from .mock import create_decoy_mock, DecoyMock
from .registry import Registry
from .spy import create_spy, SpyCall
from .stub import Stub
from .types import Call, ClassT, FuncT, ReturnT
from .types import ClassT, FuncT, ReturnT


class Decoy:
"""Decoy test double state container."""

_registry: Registry
_last_decoy_id: Optional[int]

def __init__(self) -> None:
"""Initialize the state container for test doubles and stubs.
Expand All @@ -29,17 +28,18 @@ def decoy() -> Decoy:
```
"""
self._registry = Registry()
self._last_decoy_id = None

def create_decoy(self, spec: Type[ClassT], *, is_async: bool = False) -> ClassT:
"""Create a class decoy for `spec`.
Arguments:
spec: A class definition that the decoy should mirror.
is_async: Set to `True` if the class has `await`able methods.
is_async: Force the returned spy to be asynchronous. In most cases,
this argument is unnecessary, since the Spy will use `spec` to
determine if a method should be asynchronous.
Returns:
A `MagicMock` or `AsyncMock`, typecast as an instance of `spec`.
A spy typecast as an instance of `spec`.
Example:
```python
Expand All @@ -49,8 +49,12 @@ def test_get_something(decoy: Decoy):
```
"""
decoy = self._create_and_register_mock(spec=spec, is_async=is_async)
return cast(ClassT, decoy)
spy = create_spy(
spec=spec, is_async=is_async, handle_call=self._handle_spy_call
)
self._registry.register_spy(spy)

return cast(ClassT, spy)

def create_decoy_func(
self, spec: Optional[FuncT] = None, *, is_async: bool = False
Expand All @@ -59,10 +63,12 @@ def create_decoy_func(
Arguments:
spec: A function that the decoy should mirror.
is_async: Set to `True` if the function is `await`able.
is_async: Force the returned spy to be asynchronous. In most cases,
this argument is unnecessary, since the Spy will use `spec` to
determine if the function should be asynchronous.
Returns:
A `MagicMock` or `AsyncMock`, typecast as the function given for `spec`.
A spy typecast as `spec` function.
Example:
```python
Expand All @@ -71,9 +77,12 @@ def test_create_something(decoy: Decoy):
# ...
```
"""
decoy = self._create_and_register_mock(spec=spec, is_async=is_async)
spy = create_spy(
spec=spec, is_async=is_async, handle_call=self._handle_spy_call
)
self._registry.register_spy(spy)

return cast(FuncT, decoy)
return cast(FuncT, spy)

def when(self, _rehearsal_result: ReturnT) -> Stub[ReturnT]:
"""Create a [Stub][decoy.stub.Stub] configuration using a rehearsal call.
Expand All @@ -84,18 +93,24 @@ def when(self, _rehearsal_result: ReturnT) -> Stub[ReturnT]:
_rehearsal_result: The return value of a rehearsal, used for typechecking.
Returns:
A Stub to configure using `then_return` or `then_raise`.
A stub to configure using `then_return` or `then_raise`.
Example:
```python
db = decoy.create_decoy(spec=Database)
decoy.when(db.exists("some-id")).then_return(True)
```
Note:
The "rehearsal" is an actual call to the test fake. The fact that
the call is written inside `when` is purely for typechecking and
API sugar. Decoy will pop the last call to _any_ fake off its
call stack, which will end up being the call inside `when`.
"""
decoy_id, rehearsal = self._pop_last_rehearsal()
rehearsal = self._pop_last_rehearsal()
stub = Stub[ReturnT](rehearsal=rehearsal)

self._registry.register_stub(decoy_id, stub)
self._registry.register_stub(rehearsal.spy_id, stub)

return stub

Expand All @@ -116,50 +131,32 @@ def test_create_something(decoy: Decoy):
decoy.verify(gen_id("model-prefix_"))
```
"""
decoy_id, rehearsal = self._pop_last_rehearsal()
decoy = self._registry.get_decoy(decoy_id)

if decoy is None:
raise ValueError("verify must be called with a decoy rehearsal")

decoy.assert_has_calls([rehearsal])
def _create_and_register_mock(self, spec: Any, is_async: bool) -> DecoyMock:
decoy = create_decoy_mock(is_async=is_async, spec=spec)
decoy_id = self._registry.register_decoy(decoy)
side_effect = self._create_track_call_and_act(decoy_id)

decoy.configure_mock(side_effect=side_effect)

return decoy

def _pop_last_rehearsal(self) -> Tuple[int, Call]:
decoy_id = self._last_decoy_id
Note:
The "rehearsal" is an actual call to the test fake. The fact that
the call is written inside `verify` is purely for typechecking and
API sugar. Decoy will pop the last call to _any_ fake off its
call stack, which will end up being the call inside `verify`.
"""
rehearsal = self._pop_last_rehearsal()

if decoy_id is not None:
rehearsal = self._registry.pop_decoy_last_call(decoy_id)
self._last_decoy_id = None
assert rehearsal in self._registry.get_calls_by_spy_id(rehearsal.spy_id)

if rehearsal is not None:
return (decoy_id, rehearsal)
def _pop_last_rehearsal(self) -> SpyCall:
rehearsal = self._registry.pop_last_call()

raise ValueError("when/verify must be called with a decoy rehearsal")
if rehearsal is None:
raise ValueError("when/verify must be called with a decoy rehearsal")

def _create_track_call_and_act(self, decoy_id: int) -> Callable[..., Any]:
def track_call_and_act(
*args: Sequence[Any], **_kwargs: Mapping[str, Any]
) -> Any:
self._last_decoy_id = decoy_id
return rehearsal

last_call = self._registry.peek_decoy_last_call(decoy_id)
stubs = reversed(self._registry.get_decoy_stubs(decoy_id))
def _handle_spy_call(self, call: SpyCall) -> Any:
self._registry.register_call(call)

if last_call is not None:
for stub in stubs:
if stub._rehearsal == last_call:
return stub._act()
stubs = self._registry.get_stubs_by_spy_id(call.spy_id)

return None
for stub in reversed(stubs):
if stub._rehearsal == call:
return stub._act()

return track_call_and_act
return None
27 changes: 26 additions & 1 deletion decoy/matchers.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,29 @@
"""Matcher helpers."""
"""Matcher helpers.
A "matcher" is a helper class with an `__eq__` method defined. Use them
anywhere in your test where you would use an actual value for equality
(`==`) comparision.
Matchers help you loosen assertions where strict adherence to an exact value
is not relevent to what you're trying to test.
Example:
```python
from decoy import Decoy, matchers
# ...
def test_logger_called(decoy: Decoy):
# ...
decoy.verify(
logger.log(msg=matchers.StringMatching("hello"))
)
```
Note:
Identity comparisons (`is`) will not work with matchers. Decoy only uses
equality comparisons for stubbing and verification.
"""
from re import compile as compile_re
from typing import cast, Any, Optional, Pattern, Type

Expand Down
29 changes: 0 additions & 29 deletions decoy/mock.py

This file was deleted.

Loading

0 comments on commit d1b742d

Please sign in to comment.