Skip to content

Commit 0371a77

Browse files
authored
fix: allow nested mocks and typing (#2)
1 parent 8907f68 commit 0371a77

File tree

18 files changed

+327
-177
lines changed

18 files changed

+327
-177
lines changed

.editorconfig

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
# https://editorconfig.org
2+
3+
root = true
4+
5+
[*]
6+
end_of_line = lf
7+
insert_final_newline = true
8+
9+
[*.{py,md,yml,toml}]
10+
charset = utf-8
11+
indent_style = space
12+
indent_size = 4

.flake8

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,4 +13,4 @@ extend-ignore =
1313

1414
# configure flake8-docstrings
1515
# https://pypi.org/project/flake8-docstrings/
16-
docstring-convention = pep257
16+
docstring-convention = google

.github/workflows/ci.yml

Lines changed: 59 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -3,64 +3,64 @@ name: Continuous integration
33
on: [push, pull_request]
44

55
jobs:
6-
test:
7-
name: Test Python ${{ matrix.python-version }} on ${{ matrix.os }}
8-
runs-on: ${{ matrix.os }}
9-
strategy:
10-
matrix:
11-
os: [ubuntu-latest, windows-latest, macos-latest]
12-
python-version: ["3.7", "3.8", "3.9"]
13-
steps:
14-
- uses: actions/checkout@v2
15-
- uses: actions/setup-python@v2
16-
with:
17-
python-version: ${{ matrix.python-version }}
18-
- name: Install dependencies
19-
run: pip install poetry && poetry install
20-
- name: Run tests
21-
run: poetry run pytest
6+
test:
7+
name: Test Python ${{ matrix.python-version }} on ${{ matrix.os }}
8+
runs-on: ${{ matrix.os }}
9+
strategy:
10+
matrix:
11+
os: [ubuntu-latest, windows-latest, macos-latest]
12+
python-version: ["3.7", "3.8", "3.9"]
13+
steps:
14+
- uses: actions/checkout@v2
15+
- uses: actions/setup-python@v2
16+
with:
17+
python-version: ${{ matrix.python-version }}
18+
- name: Install dependencies
19+
run: pip install poetry && poetry install
20+
- name: Run tests
21+
run: poetry run pytest
2222

23-
check:
24-
name: Lint and type checks
25-
runs-on: ubuntu-latest
26-
steps:
27-
- uses: actions/checkout@v2
28-
- uses: actions/setup-python@v2
29-
with:
30-
python-version: "3.8"
31-
- name: Install dependencies
32-
run: pip install poetry && poetry install
33-
- name: Format checks
34-
run: poetry run black --check .
35-
- name: Lint checks
36-
run: poetry run flake8
37-
- name: Type checks
38-
run: poetry run mypy
23+
check:
24+
name: Lint and type checks
25+
runs-on: ubuntu-latest
26+
steps:
27+
- uses: actions/checkout@v2
28+
- uses: actions/setup-python@v2
29+
with:
30+
python-version: "3.8"
31+
- name: Install dependencies
32+
run: pip install poetry && poetry install
33+
- name: Format checks
34+
run: poetry run black --check .
35+
- name: Lint checks
36+
run: poetry run flake8
37+
- name: Type checks
38+
run: poetry run mypy
3939

40-
build:
41-
name: Build assets and deploy on tags
42-
runs-on: ubuntu-latest
43-
needs: [test, check]
44-
steps:
45-
- uses: actions/checkout@v2
46-
- uses: actions/setup-python@v2
47-
with:
48-
python-version: "3.8"
49-
- name: Install dependencies
50-
run: pip install poetry && poetry install
51-
- name: Build artifacts
52-
run: |
53-
poetry build
54-
poetry run mkdocs build
55-
- if: startsWith(github.ref, 'refs/tags/v')
56-
name: Deploy to PyPI and GitHub Pages
57-
env:
58-
USER_NAME: ${{ github.actor }}
59-
USER_ID: ${{ github.event.sender.id }}
60-
PYPI_TOKEN: ${{ secrets.PYPI_TOKEN }}
61-
run: |
62-
git config user.name "$USER_NAME (GitHub Actions)"
63-
git config user.email "[email protected]"
64-
poetry config pypi-token.pypi $PYPI_TOKEN
65-
poetry publish
66-
poetry run mkdocs gh-deploy
40+
build:
41+
name: Build assets and deploy on tags
42+
runs-on: ubuntu-latest
43+
needs: [test, check]
44+
steps:
45+
- uses: actions/checkout@v2
46+
- uses: actions/setup-python@v2
47+
with:
48+
python-version: "3.8"
49+
- name: Install dependencies
50+
run: pip install poetry && poetry install
51+
- name: Build artifacts
52+
run: |
53+
poetry build
54+
poetry run mkdocs build
55+
- if: startsWith(github.ref, 'refs/tags/v')
56+
name: Deploy to PyPI and GitHub Pages
57+
env:
58+
USER_NAME: ${{ github.actor }}
59+
USER_ID: ${{ github.event.sender.id }}
60+
PYPI_TOKEN: ${{ secrets.PYPI_TOKEN }}
61+
run: |
62+
git config user.name "$USER_NAME (GitHub Actions)"
63+
git config user.email "[email protected]"
64+
poetry config pypi-token.pypi $PYPI_TOKEN
65+
poetry publish
66+
poetry run mkdocs gh-deploy --force

README.md

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,9 @@
1717

1818
The Decoy library allows you to create, stub, and verify test double objects for your Python unit tests, so your tests are:
1919

20-
- Less prone to insufficient tests due to unconditional stubbing
21-
- Covered by typechecking
22-
- Easier to fit into the Arrange-Act-Assert pattern
20+
- Less prone to insufficient tests due to unconditional stubbing
21+
- Covered by typechecking
22+
- Easier to fit into the Arrange-Act-Assert pattern
2323

2424
The Decoy API is heavily inspired by / stolen from the excellent [testdouble.js][] and [Mockito][] projects.
2525

@@ -63,9 +63,9 @@ A stub is a an object used in a test that is pre-configured to act in a certain
6363

6464
By pre-configuring the stub with specific rehearsals, you get the following benefits:
6565

66-
- Your test double will only return your mock value **if it is called correctly**
67-
- You avoid separate "set up mock return value" and "assert mock called correctly" steps
68-
- If you annotate your test double with an actual type, the rehearsal will fail typechecking if called incorrectly
66+
- Your test double will only return your mock value **if it is called correctly**
67+
- You avoid separate "set up mock return value" and "assert mock called correctly" steps
68+
- If you annotate your test double with an actual type, the rehearsal will fail typechecking if called incorrectly
6969

7070
```python
7171
import pytest
@@ -99,13 +99,13 @@ If you're coming from `unittest.mock`, you're probably more used to calling your
9999

100100
Verification of decoy calls after they have occurred be considered a last resort, because:
101101

102-
- If you're calling a method/function to get its data, then you can more precisely describe that relationship using [stubbing](#stubbing)
103-
- Side-effects are harder to understand and maintain than pure functions, so in general you should try to side-effect sparingly
102+
- If you're calling a method/function to get its data, then you can more precisely describe that relationship using [stubbing](#stubbing)
103+
- Side-effects are harder to understand and maintain than pure functions, so in general you should try to side-effect sparingly
104104

105105
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:
106106

107-
- The assertions are redundant
108-
- The dependency is doing too much based on its input (e.g. side-effecting _and_ calculating complex data) and should be refactored
107+
- The assertions are redundant
108+
- The dependency is doing too much based on its input (e.g. side-effecting _and_ calculating complex data) and should be refactored
109109

110110
```python
111111
import pytest

decoy/__init__.py

Lines changed: 18 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
11
"""Decoy test double stubbing and verification library."""
2-
3-
from mock import AsyncMock, MagicMock
42
from typing import cast, Any, Callable, Mapping, Optional, Sequence, Tuple, Type
53

4+
from .mock import create_decoy_mock, DecoyMock
65
from .registry import Registry
76
from .stub import Stub
87
from .types import Call, ClassT, FuncT, ReturnT
@@ -15,8 +14,7 @@ class Decoy:
1514
_last_decoy_id: Optional[int]
1615

1716
def __init__(self) -> None:
18-
"""
19-
Initialize the state container for test doubles and stubs.
17+
"""Initialize the state container for test doubles and stubs.
2018
2119
You should initialize a new Decoy instance for every test.
2220
@@ -34,8 +32,7 @@ def decoy() -> Decoy:
3432
self._last_decoy_id = None
3533

3634
def create_decoy(self, spec: Type[ClassT], *, is_async: bool = False) -> ClassT:
37-
"""
38-
Create a class decoy for `spec`.
35+
"""Create a class decoy for `spec`.
3936
4037
Arguments:
4138
spec: A class definition that the decoy should mirror.
@@ -52,25 +49,13 @@ def test_get_something(decoy: Decoy):
5249
```
5350
5451
"""
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-
52+
decoy = self._create_and_register_mock(spec=spec, is_async=is_async)
6753
return cast(ClassT, decoy)
6854

6955
def create_decoy_func(
7056
self, spec: Optional[FuncT] = None, *, is_async: bool = False
7157
) -> FuncT:
72-
"""
73-
Create a function decoy for `spec`.
58+
"""Create a function decoy for `spec`.
7459
7560
Arguments:
7661
spec: A function that the decoy should mirror.
@@ -86,16 +71,12 @@ def test_create_something(decoy: Decoy):
8671
# ...
8772
```
8873
"""
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))
74+
decoy = self._create_and_register_mock(spec=spec, is_async=is_async)
9375

9476
return cast(FuncT, decoy)
9577

9678
def when(self, _rehearsal_result: ReturnT) -> Stub[ReturnT]:
97-
"""
98-
Create a [Stub][decoy.stub.Stub] configuration using a rehearsal call.
79+
"""Create a [Stub][decoy.stub.Stub] configuration using a rehearsal call.
9980
10081
See [stubbing](/#stubbing) for more details.
10182
@@ -118,9 +99,8 @@ def when(self, _rehearsal_result: ReturnT) -> Stub[ReturnT]:
11899

119100
return stub
120101

121-
def verify(self, _rehearsal_result: ReturnT) -> None:
122-
"""
123-
Verify a decoy was called using a rehearsal.
102+
def verify(self, _rehearsal_result: Optional[ReturnT] = None) -> None:
103+
"""Verify a decoy was called using a rehearsal.
124104
125105
See [verification](/#verification) for more details.
126106
@@ -145,6 +125,15 @@ def test_create_something(decoy: Decoy):
145125

146126
decoy.assert_has_calls([rehearsal])
147127

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+
148137
def _pop_last_rehearsal(self) -> Tuple[int, Call]:
149138
decoy_id = self._last_decoy_id
150139

decoy/matchers.py

Lines changed: 5 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,7 @@ def __repr__(self) -> str:
2323

2424

2525
def Anything() -> Any:
26-
"""
27-
Match anything except None.
26+
"""Match anything except None.
2827
2928
Example:
3029
```python
@@ -52,8 +51,7 @@ def __repr__(self) -> str:
5251

5352

5453
def IsA(match_type: type) -> Any:
55-
"""
56-
Match anything that satisfies the passed in type.
54+
"""Match anything that satisfies the passed in type.
5755
5856
Arguments:
5957
match_type: Type to match.
@@ -85,8 +83,7 @@ def __repr__(self) -> str:
8583

8684

8785
def IsNot(value: object) -> Any:
88-
"""
89-
Match anything that isn't the passed in value.
86+
"""Match anything that isn't the passed in value.
9087
9188
Arguments:
9289
value: Value to check against.
@@ -120,8 +117,7 @@ def __repr__(self) -> str:
120117

121118

122119
def StringMatching(match: str) -> str:
123-
"""
124-
Match any string matching the passed in pattern.
120+
"""Match any string matching the passed in pattern.
125121
126122
Arguments:
127123
match: Pattern to check against; will be compiled into an re.Pattern.
@@ -163,8 +159,7 @@ def __repr__(self) -> str:
163159

164160

165161
def ErrorMatching(error: Type[Exception], match: Optional[str] = None) -> Exception:
166-
"""
167-
Match any error matching an Exception type and optional message matcher.
162+
"""Match any error matching an Exception type and optional message matcher.
168163
169164
Arguments:
170165
error: Exception type to match against.

decoy/mock.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
"""Custom unittest.mock subclasses."""
2+
3+
from mock import AsyncMock, MagicMock
4+
from typing import Any, Union
5+
6+
7+
class SyncDecoyMock(MagicMock):
8+
"""MagicMock variant where all child mocks use the parent side_effect."""
9+
10+
def _get_child_mock(self, **kwargs: Any) -> Any:
11+
return super()._get_child_mock(**kwargs, side_effect=self.side_effect)
12+
13+
14+
class AsyncDecoyMock(AsyncMock): # type: ignore[misc]
15+
"""AsyncMock variant where all child mocks use the parent side_effect."""
16+
17+
def _get_child_mock(self, **kwargs: Any) -> Any:
18+
return super()._get_child_mock(**kwargs, side_effect=self.side_effect)
19+
20+
21+
DecoyMock = Union[SyncDecoyMock, AsyncDecoyMock]
22+
23+
24+
def create_decoy_mock(is_async: bool, **kwargs: Any) -> DecoyMock:
25+
"""Create a MagicMock or AsyncMock."""
26+
if is_async is False:
27+
return SyncDecoyMock(**kwargs)
28+
else:
29+
return AsyncDecoyMock(**kwargs)

decoy/py.typed

Whitespace-only changes.

0 commit comments

Comments
 (0)