Skip to content

Commit d1b742d

Browse files
authored
fix: replace unittest.mock usage with custom spy (#3)
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.
1 parent a736d8e commit d1b742d

19 files changed

+763
-408
lines changed

README.md

Lines changed: 20 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ def decoy() -> Decoy:
5353
return Decoy()
5454
```
5555

56-
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.
56+
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.
5757

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

@@ -115,16 +115,16 @@ from decoy import Decoy, verify
115115
from .logger import Logger
116116

117117
def log_warning(msg: str, logger: Logger) -> None:
118-
logger.warn(msg)
118+
logger.warn(msg)
119119

120120
def test_log_warning(decoy: Decoy):
121-
logger = decoy.create_decoy(spec=Logger)
121+
logger = decoy.create_decoy(spec=Logger)
122122

123-
# call code under test
124-
some_result = log_warning("oh no!", logger)
123+
# call code under test
124+
some_result = log_warning("oh no!", logger)
125125

126-
# verify double called correctly
127-
decoy.verify(logger.warn("oh no!"))
126+
# verify double called correctly
127+
decoy.verify(logger.warn("oh no!"))
128128
```
129129

130130
### Matchers
@@ -141,19 +141,19 @@ from decoy import Decoy, matchers
141141
from .logger import Logger
142142

143143
def log_warning(msg: str, logger: Logger) -> None:
144-
logger.warn(msg)
144+
logger.warn(msg)
145145

146146
def test_log_warning(decoy: Decoy):
147-
logger = decoy.create_decoy(spec=Logger)
148-
149-
# call code under test
150-
some_result = log_warning(
151-
"Oh no, something horrible went wrong with request ID abc123efg456",
152-
logger=logger
153-
)
154-
155-
# verify double called correctly
156-
decoy.verify(
157-
mock_logger.warn(matchers.StringMatching("something went wrong"))
158-
)
147+
logger = decoy.create_decoy(spec=Logger)
148+
149+
# call code under test
150+
some_result = log_warning(
151+
"Oh no, something horrible went wrong with request ID abc123efg456",
152+
logger=logger
153+
)
154+
155+
# verify double called correctly
156+
decoy.verify(
157+
mock_logger.warn(matchers.StringMatching("something went wrong"))
158+
)
159159
```

decoy/__init__.py

Lines changed: 51 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,16 @@
11
"""Decoy test double stubbing and verification library."""
2-
from typing import cast, Any, Callable, Mapping, Optional, Sequence, Tuple, Type
2+
from typing import cast, Any, Optional, Type
33

4-
from .mock import create_decoy_mock, DecoyMock
54
from .registry import Registry
5+
from .spy import create_spy, SpyCall
66
from .stub import Stub
7-
from .types import Call, ClassT, FuncT, ReturnT
7+
from .types import ClassT, FuncT, ReturnT
88

99

1010
class Decoy:
1111
"""Decoy test double state container."""
1212

1313
_registry: Registry
14-
_last_decoy_id: Optional[int]
1514

1615
def __init__(self) -> None:
1716
"""Initialize the state container for test doubles and stubs.
@@ -29,17 +28,18 @@ def decoy() -> Decoy:
2928
```
3029
"""
3130
self._registry = Registry()
32-
self._last_decoy_id = None
3331

3432
def create_decoy(self, spec: Type[ClassT], *, is_async: bool = False) -> ClassT:
3533
"""Create a class decoy for `spec`.
3634
3735
Arguments:
3836
spec: A class definition that the decoy should mirror.
39-
is_async: Set to `True` if the class has `await`able methods.
37+
is_async: Force the returned spy to be asynchronous. In most cases,
38+
this argument is unnecessary, since the Spy will use `spec` to
39+
determine if a method should be asynchronous.
4040
4141
Returns:
42-
A `MagicMock` or `AsyncMock`, typecast as an instance of `spec`.
42+
A spy typecast as an instance of `spec`.
4343
4444
Example:
4545
```python
@@ -49,8 +49,12 @@ def test_get_something(decoy: Decoy):
4949
```
5050
5151
"""
52-
decoy = self._create_and_register_mock(spec=spec, is_async=is_async)
53-
return cast(ClassT, decoy)
52+
spy = create_spy(
53+
spec=spec, is_async=is_async, handle_call=self._handle_spy_call
54+
)
55+
self._registry.register_spy(spy)
56+
57+
return cast(ClassT, spy)
5458

5559
def create_decoy_func(
5660
self, spec: Optional[FuncT] = None, *, is_async: bool = False
@@ -59,10 +63,12 @@ def create_decoy_func(
5963
6064
Arguments:
6165
spec: A function that the decoy should mirror.
62-
is_async: Set to `True` if the function is `await`able.
66+
is_async: Force the returned spy to be asynchronous. In most cases,
67+
this argument is unnecessary, since the Spy will use `spec` to
68+
determine if the function should be asynchronous.
6369
6470
Returns:
65-
A `MagicMock` or `AsyncMock`, typecast as the function given for `spec`.
71+
A spy typecast as `spec` function.
6672
6773
Example:
6874
```python
@@ -71,9 +77,12 @@ def test_create_something(decoy: Decoy):
7177
# ...
7278
```
7379
"""
74-
decoy = self._create_and_register_mock(spec=spec, is_async=is_async)
80+
spy = create_spy(
81+
spec=spec, is_async=is_async, handle_call=self._handle_spy_call
82+
)
83+
self._registry.register_spy(spy)
7584

76-
return cast(FuncT, decoy)
85+
return cast(FuncT, spy)
7786

7887
def when(self, _rehearsal_result: ReturnT) -> Stub[ReturnT]:
7988
"""Create a [Stub][decoy.stub.Stub] configuration using a rehearsal call.
@@ -84,18 +93,24 @@ def when(self, _rehearsal_result: ReturnT) -> Stub[ReturnT]:
8493
_rehearsal_result: The return value of a rehearsal, used for typechecking.
8594
8695
Returns:
87-
A Stub to configure using `then_return` or `then_raise`.
96+
A stub to configure using `then_return` or `then_raise`.
8897
8998
Example:
9099
```python
91100
db = decoy.create_decoy(spec=Database)
92101
decoy.when(db.exists("some-id")).then_return(True)
93102
```
103+
104+
Note:
105+
The "rehearsal" is an actual call to the test fake. The fact that
106+
the call is written inside `when` is purely for typechecking and
107+
API sugar. Decoy will pop the last call to _any_ fake off its
108+
call stack, which will end up being the call inside `when`.
94109
"""
95-
decoy_id, rehearsal = self._pop_last_rehearsal()
110+
rehearsal = self._pop_last_rehearsal()
96111
stub = Stub[ReturnT](rehearsal=rehearsal)
97112

98-
self._registry.register_stub(decoy_id, stub)
113+
self._registry.register_stub(rehearsal.spy_id, stub)
99114

100115
return stub
101116

@@ -116,50 +131,32 @@ def test_create_something(decoy: Decoy):
116131
117132
decoy.verify(gen_id("model-prefix_"))
118133
```
119-
"""
120-
decoy_id, rehearsal = self._pop_last_rehearsal()
121-
decoy = self._registry.get_decoy(decoy_id)
122-
123-
if decoy is None:
124-
raise ValueError("verify must be called with a decoy rehearsal")
125-
126-
decoy.assert_has_calls([rehearsal])
127134
128-
def _create_and_register_mock(self, spec: Any, is_async: bool) -> DecoyMock:
129-
decoy = create_decoy_mock(is_async=is_async, spec=spec)
130-
decoy_id = self._registry.register_decoy(decoy)
131-
side_effect = self._create_track_call_and_act(decoy_id)
132-
133-
decoy.configure_mock(side_effect=side_effect)
134-
135-
return decoy
136-
137-
def _pop_last_rehearsal(self) -> Tuple[int, Call]:
138-
decoy_id = self._last_decoy_id
135+
Note:
136+
The "rehearsal" is an actual call to the test fake. The fact that
137+
the call is written inside `verify` is purely for typechecking and
138+
API sugar. Decoy will pop the last call to _any_ fake off its
139+
call stack, which will end up being the call inside `verify`.
140+
"""
141+
rehearsal = self._pop_last_rehearsal()
139142

140-
if decoy_id is not None:
141-
rehearsal = self._registry.pop_decoy_last_call(decoy_id)
142-
self._last_decoy_id = None
143+
assert rehearsal in self._registry.get_calls_by_spy_id(rehearsal.spy_id)
143144

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

147-
raise ValueError("when/verify must be called with a decoy rehearsal")
148+
if rehearsal is None:
149+
raise ValueError("when/verify must be called with a decoy rehearsal")
148150

149-
def _create_track_call_and_act(self, decoy_id: int) -> Callable[..., Any]:
150-
def track_call_and_act(
151-
*args: Sequence[Any], **_kwargs: Mapping[str, Any]
152-
) -> Any:
153-
self._last_decoy_id = decoy_id
151+
return rehearsal
154152

155-
last_call = self._registry.peek_decoy_last_call(decoy_id)
156-
stubs = reversed(self._registry.get_decoy_stubs(decoy_id))
153+
def _handle_spy_call(self, call: SpyCall) -> Any:
154+
self._registry.register_call(call)
157155

158-
if last_call is not None:
159-
for stub in stubs:
160-
if stub._rehearsal == last_call:
161-
return stub._act()
156+
stubs = self._registry.get_stubs_by_spy_id(call.spy_id)
162157

163-
return None
158+
for stub in reversed(stubs):
159+
if stub._rehearsal == call:
160+
return stub._act()
164161

165-
return track_call_and_act
162+
return None

decoy/matchers.py

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,29 @@
1-
"""Matcher helpers."""
1+
"""Matcher helpers.
2+
3+
A "matcher" is a helper class with an `__eq__` method defined. Use them
4+
anywhere in your test where you would use an actual value for equality
5+
(`==`) comparision.
6+
7+
Matchers help you loosen assertions where strict adherence to an exact value
8+
is not relevent to what you're trying to test.
9+
10+
Example:
11+
```python
12+
from decoy import Decoy, matchers
13+
14+
# ...
15+
16+
def test_logger_called(decoy: Decoy):
17+
# ...
18+
decoy.verify(
19+
logger.log(msg=matchers.StringMatching("hello"))
20+
)
21+
```
22+
23+
Note:
24+
Identity comparisons (`is`) will not work with matchers. Decoy only uses
25+
equality comparisons for stubbing and verification.
26+
"""
227
from re import compile as compile_re
328
from typing import cast, Any, Optional, Pattern, Type
429

decoy/mock.py

Lines changed: 0 additions & 29 deletions
This file was deleted.

0 commit comments

Comments
 (0)