diff --git a/decoy/__init__.py b/decoy/__init__.py index eea7e12..06641d6 100644 --- a/decoy/__init__.py +++ b/decoy/__init__.py @@ -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 @@ -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. @@ -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"] diff --git a/decoy/call_handler.py b/decoy/call_handler.py index e0e8939..cbf72ca 100644 --- a/decoy/call_handler.py +++ b/decoy/call_handler.py @@ -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 diff --git a/decoy/core.py b/decoy/core.py index 8c3a7a7..76fff8e 100644 --- a/decoy/core.py +++ b/decoy/core.py @@ -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 @@ -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), + ) diff --git a/decoy/stub_store.py b/decoy/stub_store.py index be1ba6a..e11f515 100644 --- a/decoy/stub_store.py +++ b/decoy/stub_store.py @@ -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 @@ -9,6 +9,7 @@ class StubBehavior(NamedTuple): return_value: Optional[Any] = None error: Optional[Exception] = None + action: Optional[Callable[..., Any]] = None once: bool = False diff --git a/docs/usage/when.md b/docs/usage/when.md index 18cc766..a6c6028 100644 --- a/docs/usage/when.md +++ b/docs/usage/when.md @@ -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: @@ -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") diff --git a/tests/test_call_handler.py b/tests/test_call_handler.py index 612c89e..c70298c 100644 --- a/tests/test_call_handler.py +++ b/tests/test_call_handler.py @@ -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")) @@ -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" diff --git a/tests/test_core.py b/tests/test_core.py index b877ef5..dc6ceeb 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -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, diff --git a/tests/test_decoy.py b/tests/test_decoy.py index 5b2b590..9b03bb1 100644 --- a/tests/test_decoy.py +++ b/tests/test_decoy.py @@ -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