-
Notifications
You must be signed in to change notification settings - Fork 1
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
0 parents
commit be1c3a1
Showing
24 changed files
with
2,242 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,16 @@ | ||
[flake8] | ||
|
||
# set line-length for black support | ||
# https://github.com/psf/black/blob/master/docs/compatible_configs.md | ||
max-line-length = 88 | ||
|
||
extend-ignore = | ||
# ignore E203 because black might reformat it | ||
E203, | ||
# do not require type annotations for self nor cls | ||
ANN101, | ||
ANN102 | ||
|
||
# configure flake8-docstrings | ||
# https://pypi.org/project/flake8-docstrings/ | ||
docstring-convention = pep257 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,4 @@ | ||
dist | ||
*.egg-info | ||
.python-version | ||
__pycache__ |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,21 @@ | ||
MIT License | ||
|
||
Copyright (c) 2020-present, Mike Cousins | ||
|
||
Permission is hereby granted, free of charge, to any person obtaining a copy | ||
of this software and associated documentation files (the "Software"), to deal | ||
in the Software without restriction, including without limitation the rights | ||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell | ||
copies of the Software, and to permit persons to whom the Software is | ||
furnished to do so, subject to the following conditions: | ||
|
||
The above copyright notice and this permission notice shall be included in all | ||
copies or substantial portions of the Software. | ||
|
||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR | ||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, | ||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE | ||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER | ||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, | ||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE | ||
SOFTWARE. |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,147 @@ | ||
# Decoy | ||
|
||
> Opinionated, typed stubbing and verification library for Python | ||
The Decoy library allows you to create, stub, and verify test double objects for your Python unit tests, so your tests are: | ||
|
||
- Easier to fit into the Arrange-Act-Assert pattern | ||
- Less prone to insufficient tests due to unconditional stubbing | ||
- Covered by typechecking | ||
|
||
The Decoy API is heavily inspired by / stolen from the excellent [testdouble.js][] and [Mockito][] projects. | ||
|
||
[testdouble.js]: https://github.com/testdouble/testdouble.js | ||
[mockito]: https://site.mockito.org/ | ||
|
||
## Install | ||
|
||
```bash | ||
# pip | ||
pip install decoy | ||
|
||
# poetry | ||
poetry add --dev decoy | ||
``` | ||
|
||
## Usage | ||
|
||
### Setup | ||
|
||
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. | ||
|
||
The examples below assume the following global test fixture: | ||
|
||
```python | ||
import pytest | ||
from decoy import Decoy | ||
|
||
@pytest.fixture | ||
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. | ||
|
||
[pytest]: https://docs.pytest.org/en/latest/ | ||
|
||
### Stubbing | ||
|
||
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. | ||
|
||
By pre-configuring the stub with specific rehearsals, you get the following benefits: | ||
|
||
- Your test double will only return your mock value **if it is called correctly** | ||
- You avoid separate "set up mock return value" and "assert mock called correctly" steps | ||
- If you annotate your test double with an actual type, the rehearsal will fail typechecking if called incorrectly | ||
|
||
```python | ||
import pytest | ||
from typing import cast, Optional | ||
from decoy import Decoy | ||
|
||
from .database import Database, Model | ||
|
||
def get_item(uid: str, db: Database) -> Optional[Model]: | ||
return db.get_by_id(uid) | ||
|
||
def test_get_item(decoy: Decoy): | ||
mock_item = cast(Model, { "foo": "bar" }) | ||
mock_db = decoy.create_decoy(spec=Database) | ||
|
||
# arrange stub using rehearsals | ||
decoy.when(mock_db.get_by_id("some-id")).then_return(mock_item) | ||
|
||
# call code under test | ||
some_result = get_item("some-id") | ||
other_result = get_item("other-id") | ||
|
||
# assert code result | ||
assert some_result == mock_item | ||
assert other_result is None | ||
``` | ||
|
||
### Verification | ||
|
||
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)**. | ||
|
||
Verification of decoy calls after they have occurred be considered a last resort, because: | ||
|
||
- If you're calling a method/function to get its data, then you can more precisely describe that relationship using [stubbing](#stubbing) | ||
- Side-effects are harder to understand and maintain than pure functions, so in general you should try to side-effect sparingly | ||
|
||
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: | ||
|
||
- The assertions are redundant | ||
- You should re-read the section on stubbing and maybe the [testdouble.js][] and/or [mockito][] documentation | ||
- Your dependency is doing too much based on its input (e.g. side-effecting _and_ calculating complex data) and should be refactored | ||
|
||
```python | ||
import pytest | ||
from typing import cast, Optional | ||
from decoy import Decoy, verify | ||
|
||
from .logger import Logger | ||
|
||
def log_warning(msg: str, logger: Logger) -> None: | ||
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!", logger) | ||
|
||
# verify double called correctly | ||
decoy.verify(logger.warn("oh no!")) | ||
``` | ||
|
||
### Matchers | ||
|
||
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. | ||
|
||
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. | ||
|
||
```python | ||
import pytest | ||
from typing import cast, Optional | ||
from decoy import Decoy, matchers | ||
|
||
from .logger import Logger | ||
|
||
def log_warning(msg: str, logger: Logger) -> None: | ||
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")) | ||
) | ||
``` |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,176 @@ | ||
"""Decoy test double stubbing and verification library.""" | ||
|
||
from mock import AsyncMock, MagicMock | ||
from typing import cast, Any, Callable, Mapping, Optional, Sequence, Tuple, Type | ||
|
||
from .registry import Registry | ||
from .stub import Stub | ||
from .types import Call, 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. | ||
You should initialize a new Decoy instance for every test. | ||
Example: | ||
```python | ||
import pytest | ||
from decoy import Decoy | ||
@pytest.fixture | ||
def decoy() -> Decoy: | ||
return 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. | ||
Returns: | ||
A `MagicMock` or `AsyncMock`, typecast as an instance of `spec`. | ||
Example: | ||
```python | ||
def test_get_something(decoy: Decoy): | ||
db = decoy.create_decoy(spec=Database) | ||
# ... | ||
``` | ||
""" | ||
decoy = MagicMock(spec=spec) if is_async is False else AsyncMock(spec=spec) | ||
decoy_id = self._registry.register_decoy(decoy) | ||
side_effect = self._create_track_call_and_act(decoy_id) | ||
|
||
decoy.configure_mock( | ||
**{ | ||
f"{method}.side_effect": side_effect | ||
for method in dir(spec) | ||
if not (method.startswith("__") and method.endswith("__")) | ||
} | ||
) | ||
|
||
return cast(ClassT, decoy) | ||
|
||
def create_decoy_func( | ||
self, spec: Optional[FuncT] = None, *, is_async: bool = False | ||
) -> FuncT: | ||
""" | ||
Create a function decoy for `spec`. | ||
Arguments: | ||
spec: A function that the decoy should mirror. | ||
is_async: Set to `True` if the function is `await`able. | ||
Returns: | ||
A `MagicMock` or `AsyncMock`, typecast as the function given for `spec`. | ||
Example: | ||
```python | ||
def test_create_something(decoy: Decoy): | ||
gen_id = decoy.create_decoy_func(spec=generate_unique_id) | ||
# ... | ||
``` | ||
""" | ||
decoy = MagicMock(spec=spec) if is_async is False else AsyncMock(spec=spec) | ||
decoy_id = self._registry.register_decoy(decoy) | ||
|
||
decoy.configure_mock(side_effect=self._create_track_call_and_act(decoy_id)) | ||
|
||
return cast(FuncT, decoy) | ||
|
||
def when(self, _rehearsal_result: ReturnT) -> Stub[ReturnT]: | ||
""" | ||
Create a [Stub][decoy.stub.Stub] configuration using a rehearsal call. | ||
See [stubbing](/#stubbing) for more details. | ||
Arguments: | ||
_rehearsal_result: The return value of a rehearsal, used for typechecking. | ||
Returns: | ||
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) | ||
``` | ||
""" | ||
decoy_id, rehearsal = self._pop_last_rehearsal() | ||
stub = Stub[ReturnT](rehearsal=rehearsal) | ||
|
||
self._registry.register_stub(decoy_id, stub) | ||
|
||
return stub | ||
|
||
def verify(self, _rehearsal_result: ReturnT) -> None: | ||
""" | ||
Verify a decoy was called using a rehearsal. | ||
See [verification](/#verification) for more details. | ||
Arguments: | ||
_rehearsal_result: The return value of a rehearsal, unused. | ||
Example: | ||
```python | ||
def test_create_something(decoy: Decoy): | ||
gen_id = decoy.create_decoy_func(spec=generate_unique_id) | ||
# ... | ||
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 _pop_last_rehearsal(self) -> Tuple[int, Call]: | ||
decoy_id = self._last_decoy_id | ||
|
||
if decoy_id is not None: | ||
rehearsal = self._registry.pop_decoy_last_call(decoy_id) | ||
self._last_decoy_id = None | ||
|
||
if rehearsal is not None: | ||
return (decoy_id, rehearsal) | ||
|
||
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 | ||
|
||
last_call = self._registry.peek_decoy_last_call(decoy_id) | ||
stubs = reversed(self._registry.get_decoy_stubs(decoy_id)) | ||
|
||
if last_call is not None: | ||
for stub in stubs: | ||
if stub._rehearsal == last_call: | ||
return stub._act() | ||
|
||
return None | ||
|
||
return track_call_and_act |
Oops, something went wrong.