Skip to content

Commit be1c3a1

Browse files
committed
feat: create Decoy library
0 parents  commit be1c3a1

24 files changed

+2242
-0
lines changed

.flake8

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
[flake8]
2+
3+
# set line-length for black support
4+
# https://github.com/psf/black/blob/master/docs/compatible_configs.md
5+
max-line-length = 88
6+
7+
extend-ignore =
8+
# ignore E203 because black might reformat it
9+
E203,
10+
# do not require type annotations for self nor cls
11+
ANN101,
12+
ANN102
13+
14+
# configure flake8-docstrings
15+
# https://pypi.org/project/flake8-docstrings/
16+
docstring-convention = pep257

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
dist
2+
*.egg-info
3+
.python-version
4+
__pycache__

LICENSE

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) 2020-present, Mike Cousins
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE.

README.md

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
# Decoy
2+
3+
> Opinionated, typed stubbing and verification library for Python
4+
5+
The Decoy library allows you to create, stub, and verify test double objects for your Python unit tests, so your tests are:
6+
7+
- Easier to fit into the Arrange-Act-Assert pattern
8+
- Less prone to insufficient tests due to unconditional stubbing
9+
- Covered by typechecking
10+
11+
The Decoy API is heavily inspired by / stolen from the excellent [testdouble.js][] and [Mockito][] projects.
12+
13+
[testdouble.js]: https://github.com/testdouble/testdouble.js
14+
[mockito]: https://site.mockito.org/
15+
16+
## Install
17+
18+
```bash
19+
# pip
20+
pip install decoy
21+
22+
# poetry
23+
poetry add --dev decoy
24+
```
25+
26+
## Usage
27+
28+
### Setup
29+
30+
You'll want to create a test fixture to reset Decoy state between each test run. In [pytest][], you can do this by using a fixture to create a new Decoy instance for every test.
31+
32+
The examples below assume the following global test fixture:
33+
34+
```python
35+
import pytest
36+
from decoy import Decoy
37+
38+
@pytest.fixture
39+
def decoy() -> Decoy:
40+
return Decoy()
41+
```
42+
43+
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.
44+
45+
[pytest]: https://docs.pytest.org/en/latest/
46+
47+
### Stubbing
48+
49+
A stub is a an object used in a test that is pre-configured to act in a certain way if called according to a spec, defined by a rehearsal. A "rehearsal" is simply a call to the stub inside of a `decoy.when` wrapper.
50+
51+
By pre-configuring the stub with specific rehearsals, you get the following benefits:
52+
53+
- Your test double will only return your mock value **if it is called correctly**
54+
- You avoid separate "set up mock return value" and "assert mock called correctly" steps
55+
- If you annotate your test double with an actual type, the rehearsal will fail typechecking if called incorrectly
56+
57+
```python
58+
import pytest
59+
from typing import cast, Optional
60+
from decoy import Decoy
61+
62+
from .database import Database, Model
63+
64+
def get_item(uid: str, db: Database) -> Optional[Model]:
65+
return db.get_by_id(uid)
66+
67+
def test_get_item(decoy: Decoy):
68+
mock_item = cast(Model, { "foo": "bar" })
69+
mock_db = decoy.create_decoy(spec=Database)
70+
71+
# arrange stub using rehearsals
72+
decoy.when(mock_db.get_by_id("some-id")).then_return(mock_item)
73+
74+
# call code under test
75+
some_result = get_item("some-id")
76+
other_result = get_item("other-id")
77+
78+
# assert code result
79+
assert some_result == mock_item
80+
assert other_result is None
81+
```
82+
83+
### Verification
84+
85+
If you're coming from `unittest.mock`, you're probably more used to calling your code under test and _then_ verifying that your test double was called correctly. Asserting on mock call signatures after the fact can be useful, but **should only be used if the dependency is being called solely for its side-effect(s)**.
86+
87+
Verification of decoy calls after they have occurred be considered a last resort, because:
88+
89+
- If you're calling a method/function to get its data, then you can more precisely describe that relationship using [stubbing](#stubbing)
90+
- Side-effects are harder to understand and maintain than pure functions, so in general you should try to side-effect sparingly
91+
92+
Stubbing and verification of a decoy are **mutually exclusive** within a test. If you find yourself wanting to both stub and verify the same decoy, then one or more of these is true:
93+
94+
- The assertions are redundant
95+
- You should re-read the section on stubbing and maybe the [testdouble.js][] and/or [mockito][] documentation
96+
- Your dependency is doing too much based on its input (e.g. side-effecting _and_ calculating complex data) and should be refactored
97+
98+
```python
99+
import pytest
100+
from typing import cast, Optional
101+
from decoy import Decoy, verify
102+
103+
from .logger import Logger
104+
105+
def log_warning(msg: str, logger: Logger) -> None:
106+
logger.warn(msg)
107+
108+
def test_log_warning(decoy: Decoy):
109+
logger = decoy.create_decoy(spec=Logger)
110+
111+
# call code under test
112+
some_result = log_warning("oh no!", logger)
113+
114+
# verify double called correctly
115+
decoy.verify(logger.warn("oh no!"))
116+
```
117+
118+
### Matchers
119+
120+
Sometimes, when you're stubbing or verifying decoy calls (or really when you're doing any sort of equality assertion in a test), you need to loosen a given assertion. For example, you may want to assert that a double is called with a string, but you don't care what the full contents of that string is.
121+
122+
Decoy includes a set of matchers, which are simply Python classes with `__eq__` methods defined, that you can use in decoy rehearsals and/or assertions.
123+
124+
```python
125+
import pytest
126+
from typing import cast, Optional
127+
from decoy import Decoy, matchers
128+
129+
from .logger import Logger
130+
131+
def log_warning(msg: str, logger: Logger) -> None:
132+
logger.warn(msg)
133+
134+
def test_log_warning(decoy: Decoy):
135+
logger = decoy.create_decoy(spec=Logger)
136+
137+
# call code under test
138+
some_result = log_warning(
139+
"Oh no, something horrible went wrong with request ID abc123efg456",
140+
logger=logger
141+
)
142+
143+
# verify double called correctly
144+
decoy.verify(
145+
mock_logger.warn(matchers.StringMatching("something went wrong"))
146+
)
147+
```

decoy/__init__.py

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
"""Decoy test double stubbing and verification library."""
2+
3+
from mock import AsyncMock, MagicMock
4+
from typing import cast, Any, Callable, Mapping, Optional, Sequence, Tuple, Type
5+
6+
from .registry import Registry
7+
from .stub import Stub
8+
from .types import Call, ClassT, FuncT, ReturnT
9+
10+
11+
class Decoy:
12+
"""Decoy test double state container."""
13+
14+
_registry: Registry
15+
_last_decoy_id: Optional[int]
16+
17+
def __init__(self) -> None:
18+
"""
19+
Initialize the state container for test doubles and stubs.
20+
21+
You should initialize a new Decoy instance for every test.
22+
23+
Example:
24+
```python
25+
import pytest
26+
from decoy import Decoy
27+
28+
@pytest.fixture
29+
def decoy() -> Decoy:
30+
return Decoy()
31+
```
32+
"""
33+
self._registry = Registry()
34+
self._last_decoy_id = None
35+
36+
def create_decoy(self, spec: Type[ClassT], *, is_async: bool = False) -> ClassT:
37+
"""
38+
Create a class decoy for `spec`.
39+
40+
Arguments:
41+
spec: A class definition that the decoy should mirror.
42+
is_async: Set to `True` if the class has `await`able methods.
43+
44+
Returns:
45+
A `MagicMock` or `AsyncMock`, typecast as an instance of `spec`.
46+
47+
Example:
48+
```python
49+
def test_get_something(decoy: Decoy):
50+
db = decoy.create_decoy(spec=Database)
51+
# ...
52+
```
53+
54+
"""
55+
decoy = MagicMock(spec=spec) if is_async is False else AsyncMock(spec=spec)
56+
decoy_id = self._registry.register_decoy(decoy)
57+
side_effect = self._create_track_call_and_act(decoy_id)
58+
59+
decoy.configure_mock(
60+
**{
61+
f"{method}.side_effect": side_effect
62+
for method in dir(spec)
63+
if not (method.startswith("__") and method.endswith("__"))
64+
}
65+
)
66+
67+
return cast(ClassT, decoy)
68+
69+
def create_decoy_func(
70+
self, spec: Optional[FuncT] = None, *, is_async: bool = False
71+
) -> FuncT:
72+
"""
73+
Create a function decoy for `spec`.
74+
75+
Arguments:
76+
spec: A function that the decoy should mirror.
77+
is_async: Set to `True` if the function is `await`able.
78+
79+
Returns:
80+
A `MagicMock` or `AsyncMock`, typecast as the function given for `spec`.
81+
82+
Example:
83+
```python
84+
def test_create_something(decoy: Decoy):
85+
gen_id = decoy.create_decoy_func(spec=generate_unique_id)
86+
# ...
87+
```
88+
"""
89+
decoy = MagicMock(spec=spec) if is_async is False else AsyncMock(spec=spec)
90+
decoy_id = self._registry.register_decoy(decoy)
91+
92+
decoy.configure_mock(side_effect=self._create_track_call_and_act(decoy_id))
93+
94+
return cast(FuncT, decoy)
95+
96+
def when(self, _rehearsal_result: ReturnT) -> Stub[ReturnT]:
97+
"""
98+
Create a [Stub][decoy.stub.Stub] configuration using a rehearsal call.
99+
100+
See [stubbing](/#stubbing) for more details.
101+
102+
Arguments:
103+
_rehearsal_result: The return value of a rehearsal, used for typechecking.
104+
105+
Returns:
106+
A Stub to configure using `then_return` or `then_raise`.
107+
108+
Example:
109+
```python
110+
db = decoy.create_decoy(spec=Database)
111+
decoy.when(db.exists("some-id")).then_return(True)
112+
```
113+
"""
114+
decoy_id, rehearsal = self._pop_last_rehearsal()
115+
stub = Stub[ReturnT](rehearsal=rehearsal)
116+
117+
self._registry.register_stub(decoy_id, stub)
118+
119+
return stub
120+
121+
def verify(self, _rehearsal_result: ReturnT) -> None:
122+
"""
123+
Verify a decoy was called using a rehearsal.
124+
125+
See [verification](/#verification) for more details.
126+
127+
Arguments:
128+
_rehearsal_result: The return value of a rehearsal, unused.
129+
130+
Example:
131+
```python
132+
def test_create_something(decoy: Decoy):
133+
gen_id = decoy.create_decoy_func(spec=generate_unique_id)
134+
135+
# ...
136+
137+
decoy.verify(gen_id("model-prefix_"))
138+
```
139+
"""
140+
decoy_id, rehearsal = self._pop_last_rehearsal()
141+
decoy = self._registry.get_decoy(decoy_id)
142+
143+
if decoy is None:
144+
raise ValueError("verify must be called with a decoy rehearsal")
145+
146+
decoy.assert_has_calls([rehearsal])
147+
148+
def _pop_last_rehearsal(self) -> Tuple[int, Call]:
149+
decoy_id = self._last_decoy_id
150+
151+
if decoy_id is not None:
152+
rehearsal = self._registry.pop_decoy_last_call(decoy_id)
153+
self._last_decoy_id = None
154+
155+
if rehearsal is not None:
156+
return (decoy_id, rehearsal)
157+
158+
raise ValueError("when/verify must be called with a decoy rehearsal")
159+
160+
def _create_track_call_and_act(self, decoy_id: int) -> Callable[..., Any]:
161+
def track_call_and_act(
162+
*args: Sequence[Any], **_kwargs: Mapping[str, Any]
163+
) -> Any:
164+
self._last_decoy_id = decoy_id
165+
166+
last_call = self._registry.peek_decoy_last_call(decoy_id)
167+
stubs = reversed(self._registry.get_decoy_stubs(decoy_id))
168+
169+
if last_call is not None:
170+
for stub in stubs:
171+
if stub._rehearsal == last_call:
172+
return stub._act()
173+
174+
return None
175+
176+
return track_call_and_act

0 commit comments

Comments
 (0)