Skip to content

Commit 2555850

Browse files
committed
docs: split usage guide into multiple sections
1 parent 3193808 commit 2555850

File tree

9 files changed

+239
-186
lines changed

9 files changed

+239
-186
lines changed

README.md

Lines changed: 5 additions & 170 deletions
Original file line numberDiff line numberDiff line change
@@ -14,15 +14,15 @@
1414
</a>
1515
</p>
1616
<p>
17-
<a href="https://mike.cousins.io/decoy/">https://mike.cousins.io/decoy/</a>
17+
<a href="https://mike.cousins.io/decoy/">Usage guide and documentation</a>
1818
</p>
1919
</div>
2020

21-
The Decoy library allows you to create, stub, and verify test double objects for your Python unit tests, so your tests are:
21+
The Decoy library allows you to create, stub, and verify fully-typed, async/await friendly mocks in your Python unit tests, so your tests are:
2222

2323
- Less prone to insufficient tests due to unconditional stubbing
24-
- Covered by typechecking
2524
- Easier to fit into the Arrange-Act-Assert pattern
25+
- Covered by typechecking
2626

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

@@ -41,7 +41,7 @@ poetry add --dev decoy
4141

4242
## Setup
4343

44-
### Pytest
44+
### Pytest setup
4545

4646
Decoy ships with its own [pytest][] plugin, so once Decoy is installed, you're ready to start using it via its pytest fixture, called `decoy`.
4747

@@ -59,9 +59,7 @@ The `decoy` fixture is function-scoped and will ensure that all stub and spy sta
5959

6060
### Mypy Setup
6161

62-
Decoy's rehearsal syntax can be a bit confusing to [mypy][] if a function is supposed to return `None`. Normally, [mypy will complain][] if you try to use a `None`-returning expression as a value, because this is almost always a mistake.
63-
64-
In Decoy, however, it's an intentional part of the API and _not_ a mistake. To suppress these errors, Decoy provides a mypy plugin that you should add to your configuration file:
62+
Decoy's API can be a bit confusing to [mypy][]. To suppress mypy errors that may be emitted during valid usage of the Decoy API, we have a mypy plugin that you should add to your configuration file:
6563

6664
```ini
6765
# mypi.ini
@@ -72,166 +70,3 @@ plugins = decoy.mypy
7270
```
7371

7472
[mypy]: https://mypy.readthedocs.io/
75-
[mypy will complain]: https://mypy.readthedocs.io/en/stable/error_code_list.html#check-that-called-function-returns-a-value-func-returns-value
76-
77-
## Usage
78-
79-
### Stubbing
80-
81-
A stub is an object used in a test that is pre-configured to return a result or raise an error if called according to a specification. In Decoy, you specify a stub's call conditions with a "rehearsal", which is simply a call to the stub inside of a `decoy.when` wrapper.
82-
83-
By pre-configuring the stub with specific rehearsals, you get the following benefits:
84-
85-
- Your test double will only return your mock value **if it is called correctly**
86-
- You avoid separate "set up mock return value" and "assert mock called correctly" steps
87-
- If you annotate your test double with an actual type, the rehearsal will fail typechecking if called incorrectly
88-
89-
```python
90-
import pytest
91-
from typing import cast, Optional
92-
from decoy import Decoy
93-
94-
from .database import Database, Model
95-
96-
def get_item(uid: str, db: Database) -> Optional[Model]:
97-
return db.get_by_id(uid)
98-
99-
def test_get_item(decoy: Decoy):
100-
mock_item = cast(Model, { "foo": "bar" })
101-
mock_db = decoy.create_decoy(spec=Database)
102-
103-
# arrange stub using rehearsals
104-
decoy.when(mock_db.get_by_id("some-id")).then_return(mock_item)
105-
106-
# call code under test
107-
some_result = get_item("some-id")
108-
other_result = get_item("other-id")
109-
110-
# assert code result
111-
assert some_result == mock_item
112-
assert other_result is None
113-
```
114-
115-
### Verifying interactions
116-
117-
If you're coming from `unittest.mock`, you're probably used to calling your code under test and _then_ verifying that your dependency was called correctly. Decoy provides similar call verification using the same "rehearsal" mechanism that the stubbing API uses.
118-
119-
```python
120-
import pytest
121-
from typing import cast, Optional
122-
from decoy import Decoy, verify
123-
124-
from .logger import Logger
125-
126-
def log_warning(msg: str, logger: Logger) -> None:
127-
logger.warn(msg)
128-
129-
def test_log_warning(decoy: Decoy):
130-
logger = decoy.create_decoy(spec=Logger)
131-
132-
# call code under test
133-
some_result = log_warning("oh no!", logger)
134-
135-
# verify double called correctly with a rehearsal
136-
decoy.verify(logger.warn("oh no!"))
137-
```
138-
139-
Asserting that calls happened 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 interactions in this manner should be considered a last resort, because:
140-
141-
- If you're calling a dependency to get data, then you can more precisely describe that relationship using [stubbing](#stubbing)
142-
- Side-effects are harder to understand and maintain than pure functions, so in general you should try to side-effect sparingly
143-
144-
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:
145-
146-
- The assertions are redundant
147-
- The dependency is doing too much based on its input (e.g. side-effecting _and_ calculating complex data) and should be refactored
148-
149-
#### Verifying order of multiple calls
150-
151-
If your code under test must call several dependencies in order, you may pass multiple rehearsals to `verify`. Decoy will search through the list of all calls made to the given spies and look for the exact rehearsal sequence given, in order.
152-
153-
```python
154-
decoy.verify(
155-
handler.call_first_procedure("hello"),
156-
handler.call_second_procedure("world"),
157-
)
158-
```
159-
160-
### Usage with async/await
161-
162-
Decoy supports async/await out of the box! Pass your async function or class with async methods to `spec` in `decoy.create_decoy_func` or `decoy.create_decoy`, respectively, and Decoy will figure out the rest.
163-
164-
When writing rehearsals on async functions and methods, remember to include the `await` with your rehearsal call:
165-
166-
```py
167-
decoy.when(await mock_db.get_by_id("some-id")).then_return(mock_item)
168-
```
169-
170-
### Matchers
171-
172-
Sometimes, when you're stubbing or verifying 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 dependency is called with a string, but you don't care about the full contents of that string.
173-
174-
Decoy includes a set of matchers, which are simply Python classes with `__eq__` methods defined, that you can use in rehearsals and/or assertions.
175-
176-
```python
177-
import pytest
178-
from typing import cast, Optional
179-
from decoy import Decoy, matchers
180-
181-
from .logger import Logger
182-
183-
def log_warning(msg: str, logger: Logger) -> None:
184-
logger.warn(msg)
185-
186-
def test_log_warning(decoy: Decoy):
187-
logger = decoy.create_decoy(spec=Logger)
188-
189-
# call code under test
190-
some_result = log_warning(
191-
"Oh no, something went wrong with request ID abc123efg456",
192-
logger=logger
193-
)
194-
195-
# verify double called correctly
196-
decoy.verify(
197-
logger.warn(matchers.StringMatching("request ID abc123efg456"))
198-
)
199-
```
200-
201-
#### Capturing values with `matchers.captor`
202-
203-
When testing certain APIs, especially callback APIs, it can be helpful to capture the values of arguments passed to a given decoy.
204-
205-
For example, our test subject may register an anonymous event listener handler with a dependency, and we want to test our subject's behavior when the event listener is triggered.
206-
207-
```py
208-
import pytest
209-
from typing import cast, Optional
210-
from decoy import Decoy, matchers
211-
212-
from .event_source import EventSource
213-
from .event_consumer import EventConsumer
214-
215-
216-
def test_event_listener(decoy: Decoy):
217-
event_source = decoy.create_decoy(spec=EventSource)
218-
subject = EventConsumer(event_source=event_source)
219-
captor = matchers.Captor()
220-
221-
# subject registers its listener when started
222-
subject.start_consuming()
223-
224-
# verify listener attached and capture the listener
225-
decoy.verify(event_source.register(event_listener=captor))
226-
227-
# trigger the listener
228-
event_handler = captor.value # or, equivalently, captor.values[0]
229-
230-
assert subject.has_heard_event is False
231-
event_handler()
232-
assert subject.has_heard_event is True
233-
```
234-
235-
This is a pretty verbose way of writing a test, so in general, you may want to approach using `matchers.captor` as a form of potential code smell / test pain. There are often better ways to structure your code for these sorts of interactions that don't involve anonymous / private functions.
236-
237-
For further reading on when (or rather, when not) to use argument captors, check out [testdouble's documentation on its argument captor matcher](https://github.com/testdouble/testdouble.js/blob/main/docs/6-verifying-invocations.md#tdmatcherscaptor).

decoy/__init__.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,8 @@ def create_decoy(
6464
) -> ClassT:
6565
"""Create a class decoy for `spec`.
6666
67+
See [decoy creation usage guide](../usage/create) for more details.
68+
6769
Arguments:
6870
spec: A class definition that the decoy should mirror.
6971
is_async: Force the returned spy to be asynchronous. In most cases,
@@ -93,6 +95,8 @@ def create_decoy_func(
9395
) -> FuncT:
9496
"""Create a function decoy for `spec`.
9597
98+
See [decoy creation usage guide](../usage/create) for more details.
99+
96100
Arguments:
97101
spec: A function that the decoy should mirror.
98102
is_async: Force the returned spy to be asynchronous. In most cases,
@@ -119,7 +123,7 @@ def test_create_something(decoy: Decoy):
119123
def when(self, _rehearsal_result: ReturnT) -> Stub[ReturnT]:
120124
"""Create a [Stub][decoy.stub.Stub] configuration using a rehearsal call.
121125
122-
See [stubbing](index.md#stubbing) for more details.
126+
See [stubbing usage guide](../usage/when) for more details.
123127
124128
Arguments:
125129
_rehearsal_result: The return value of a rehearsal, used for typechecking.
@@ -149,7 +153,7 @@ def when(self, _rehearsal_result: ReturnT) -> Stub[ReturnT]:
149153
def verify(self, *_rehearsal_results: Any) -> None:
150154
"""Verify a decoy was called using one or more rehearsals.
151155
152-
See [verification](index.md#verification) for more details.
156+
See [verification usage guide](../usage/verify) for more details.
153157
154158
Arguments:
155159
_rehearsal_results: The return value of rehearsals, unused except

decoy/stub.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ def __init__(self, rehearsal: SpyCall) -> None:
2626
def then_return(self, *values: ReturnT) -> None:
2727
"""Set the stub's return value(s).
2828
29-
See [stubbing](/#stubbing) for more details.
29+
See [stubbing usage guide](../usage/when) for more details.
3030
3131
Arguments:
3232
*values: Zero or more return values. Multiple values will result
@@ -39,7 +39,7 @@ def then_return(self, *values: ReturnT) -> None:
3939
def then_raise(self, error: Exception) -> None:
4040
"""Set the stub's error value.
4141
42-
See [stubbing](/#stubbing) for more details.
42+
See [stubbing usage guide](../usage/when) for more details.
4343
4444
Arguments:
4545
error: The error to raise.

docs/usage/create.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
# Creating mocks
2+
3+
Decoy can create two kinds of mocks:
4+
5+
- Mocks of a class instance
6+
- Mocks of a function
7+
8+
## Mocking a class
9+
10+
The [`create_decoy` method][decoy.Decoy.create_decoy] is used to create mock class instances. Decoy will inspect type annotations and method signatures to automatically configure methods as synchronous or asynchronous. Decoy mocks are automatically deep.
11+
12+
To typecheckers, the mock will appear to have the exact same type as the `spec` argument. The mock will also pass `isinstance` checks.
13+
14+
```python
15+
def test_my_thing(decoy: Decoy) -> None:
16+
some_dependency = decoy.create_decoy(spec=SomeDependency)
17+
```
18+
19+
## Mocking a function
20+
21+
The [`create_decoy_func` method][decoy.Decoy.create_decoy_func] is used to create a mock function instance. Decoy can inspect a `spec` function signature to automatically configure the function as syncronous or asynchronous. Otherwise, the `is_async` argument can force the mock to be asynchronous.
22+
23+
To typecheckers, the mock will appear to have the exact same type as the `spec` argument, if used.
24+
25+
```python
26+
def test_my_thing(decoy: Decoy) -> None:
27+
mock_function = decoy.create_decoy_func(spec=some_function)
28+
free_async_function = decoy.create_decoy_func(is_async=True)
29+
```

docs/usage/matchers.md

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
# Loosening assertions with matchers
2+
3+
Sometimes, when you're stubbing or verifying 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 dependency is called with a string, but you don't care about the full contents of that string.
4+
5+
Decoy includes a [`matchers` module][decoy.matchers], which has a set of Python classes with `__eq__` methods defined that you can use in rehearsals and/or assertions in place of actual values
6+
7+
## Basic usage
8+
9+
To use, import `decoy.matchers` and use a matcher wherever you would normally use a value.
10+
11+
```python
12+
import pytest
13+
from typing import cast, Optional
14+
from decoy import Decoy, matchers
15+
16+
from .logger import Logger
17+
from .my_thing import MyThing
18+
19+
def test_log_warning(decoy: Decoy):
20+
logger = decoy.create_decoy(spec=Logger)
21+
22+
subject = MyThing(logger=logger)
23+
24+
# call code under test
25+
subject.log_warning("Oh no, something went wrong with request abc123efg456")
26+
27+
# verify double called correctly
28+
decoy.verify(
29+
logger.warn(matchers.StringMatching("request abc123efg456"))
30+
)
31+
```
32+
33+
## Capturing values
34+
35+
When testing certain APIs, especially callback APIs, it can be helpful to capture the values of arguments passed to a given dependency. For this, Decoy provides [`matchers.Captor`][decoy.matchers.captor].
36+
37+
For example, our test subject may register an event listener handler, and we want to test our subject's behavior when the event listener is triggered.
38+
39+
```py
40+
import pytest
41+
from typing import cast, Optional
42+
from decoy import Decoy, matchers
43+
44+
from .event_source import EventSource
45+
from .event_consumer import EventConsumer
46+
47+
48+
def test_event_listener(decoy: Decoy):
49+
event_source = decoy.create_decoy(spec=EventSource)
50+
subject = EventConsumer(event_source=event_source)
51+
captor = matchers.Captor()
52+
53+
# subject registers its listener when started
54+
subject.start_consuming()
55+
56+
# verify listener attached and capture the listener
57+
decoy.verify(event_source.register(event_listener=captor))
58+
59+
# trigger the listener
60+
event_handler = captor.value # or, equivalently, captor.values[0]
61+
62+
assert subject.has_heard_event is False
63+
event_handler()
64+
assert subject.has_heard_event is True
65+
```
66+
67+
This is a pretty verbose way of writing a test, so in general, you may want to approach using `matchers.captor` as a form of potential code smell / test pain. There are often better ways to structure your code for these sorts of interactions that don't involve private functions.
68+
69+
For further reading on when (or rather, when not) to use argument captors, check out [testdouble's documentation on its argument captor matcher](https://github.com/testdouble/testdouble.js/blob/main/docs/6-verifying-invocations.md#tdmatcherscaptor).

0 commit comments

Comments
 (0)