Skip to content

Commit

Permalink
feat(when): add then_do API for triggering a callback (#39)
Browse files Browse the repository at this point in the history
Closes #31
  • Loading branch information
mcous authored Jul 12, 2021
1 parent 8494ebf commit 01fb271
Show file tree
Hide file tree
Showing 8 changed files with 173 additions and 23 deletions.
22 changes: 15 additions & 7 deletions decoy/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -159,15 +159,16 @@ def reset(self) -> None:


class Stub(Generic[ReturnT]):
"""A rehearsed Stub that can be used to configure mock behaviors."""
"""A rehearsed Stub that can be used to configure mock behaviors.
See [stubbing usage guide](../usage/when) for more details.
"""

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

def then_return(self, *values: ReturnT) -> None:
"""Set the stub to return value(s).
See [stubbing usage guide](../usage/when) for more details.
"""Configure the stub to return value(s).
Arguments:
*values: Zero or more return values. Multiple values will result
Expand All @@ -177,9 +178,7 @@ def then_return(self, *values: ReturnT) -> None:
self._core.then_return(*values)

def then_raise(self, error: Exception) -> None:
"""Set the stub to raise an error.
See [stubbing usage guide](../usage/when) for more details.
"""Configure the stub to raise an error.
Arguments:
error: The error to raise.
Expand All @@ -191,5 +190,14 @@ def then_raise(self, error: Exception) -> None:
"""
self._core.then_raise(error)

def then_do(self, action: Callable[..., ReturnT]) -> None:
"""Configure the stub to trigger an action.
Arguments:
action: The function to call. Called with whatever arguments
are actually passed to the stub.
"""
self._core.then_do(action)


__all__ = ["Decoy", "Stub", "matchers", "warnings", "errors"]
3 changes: 3 additions & 0 deletions decoy/call_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,7 @@ def handle(self, call: SpyCall) -> Any:
if behavior.error:
raise behavior.error

if behavior.action:
return behavior.action(*call.args, **call.kwargs)

return behavior.return_value
9 changes: 8 additions & 1 deletion decoy/core.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"""Decoy implementation logic."""
from __future__ import annotations
from typing import Any, Optional
from typing import Any, Callable, Optional

from .spy import SpyConfig, SpyFactory, create_spy as default_create_spy
from .spy_calls import WhenRehearsal
Expand Down Expand Up @@ -89,3 +89,10 @@ def then_raise(self, error: Exception) -> None:
rehearsal=self._rehearsal,
behavior=StubBehavior(error=error),
)

def then_do(self, action: Callable[..., ReturnT]) -> None:
"""Set the stub to perform an action."""
self._stub_store.add(
rehearsal=self._rehearsal,
behavior=StubBehavior(action=action),
)
3 changes: 2 additions & 1 deletion decoy/stub_store.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
"""Stub creation and storage."""
from typing import Any, List, NamedTuple, Optional
from typing import Any, Callable, List, NamedTuple, Optional

from .spy_calls import SpyCall, WhenRehearsal

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

return_value: Optional[Any] = None
error: Optional[Exception] = None
action: Optional[Callable[..., Any]] = None
once: bool = False


Expand Down
97 changes: 85 additions & 12 deletions docs/usage/when.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,45 +2,114 @@

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.

## Using rehearsals to return a value
## Configuring a stub

`Decoy.when` uses a "rehearsal" syntax to configure a stub's conditions:
`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`:

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

decoy.when(database.get("some-id")).then_return(Model(id="some-id"))

result = subject.get_model_by_id("some-id")

assert result == Model(id="some-id")
stub = decoy.when(
database.get("some-id") # <-- rehearsal
)
...
```

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).
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).

The "rehearsal" API gives us the following benefits:

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

## Returning a value

To configure a return value, use [decoy.Stub.then_return][]:

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

decoy.when(
database.get("some-id") # <-- when `database.get` is called with "some-id"
).then_return(
Model(id="some-id") # <-- then return the value `Model(id="some-id")`
)

result = subject.get_model_by_id("some-id")

assert result == Model(id="some-id")
```

The value that you pass to `then_return` will be type-checked.

## Raising an error

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

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

decoy.when(database.get("foo")).then_raise(KeyError(f"foo does not exist"))
decoy.when(
database.get("foo") # <-- when `database.get` is called with "foo"
).then_raise(
KeyError("foo does not exist") # <-- then raise a KeyError
)

with pytest.raises(KeyError):
subject.get_model_by_id("foo")
```

**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:

```python
decoy.when(database.get("foo")).then_raise(KeyError("oh no"))

# ...later

try:
database.get("foo")
except Exception:
pass
finally:
# even though `database.get` is not inside the `when`, Decoy
# will pop the last call off its stack to use as the rehearsal
decoy.when().then_return("hurray!")
```

## Performing an action

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][].

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.

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

def _side_effect(key):
print(f"Getting {key}")
return Model(id=key)

decoy.when(
database.get("foo") # <-- when `database.get` is called with "foo"
).then_do(
_side_effect # <-- then run `_side_effect`
)

with pytest.raises(KeyError):
subject.get_model_by_id("foo")
```

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.

## Stubbing with async/await

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

decoy.when(await database.get("some-id")).then_return(Model(id="some-id"))
decoy.when(
await database.get("some-id") # <-- when database.get(...) is awaited
).then_return(
Model(id="some-id") # <-- then return a value
)

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

Expand Down
21 changes: 20 additions & 1 deletion tests/test_call_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ def test_handle_call_with_raise(
stub_store: StubStore,
subject: CallHandler,
) -> None:
"""It return a Stub's configured return value."""
"""It raise a Stub's configured error."""
spy_call = SpyCall(spy_id=42, spy_name="spy_name", args=(), kwargs={})
behavior = StubBehavior(error=RuntimeError("oh no"))

Expand All @@ -85,3 +85,22 @@ def test_handle_call_with_raise(
subject.handle(spy_call)

decoy.verify(call_stack.push(spy_call))


def test_handle_call_with_action(
decoy: Decoy,
call_stack: CallStack,
stub_store: StubStore,
subject: CallHandler,
) -> None:
"""It should trigger a stub's configured action."""
action = decoy.mock()
spy_call = SpyCall(spy_id=42, spy_name="spy_name", args=(1,), kwargs={"foo": "bar"})
behavior = StubBehavior(action=action)

decoy.when(stub_store.get_by_call(spy_call)).then_return(behavior)
decoy.when(action(1, foo="bar")).then_return("hello world")

result = subject.handle(spy_call)

assert result == "hello world"
22 changes: 22 additions & 0 deletions tests/test_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,28 @@ def test_when_then_raise(
)


def test_when_then_do(
decoy: Decoy,
call_stack: CallStack,
stub_store: StubStore,
subject: DecoyCore,
) -> None:
"""It should add an action behavior to a stub."""
rehearsal = WhenRehearsal(spy_id=1, spy_name="my_spy", args=(), kwargs={})
decoy.when(call_stack.consume_when_rehearsal()).then_return(rehearsal)

action = lambda: "hello world" # noqa: E731
result = subject.when("__rehearsal__")
result.then_do(action)

decoy.verify(
stub_store.add(
rehearsal=rehearsal,
behavior=StubBehavior(action=action),
)
)


def test_verify(
decoy: Decoy,
call_stack: CallStack,
Expand Down
19 changes: 18 additions & 1 deletion tests/test_decoy.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,11 +50,28 @@ def test_when_smoke_test(decoy: Decoy) -> None:
subject = decoy.mock(func=some_func)

decoy.when(subject("hello")).then_return("hello world")
decoy.when(subject("goodbye")).then_raise(ValueError("oh no"))

action_result = None

def _then_do_action(arg: str) -> str:
nonlocal action_result
action_result = arg
return "hello from the other side"

decoy.when(subject("what's up")).then_do(_then_do_action)

result = subject("hello")
assert result == "hello world"

result = subject("goodbye")
with pytest.raises(ValueError, match="oh no"):
subject("goodbye")

result = subject("what's up")
assert action_result == "what's up"
assert result == "hello from the other side"

result = subject("asdfghjkl")
assert result is None


Expand Down

0 comments on commit 01fb271

Please sign in to comment.