Skip to content

Commit

Permalink
feat: create Decoy library
Browse files Browse the repository at this point in the history
  • Loading branch information
mcous committed Nov 23, 2020
0 parents commit be1c3a1
Show file tree
Hide file tree
Showing 24 changed files with 2,242 additions and 0 deletions.
16 changes: 16 additions & 0 deletions .flake8
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
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
dist
*.egg-info
.python-version
__pycache__
21 changes: 21 additions & 0 deletions LICENSE
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.
147 changes: 147 additions & 0 deletions README.md
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"))
)
```
176 changes: 176 additions & 0 deletions decoy/__init__.py
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
Loading

0 comments on commit be1c3a1

Please sign in to comment.