Skip to content

Commit

Permalink
feat: add property mocking (#121)
Browse files Browse the repository at this point in the history
  • Loading branch information
mcous authored Mar 12, 2022
1 parent b2d373a commit 5ee2de7
Show file tree
Hide file tree
Showing 29 changed files with 2,044 additions and 589 deletions.
85 changes: 74 additions & 11 deletions decoy/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
ContextManager,
GeneratorContextManager,
)
from .core import DecoyCore, StubCore
from .core import DecoyCore, StubCore, PropCore
from .types import ClassT, ContextValueT, FuncT, ReturnT

# ensure decoy does not pollute pytest tracebacks
Expand All @@ -19,7 +19,7 @@ class Decoy:
"""Decoy mock factory and state container.
You should create a new Decoy instance before each test and call
[reset][decoy.Decoy.reset] after each test. If you use the
[`reset`][decoy.Decoy.reset] after each test. If you use the
[`decoy` pytest fixture][decoy.pytest_plugin.decoy], this is done
automatically. See the [setup guide](../#setup) for more details.
Expand Down Expand Up @@ -92,7 +92,7 @@ def create_decoy(
"""Create a class mock for `spec`.
!!! warning "Deprecated since v1.6.0"
Use [decoy.Decoy.mock][] with the `cls` parameter, instead.
Use [`mock`][decoy.Decoy.mock] with the `cls` parameter, instead.
"""
warn(
"decoy.create_decoy is deprecated; use decoy.mock(cls=...) instead.",
Expand All @@ -111,7 +111,7 @@ def create_decoy_func(
"""Create a function mock for `spec`.
!!! warning "Deprecated since v1.6.0"
Use [decoy.Decoy.mock][] with the `func` parameter, instead.
Use [`mock`][decoy.Decoy.mock] with the `func` parameter, instead.
"""
warn(
"decoy.create_decoy_func is deprecated; use decoy.mock(func=...) instead.",
Expand All @@ -127,7 +127,7 @@ def when(
*,
ignore_extra_args: bool = False,
) -> "Stub[ReturnT]":
"""Create a [Stub][decoy.Stub] configuration using a rehearsal call.
"""Create a [`Stub`][decoy.Stub] configuration using a rehearsal call.
See [stubbing usage guide](../usage/when/) for more details.
Expand All @@ -138,8 +138,9 @@ def when(
ignoring unspecified arguments.
Returns:
A stub to configure using `then_return`, `then_raise`, `then_do`, or
`then_enter_with`.
A stub to configure using [`then_return`][decoy.Stub.then_return],
[`then_raise`][decoy.Stub.then_raise], [`then_do`][decoy.Stub.then_do],
or [`then_enter_with`][decoy.Stub.then_enter_with].
Example:
```python
Expand Down Expand Up @@ -202,12 +203,27 @@ def test_create_something(decoy: Decoy):
ignore_extra_args=ignore_extra_args,
)

def prop(self, _rehearsal_result: ReturnT) -> "Prop[ReturnT]":
"""Create property setter and deleter rehearsals.
See [property mocking guide](../advanced/properties/) for more details.
Arguments:
_rehearsal_result: The property to mock, for typechecking.
Returns:
A prop rehearser on which you can call [`set`][decoy.Prop.set] or
[`delete`][decoy.Prop.delete] to create property rehearsals.
"""
prop_core = self._core.prop(_rehearsal_result)
return Prop(core=prop_core)

def reset(self) -> None:
"""Reset all mock state.
This method should be called after every test to ensure spies and stubs
don't leak between tests. The `decoy` fixture provided by the pytest plugin
will call `reset` automatically.
don't leak between tests. The [`decoy`][decoy.pytest_plugin.decoy] fixture
provided by the pytest plugin will call `reset` automatically.
The `reset` method may also trigger warnings if Decoy detects any questionable
mock usage. See [decoy.warnings][] for more details.
Expand Down Expand Up @@ -243,7 +259,8 @@ def then_raise(self, error: Exception) -> None:
Note:
Setting a stub to raise will prevent you from writing new
rehearsals, because they will raise. If you need to make more calls
to `when`, you'll need to wrap your rehearsal in a `try`.
to [`when`][decoy.Decoy.when], you'll need to wrap your rehearsal
in a `try`.
"""
self._core.then_raise(error)

Expand Down Expand Up @@ -299,4 +316,50 @@ def then_enter_with(
self._core.then_enter_with(value)


__all__ = ["Decoy", "Stub", "matchers", "warnings", "errors"]
class Prop(Generic[ReturnT]):
"""Rehearsal creator for mocking property setters and deleters.
See [property mocking guide](../advanced/properties/) for more details.
"""

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

def set(self, value: ReturnT) -> None:
"""Create a property setter rehearsal.
By wrapping `set` in a call to [`when`][decoy.Decoy.when] or
[`verify`][decoy.Decoy.verify], you can stub or verify a call
to a property setter.
Arguments:
value: The value
Example:
```python
some_obj = decoy.mock()
some_obj.prop = 42
decoy.verify(decoy.prop(some_obj.prop).set(42))
```
"""
self._core.set(value)

def delete(self) -> None:
"""Create a property deleter rehearsal.
By wrapping `delete` in a call to [`when`][decoy.Decoy.when] or
[`verify`][decoy.Decoy.verify], you can stub or verify a call
to a property deleter.
Example:
```python
some_obj = decoy.mock()
del some_obj.prop
decoy.verify(decoy.prop(some_obj.prop).delete())
```
"""
self._core.delete()


__all__ = ["Decoy", "Stub", "Prop", "matchers", "warnings", "errors"]
33 changes: 25 additions & 8 deletions decoy/call_handler.py
Original file line number Diff line number Diff line change
@@ -1,21 +1,27 @@
"""Spy call handling."""
from typing import Any
from typing import Any, NamedTuple, Optional

from .spy_log import SpyLog
from .context_managers import ContextWrapper
from .spy_calls import SpyCall
from .spy_events import SpyCall, SpyEvent
from .stub_store import StubStore


class CallHandlerResult(NamedTuple):
"""A return value from a call."""

value: Any


class CallHandler:
"""An interface to handle calls to spies."""

def __init__(self, spy_log: SpyLog, stub_store: StubStore) -> None:
"""Initialize the CallHandler with access to SpyCalls and Stubs."""
"""Initialize the CallHandler with access to SpyEvents and Stubs."""
self._spy_log = spy_log
self._stub_store = stub_store

def handle(self, call: SpyCall) -> Any:
def handle(self, call: SpyEvent) -> Optional[CallHandlerResult]:
"""Handle a Spy's call, triggering stub behavior if necessary."""
behavior = self._stub_store.get_by_call(call)
self._spy_log.push(call)
Expand All @@ -26,10 +32,21 @@ def handle(self, call: SpyCall) -> Any:
if behavior.error:
raise behavior.error

return_value: Any

if behavior.action:
return behavior.action(*call.args, **call.kwargs)
if isinstance(call.payload, SpyCall):
return_value = behavior.action(
*call.payload.args,
**call.payload.kwargs,
)
else:
return_value = behavior.action()

elif behavior.context_value:
return_value = ContextWrapper(behavior.context_value)

if behavior.context_value:
return ContextWrapper(behavior.context_value)
else:
return_value = behavior.return_value

return behavior.return_value
return CallHandlerResult(return_value)
56 changes: 54 additions & 2 deletions decoy/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

from .call_handler import CallHandler
from .spy import SpyCreator
from .spy_calls import WhenRehearsal
from .spy_events import WhenRehearsal, PropAccessType, SpyEvent, SpyPropAccess
from .spy_log import SpyLog
from .stub_store import StubBehavior, StubStore
from .types import ContextValueT, ReturnT
Expand Down Expand Up @@ -65,10 +65,21 @@ def verify(
count=len(_rehearsals),
ignore_extra_args=ignore_extra_args,
)
calls = self._spy_log.get_by_rehearsals(rehearsals)
calls = self._spy_log.get_calls_to_verify([r.spy_id for r in rehearsals])

self._verifier.verify(rehearsals=rehearsals, calls=calls, times=times)

def prop(self, _rehearsal: ReturnT) -> "PropCore":
"""Get a property setter/deleter rehearser."""
spy_id, spy_name, payload = self._spy_log.consume_prop_rehearsal()

return PropCore(
spy_id=spy_id,
spy_name=spy_name,
prop_name=payload.prop_name,
spy_log=self._spy_log,
)

def reset(self) -> None:
"""Reset and remove all stored spies and stubs."""
calls = self._spy_log.get_all()
Expand Down Expand Up @@ -116,3 +127,44 @@ def then_enter_with(self, value: ContextValueT) -> None:
rehearsal=self._rehearsal,
behavior=StubBehavior(context_value=value),
)


class PropCore:
"""Main logic of a property access rehearser."""

def __init__(
self,
spy_id: int,
spy_name: str,
prop_name: str,
spy_log: SpyLog,
) -> None:
self._spy_id = spy_id
self._spy_name = spy_name
self._prop_name = prop_name
self._spy_log = spy_log

def set(self, value: Any) -> None:
"""Create a property setter rehearsal."""
event = SpyEvent(
spy_id=self._spy_id,
spy_name=self._spy_name,
payload=SpyPropAccess(
prop_name=self._prop_name,
access_type=PropAccessType.SET,
value=value,
),
)
self._spy_log.push(event)

def delete(self) -> None:
"""Create a property deleter rehearsal."""
event = SpyEvent(
spy_id=self._spy_id,
spy_name=self._spy_name,
payload=SpyPropAccess(
prop_name=self._prop_name,
access_type=PropAccessType.DELETE,
),
)
self._spy_log.push(event)
17 changes: 9 additions & 8 deletions decoy/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,24 +6,25 @@
"""
from typing import Optional, Sequence

from .spy_calls import SpyCall, VerifyRehearsal
from .spy_events import SpyEvent, VerifyRehearsal
from .stringify import count, stringify_error_message


class MissingRehearsalError(ValueError):
"""An error raised when `when` or `verify` is called without rehearsal(s).
"""An error raised when a Decoy method is called without rehearsal(s).
This error is raised if you use Decoy incorrectly in your tests. When
using async/await, this error can be triggered if you forget to include
`await` with your rehearsal.
This error is raised if you use [`when`][decoy.Decoy.when],
[`verify`][decoy.Decoy.verify], or [`prop`][decoy.Decoy.prop] incorrectly
in your tests. When using async/await, this error can be triggered if you
forget to include `await` with your rehearsal.
See the [MissingRehearsalError guide][] for more details.
[MissingRehearsalError guide]: ../usage/errors-and-warnings/#missingrehearsalerror
"""

def __init__(self) -> None:
super().__init__("Rehearsal not found for when/verify.")
super().__init__("Rehearsal not found.")


class VerifyError(AssertionError):
Expand All @@ -40,13 +41,13 @@ class VerifyError(AssertionError):
"""

rehearsals: Sequence[VerifyRehearsal]
calls: Sequence[SpyCall]
calls: Sequence[SpyEvent]
times: Optional[int]

def __init__(
self,
rehearsals: Sequence[VerifyRehearsal],
calls: Sequence[SpyCall],
calls: Sequence[SpyEvent],
times: Optional[int],
) -> None:
if times is not None:
Expand Down
Loading

0 comments on commit 5ee2de7

Please sign in to comment.