Skip to content

Commit d596cc9

Browse files
authored
ci: add CI configuration (#1)
1 parent 1f6cc2d commit d596cc9

File tree

7 files changed

+360
-7
lines changed

7 files changed

+360
-7
lines changed

.github/workflows/ci.yml

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
name: Continuous integration
2+
3+
on: [push, pull_request]
4+
5+
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
22+
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
39+
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

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
dist
2+
site
23
*.egg-info
34
.python-version
45
__pycache__

README.md

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,25 @@
11
# Decoy
22

3+
[![ci badge][]][ci]
4+
[![pypi version badge][]][pypi]
5+
[![license badge][]][license]
6+
7+
[ci]: https://github.com/mcous/decoy/actions
8+
[ci badge]: https://flat.badgen.net/github/checks/mcous/decoy
9+
[pypi]: https://pypi.org/project/decoy/
10+
[pypi version badge]: https://flat.badgen.net/pypi/v/decoy
11+
[license]: https://github.com/mcous/decoy/blob/main/LICENSE
12+
[license badge]: https://flat.badgen.net/github/license/mcous/decoy
13+
314
> Opinionated, typed stubbing and verification library for Python
415
16+
<https://mike.cousins.io/decoy/>
17+
518
The Decoy library allows you to create, stub, and verify test double objects for your Python unit tests, so your tests are:
619

7-
- Easier to fit into the Arrange-Act-Assert pattern
820
- Less prone to insufficient tests due to unconditional stubbing
921
- Covered by typechecking
22+
- Easier to fit into the Arrange-Act-Assert pattern
1023

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

@@ -42,7 +55,7 @@ def decoy() -> Decoy:
4255

4356
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.
4457

45-
[pytest]: https://docs.pytest.org/en/latest/
58+
[pytest]: https://docs.pytest.org/
4659

4760
### Stubbing
4861

@@ -92,8 +105,7 @@ Verification of decoy calls after they have occurred be considered a last resort
92105
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:
93106

94107
- 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
108+
- The dependency is doing too much based on its input (e.g. side-effecting _and_ calculating complex data) and should be refactored
97109

98110
```python
99111
import pytest

docs/contributing.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,3 +53,30 @@ poetry run black .
5353
[mypy]: https://mypy.readthedocs.io
5454
[flake8]: https://flake8.pycqa.org
5555
[black]: https://black.readthedocs.io
56+
57+
## Deploying
58+
59+
The library and documentation will be deployed to PyPI and GitHub Pages, respectively, by CI. To trigger the deploy, cut a new version and push it to GitHub.
60+
61+
Deploy adheres to [semantic versioning][], so care should be taken to bump accurately.
62+
63+
```bash
64+
# checkout the main branch and pull down latest changes
65+
git checkout main
66+
git pull
67+
68+
# bump the version
69+
# replace ${bump_version} with a bump specifier, like "minor"
70+
poetry version ${bump_version}
71+
72+
# add the bumped pyproject.toml
73+
git add pyproject.toml
74+
75+
# commit and tag the bump
76+
# replace ${release_version} with the actual version string
77+
git commit -m "chore(release): ${release_version}"
78+
git tag -a v${release_version} -m "chore(release): ${release_version}"
79+
git push --folow-tags
80+
```
81+
82+
[semantic versioning]: https://semver.org/

docs/why.md

Lines changed: 246 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,246 @@
1+
# Why Use Decoy?
2+
3+
The Python testing world already has [unittest.mock][] for creating fakes, so why is a library like Decoy even necessary?
4+
5+
The `MagicMock` class (and friends) provided by the Python standard library are great, which is why Decoy uses them under the hood. They are, however:
6+
7+
- Not very opinionated in how they are used
8+
- Not able to adhere to type annotations of your actual interfaces
9+
10+
At its core, Decoy wraps `MagicMock` with a more opinionated, strictly typed interface to encourage well written tests and, ultimately, well written source code.
11+
12+
[unittest.mock]: https://docs.python.org/3/library/unittest.mock.html
13+
14+
## Recommended Reading
15+
16+
Decoy is heavily influenced by and/or stolen from the [testdouble.js][] and [Mockito][] projects. These projects have both been around for a while (especially Mockito), and their docs are valuable resources for learning how to test and mock effectively.
17+
18+
If you have the time, you should check out:
19+
20+
- Mockito Wiki - [How to write good tests](https://github.com/mockito/mockito/wiki/How-to-write-good-tests)
21+
- Test Double Wiki - [Discovery Testing](https://github.com/testdouble/contributing-tests/wiki/Discovery-Testing)
22+
- Test Double Wiki - [Test Double](https://github.com/testdouble/contributing-tests/wiki/Test-Double)
23+
24+
[testdouble.js]: https://github.com/testdouble/testdouble.js
25+
[mockito]: https://site.mockito.org/
26+
27+
## Creating and Using a Stub
28+
29+
A [stub][] is a specific type of test fake that:
30+
31+
- Can be configured to respond in a certain way if given certain inputs
32+
- Will no-op if given output outside of its pre-configured specifications
33+
34+
Stubs are great for simulating dependencies that provide data to or run calculations on input data for the code under test.
35+
36+
For the following examples, let's assume:
37+
38+
- We're testing a library to deal with `Book` objects
39+
- That library depends on a `Database` provider to store objects in a database
40+
- That library depends on a `Logger` interface to log access
41+
42+
[stub]: https://en.wikipedia.org/wiki/Test_stub
43+
44+
### Stubbing with a MagicMock
45+
46+
```python
47+
# setup
48+
from pytest
49+
from unittest.mock import MagicMock
50+
from typing import cast
51+
52+
from my_lib.database import Database
53+
from my_lib.bookshelf import get_book, Book
54+
55+
56+
@pytest.fixture
57+
def mock_database() -> MagicMock:
58+
return MagicMock(spec=Database)
59+
60+
@pytest.fixture
61+
def mock_book() -> Book:
62+
return cast(Book, {"title": "The Metamorphosis"})
63+
```
64+
65+
`MagicMock` does not provide an explicit stubbing interface, so to stub you could:
66+
67+
```python
68+
# option 1:
69+
# - return mock data unconditionally
70+
# - assert dependency was called correctly after the fact
71+
72+
def test_get_book(mock_database: MagicMock, mock_book: Book) -> None:
73+
# arrange mock to always return mock data
74+
mock_database.get_by_id.return_value = mock_book
75+
76+
# exercise the code under test
77+
result = get_book("unique-id", database=mock_database)
78+
79+
# assert that the result is correct
80+
assert result == mock_book
81+
# also assert that the database mock was called correctly
82+
mock_database.get_by_id.assert_called_with("unique-id)
83+
```
84+
85+
```python
86+
# option 2: create a side-effect function to check the conditions
87+
def test_get_book(mock_database: MagicMock, mock_book: Book) -> None:
88+
def stub_get_by_id(uid: str) -> Optional[Book]:
89+
if uid == "unique-id":
90+
return mock_book
91+
else:
92+
return None
93+
94+
# arrange mock to always return mock data
95+
mock_database.get_by_id.side_effect = stub_get_by_id
96+
97+
# exercise the code under test
98+
result = get_book("unique-id", database=mock_database)
99+
100+
# assert that the result is correct
101+
# because of the `if` in the side-effect, we know the stub was called
102+
# correctly because there's no other way the code-under-test could have
103+
# gotten the mock_book data
104+
assert result == mock_book
105+
```
106+
107+
Both of these options have roughly the same upside and downsides:
108+
109+
- Upside: `MagicMock` is part of the Python standard library
110+
- Downside: they're both a little difficult to read
111+
- Option 1 separates the input checking from output value, and they appear in reverse chronological order (you define the dependency output before you define the input)
112+
- Option 2 forces you to create a whole new function and assign it to the `side_effect` value
113+
- Downside: `MagicMock` is effectively `Any` typed
114+
- `return_value` and `assert_called_with` are not typed according to the dependency's type definition
115+
- A manual `side_effect`, if typed, needs to be manually typed, which may not match the actual dependency type definition
116+
117+
Option 1 has another downside, specifically:
118+
119+
- The mocked return value is unconditional
120+
- If the assert step is wrong or accidentally skipped, the code-under-test is still fed the mock data
121+
- This increases the likelihood of a false pass or insufficient test coverage
122+
123+
### Stubbing with Decoy
124+
125+
```python
126+
# setup
127+
from pytest
128+
from decoy import Decoy
129+
130+
from my_lib.database import Database
131+
from my_lib.bookshelf import get_book, Book
132+
133+
@pytest.fixture
134+
def decoy() -> Decoy:
135+
return Decoy()
136+
137+
@pytest.fixture
138+
def mock_database(decoy: Decoy) -> Database:
139+
return decoy.create_decoy(spec=Database)
140+
141+
@pytest.fixture
142+
def mock_book() -> Book:
143+
return cast(Book, {"title": "The Metamorphosis"})
144+
```
145+
146+
Decoy wraps `MagicMock` with a simple, rehearsal-based stubbing interface.
147+
148+
```python
149+
def test_get_book(decoy: Decoy, mock_database: Database, mock_book: Book) -> None:
150+
# arrange stub to return mock data when called correctly
151+
decoy.when(
152+
# this is a rehearsal, which might look a little funny at first
153+
# it's an actual call to the test double that Decoy captures
154+
mock_database.get_by_id("unique-id")
155+
).then_return(mock_book)
156+
157+
# exercise the code under test
158+
result = get_book("unique-id", database=mock_database)
159+
160+
# assert that the result is correct
161+
assert result == mock_book
162+
```
163+
164+
Benefits to note over the vanilla `MagicMock` versions:
165+
166+
- The rehearsal syntax for stub configuration is terse but very easy to read
167+
- Inputs and outputs from the dependency are specified together
168+
- You specify the inputs _before_ outputs, which can be easier to grok
169+
- The entire test fits neatly into "arrange", "act", and "assert" phases
170+
- Decoy casts test doubles as the actual types they are mimicking
171+
- This means stub configuration arguments _and_ return values are type-checked
172+
173+
## Creating and Using a Spy
174+
175+
A [spy][] is another kind of test fake that simply records calls made to it. Spies are useful to model dependencies that are used for their side-effects rather than providing or calculating data.
176+
177+
[spy]: https://github.com/testdouble/contributing-tests/wiki/Spy
178+
179+
### Spying with a MagicMock
180+
181+
```python
182+
# setup
183+
from pytest
184+
from decoy import Decoy
185+
186+
from my_lib.logger import Logger
187+
from my_lib.bookshelf import get_book, Book
188+
189+
190+
@pytest.fixture
191+
def decoy() -> Decoy:
192+
return Decoy()
193+
194+
195+
@pytest.fixture
196+
def mock_logger() -> MagicMock:
197+
return MagicMock(spec=Logger)
198+
```
199+
200+
`MagicMock` is well suited to spying, since it's an object that records all calls made to it.
201+
202+
```python
203+
def test_get_book_logs(mock_logger: MagicMock) -> None:
204+
# exercise the code under test
205+
get_book("unique-id", logger=mock_logger)
206+
207+
# assert logger spy was called correctly
208+
mock_logger.log.assert_called_with(level="debug", msg="Get book unique-id")
209+
```
210+
211+
The only real downside to `MagicMock` in this case is the lack of typechecking.
212+
213+
### Spying with Decoy
214+
215+
```python
216+
# setup
217+
from pytest
218+
from decoy import Decoy
219+
220+
from my_lib.logger import Logger
221+
from my_lib.bookshelf import get_book, Book
222+
223+
224+
@pytest.fixture
225+
def decoy() -> Decoy:
226+
return Decoy()
227+
228+
229+
@pytest.fixture
230+
def mock_logger(decoy: Decoy) -> Logger:
231+
return decoy.create_decoy(spec=Logger)
232+
```
233+
234+
For verification of spies, Decoy doesn't do much except set out to add typechecking.
235+
236+
```python
237+
def test_get_book_logs(decoy: Decoy, mock_logger: Logger) -> None:
238+
# exercise the code under test
239+
get_book("unique-id", logger=mock_logger)
240+
241+
# assert logger spy was called correctly
242+
# uses the same type-checked "rehearsal" syntax as stubbing
243+
decoy.verify(
244+
mock_logger(level="debug", msg="Get book unique-id")
245+
)
246+
```

0 commit comments

Comments
 (0)