Skip to content

Commit 01fb271

Browse files
authored
feat(when): add then_do API for triggering a callback (#39)
Closes #31
1 parent 8494ebf commit 01fb271

File tree

8 files changed

+173
-23
lines changed

8 files changed

+173
-23
lines changed

decoy/__init__.py

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -159,15 +159,16 @@ def reset(self) -> None:
159159

160160

161161
class Stub(Generic[ReturnT]):
162-
"""A rehearsed Stub that can be used to configure mock behaviors."""
162+
"""A rehearsed Stub that can be used to configure mock behaviors.
163+
164+
See [stubbing usage guide](../usage/when) for more details.
165+
"""
163166

164167
def __init__(self, core: StubCore) -> None:
165168
self._core = core
166169

167170
def then_return(self, *values: ReturnT) -> None:
168-
"""Set the stub to return value(s).
169-
170-
See [stubbing usage guide](../usage/when) for more details.
171+
"""Configure the stub to return value(s).
171172
172173
Arguments:
173174
*values: Zero or more return values. Multiple values will result
@@ -177,9 +178,7 @@ def then_return(self, *values: ReturnT) -> None:
177178
self._core.then_return(*values)
178179

179180
def then_raise(self, error: Exception) -> None:
180-
"""Set the stub to raise an error.
181-
182-
See [stubbing usage guide](../usage/when) for more details.
181+
"""Configure the stub to raise an error.
183182
184183
Arguments:
185184
error: The error to raise.
@@ -191,5 +190,14 @@ def then_raise(self, error: Exception) -> None:
191190
"""
192191
self._core.then_raise(error)
193192

193+
def then_do(self, action: Callable[..., ReturnT]) -> None:
194+
"""Configure the stub to trigger an action.
195+
196+
Arguments:
197+
action: The function to call. Called with whatever arguments
198+
are actually passed to the stub.
199+
"""
200+
self._core.then_do(action)
201+
194202

195203
__all__ = ["Decoy", "Stub", "matchers", "warnings", "errors"]

decoy/call_handler.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,4 +22,7 @@ def handle(self, call: SpyCall) -> Any:
2222
if behavior.error:
2323
raise behavior.error
2424

25+
if behavior.action:
26+
return behavior.action(*call.args, **call.kwargs)
27+
2528
return behavior.return_value

decoy/core.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"""Decoy implementation logic."""
22
from __future__ import annotations
3-
from typing import Any, Optional
3+
from typing import Any, Callable, Optional
44

55
from .spy import SpyConfig, SpyFactory, create_spy as default_create_spy
66
from .spy_calls import WhenRehearsal
@@ -89,3 +89,10 @@ def then_raise(self, error: Exception) -> None:
8989
rehearsal=self._rehearsal,
9090
behavior=StubBehavior(error=error),
9191
)
92+
93+
def then_do(self, action: Callable[..., ReturnT]) -> None:
94+
"""Set the stub to perform an action."""
95+
self._stub_store.add(
96+
rehearsal=self._rehearsal,
97+
behavior=StubBehavior(action=action),
98+
)

decoy/stub_store.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
"""Stub creation and storage."""
2-
from typing import Any, List, NamedTuple, Optional
2+
from typing import Any, Callable, List, NamedTuple, Optional
33

44
from .spy_calls import SpyCall, WhenRehearsal
55

@@ -9,6 +9,7 @@ class StubBehavior(NamedTuple):
99

1010
return_value: Optional[Any] = None
1111
error: Optional[Exception] = None
12+
action: Optional[Callable[..., Any]] = None
1213
once: bool = False
1314

1415

docs/usage/when.md

Lines changed: 85 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,45 +2,114 @@
22

33
A stub is a mock that is pre-configured to return a result or raise an error if called according to a specification. In Decoy, you use [decoy.Decoy.when][] to configure stubs.
44

5-
## Using rehearsals to return a value
5+
## Configuring a stub
66

7-
`Decoy.when` uses a "rehearsal" syntax to configure a stub's conditions:
7+
`Decoy.when` uses a "rehearsal" syntax to configure a stub's conditions. To configure a stubbed behavior, form the expected call to the mock and wrap it in `when`:
88

99
```python
1010
def test_my_thing(decoy: Decoy) -> None:
1111
database = decoy.mock(cls=Database)
1212
subject = MyThing(database=database)
1313

14-
decoy.when(database.get("some-id")).then_return(Model(id="some-id"))
15-
16-
result = subject.get_model_by_id("some-id")
17-
18-
assert result == Model(id="some-id")
14+
stub = decoy.when(
15+
database.get("some-id") # <-- rehearsal
16+
)
17+
...
1918
```
2019

21-
The "rehearsal" is simply a call to the stub wrapped inside `decoy.when`. Decoy is able to differentiate between rehearsal calls and actual calls. If the mock is called later **in exactly the same way as a rehearsal**, it will behave as configured. If you need to loosen the "exact argument match" behavior, see [matchers](./matchers).
20+
Any time your dependency is called **in exactly the same way as the rehearsal**, whatever stub behaviors you configure will be triggered. If you need to loosen the "exact argument match" behavior, you can use [matchers](./matchers).
2221

2322
The "rehearsal" API gives us the following benefits:
2423

25-
- Your test double will only return the value **if it is called correctly**
24+
- Your test double will only take action **if it is called correctly**
2625
- Therefore, you avoid separate "configure return" and "assert called" steps
2726
- If you use type annotations, you get typechecking for free
2827

28+
## Returning a value
29+
30+
To configure a return value, use [decoy.Stub.then_return][]:
31+
32+
```python
33+
def test_my_thing(decoy: Decoy) -> None:
34+
database = decoy.mock(cls=Database)
35+
subject = MyThing(database=database)
36+
37+
decoy.when(
38+
database.get("some-id") # <-- when `database.get` is called with "some-id"
39+
).then_return(
40+
Model(id="some-id") # <-- then return the value `Model(id="some-id")`
41+
)
42+
43+
result = subject.get_model_by_id("some-id")
44+
45+
assert result == Model(id="some-id")
46+
```
47+
48+
The value that you pass to `then_return` will be type-checked.
49+
2950
## Raising an error
3051

31-
You can configure your stub to raise an error if called in a certain way:
52+
To configure a raised exception when called, use [decoy.Stub.then_raise][]:
3253

3354
```python
3455
def test_my_thing_when_database_raises(decoy: Decoy) -> None:
3556
database = decoy.mock(cls=Database)
3657
subject = MyThing(database=database)
3758

38-
decoy.when(database.get("foo")).then_raise(KeyError(f"foo does not exist"))
59+
decoy.when(
60+
database.get("foo") # <-- when `database.get` is called with "foo"
61+
).then_raise(
62+
KeyError("foo does not exist") # <-- then raise a KeyError
63+
)
3964

4065
with pytest.raises(KeyError):
4166
subject.get_model_by_id("foo")
4267
```
4368

69+
**Note:** configuring a stub to raise will **make future rehearsals with the same arguments raise.** If you must configure a new behavior after a raise, use a `try/except` block:
70+
71+
```python
72+
decoy.when(database.get("foo")).then_raise(KeyError("oh no"))
73+
74+
# ...later
75+
76+
try:
77+
database.get("foo")
78+
except Exception:
79+
pass
80+
finally:
81+
# even though `database.get` is not inside the `when`, Decoy
82+
# will pop the last call off its stack to use as the rehearsal
83+
decoy.when().then_return("hurray!")
84+
```
85+
86+
## Performing an action
87+
88+
For complex situations, you may find that you want your stub to trigger a side-effect when called. For this, use [decoy.Stub.then_do][].
89+
90+
This is a powerful feature, and if you find yourself reaching for it, you should first consider if your code under test can be reorganized to be tested in a more straightforward manner.
91+
92+
```python
93+
def test_my_thing_when_database_raises(decoy: Decoy) -> None:
94+
database = decoy.mock(cls=Database)
95+
subject = MyThing(database=database)
96+
97+
def _side_effect(key):
98+
print(f"Getting {key}")
99+
return Model(id=key)
100+
101+
decoy.when(
102+
database.get("foo") # <-- when `database.get` is called with "foo"
103+
).then_do(
104+
_side_effect # <-- then run `_side_effect`
105+
)
106+
107+
with pytest.raises(KeyError):
108+
subject.get_model_by_id("foo")
109+
```
110+
111+
The action function passed to `then_do` will be passed any arguments given to the stub, and the stub will return whatever value is returned by the action.
112+
44113
## Stubbing with async/await
45114

46115
If your dependency uses async/await, simply add `await` to the rehearsal:
@@ -51,7 +120,11 @@ async def test_my_async_thing(decoy: Decoy) -> None:
51120
database = decoy.mock(cls=Database)
52121
subject = MyThing(database=database)
53122

54-
decoy.when(await database.get("some-id")).then_return(Model(id="some-id"))
123+
decoy.when(
124+
await database.get("some-id") # <-- when database.get(...) is awaited
125+
).then_return(
126+
Model(id="some-id") # <-- then return a value
127+
)
55128

56129
result = await subject.get_model_by_id("some-id")
57130

tests/test_call_handler.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ def test_handle_call_with_raise(
7575
stub_store: StubStore,
7676
subject: CallHandler,
7777
) -> None:
78-
"""It return a Stub's configured return value."""
78+
"""It raise a Stub's configured error."""
7979
spy_call = SpyCall(spy_id=42, spy_name="spy_name", args=(), kwargs={})
8080
behavior = StubBehavior(error=RuntimeError("oh no"))
8181

@@ -85,3 +85,22 @@ def test_handle_call_with_raise(
8585
subject.handle(spy_call)
8686

8787
decoy.verify(call_stack.push(spy_call))
88+
89+
90+
def test_handle_call_with_action(
91+
decoy: Decoy,
92+
call_stack: CallStack,
93+
stub_store: StubStore,
94+
subject: CallHandler,
95+
) -> None:
96+
"""It should trigger a stub's configured action."""
97+
action = decoy.mock()
98+
spy_call = SpyCall(spy_id=42, spy_name="spy_name", args=(1,), kwargs={"foo": "bar"})
99+
behavior = StubBehavior(action=action)
100+
101+
decoy.when(stub_store.get_by_call(spy_call)).then_return(behavior)
102+
decoy.when(action(1, foo="bar")).then_return("hello world")
103+
104+
result = subject.handle(spy_call)
105+
106+
assert result == "hello world"

tests/test_core.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,28 @@ def test_when_then_raise(
175175
)
176176

177177

178+
def test_when_then_do(
179+
decoy: Decoy,
180+
call_stack: CallStack,
181+
stub_store: StubStore,
182+
subject: DecoyCore,
183+
) -> None:
184+
"""It should add an action behavior to a stub."""
185+
rehearsal = WhenRehearsal(spy_id=1, spy_name="my_spy", args=(), kwargs={})
186+
decoy.when(call_stack.consume_when_rehearsal()).then_return(rehearsal)
187+
188+
action = lambda: "hello world" # noqa: E731
189+
result = subject.when("__rehearsal__")
190+
result.then_do(action)
191+
192+
decoy.verify(
193+
stub_store.add(
194+
rehearsal=rehearsal,
195+
behavior=StubBehavior(action=action),
196+
)
197+
)
198+
199+
178200
def test_verify(
179201
decoy: Decoy,
180202
call_stack: CallStack,

tests/test_decoy.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,11 +50,28 @@ def test_when_smoke_test(decoy: Decoy) -> None:
5050
subject = decoy.mock(func=some_func)
5151

5252
decoy.when(subject("hello")).then_return("hello world")
53+
decoy.when(subject("goodbye")).then_raise(ValueError("oh no"))
54+
55+
action_result = None
56+
57+
def _then_do_action(arg: str) -> str:
58+
nonlocal action_result
59+
action_result = arg
60+
return "hello from the other side"
61+
62+
decoy.when(subject("what's up")).then_do(_then_do_action)
5363

5464
result = subject("hello")
5565
assert result == "hello world"
5666

57-
result = subject("goodbye")
67+
with pytest.raises(ValueError, match="oh no"):
68+
subject("goodbye")
69+
70+
result = subject("what's up")
71+
assert action_result == "what's up"
72+
assert result == "hello from the other side"
73+
74+
result = subject("asdfghjkl")
5875
assert result is None
5976

6077

0 commit comments

Comments
 (0)