Repository: mcous/decoy (language: python)

Issue:
feature: mock out property access
## Overview

It is relatively common in Python classes to use `@property` methods as computed value getters. In this usage, mocking a return value would be an appropriate thing to do in a test, but Decoy does not support it.

Explore adding property method mocking to Decoy stubs


Discussion / hints:
After some exploration and thinking, I don't think property setters / getters / deleters are a good match for Decoy. If a property needs to be mocked out, one can replace it on the spy object manually:

```python
spy = decoy.mock(cls=SomeClass)
spy.some_property = "some-value"
```
Upon even further thinking, I think mocking out property access is more useful than I initially thought, depending on the situation. I think an API along these lines could work well:

```py
from decoy import Decoy, prop

class DepThing:
    @property
    def x(self) -> int:
       ...

    @value.setter
    def x(self, value: int) -> None:
        ...

class Subject:
    def __init__(self, thing: DepThing) -> None:
        self._thing = thing

    def read(self) -> int:
        return self._thing.x

    def write(self, value: int) -> None:
        self._thing.x = value


def test_read(decoy: Decoy) -> None:
    thing = decoy.mock(cls=Thing)
    subject = Subject(thing=thing)
    
    decoy.when(prop.get(thing.x)).then_return(42)

    assert subject.read() == 42


def test_write(decoy: Decoy) -> None:
    thing = decoy.mock(cls=DepThing)
    subject = Subject(thing=thing)

    subject.write(42)

    decoy.verify(prop.set(thing.x, 42))
```

This API inspired by reading the [mimid](https://github.com/konradhalas/mimid) docs. Key details:

- `when` and `verify` can look for a special `prop` object and use that instead of popping a rehearsal
- A `Spy` can be configured to have saved property values to return from `__getattr__` instead of a child spy
- Property get/set/delete should be configurable to raise
- Property get should be configurable to return
- Will need to be careful about how this affects auto mocking of non-primitive properties
- Will need to issue a warning if `prop.set` (et. al.) used on a field that isn't defined in the spec class as a `@property`

Fix patch:
diff --git a/decoy/__init__.py b/decoy/__init__.py
index 2e1a796..7707003 100644
--- a/decoy/__init__.py
+++ b/decoy/__init__.py
@@ -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
@@ -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.
 
@@ -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.",
@@ -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.",
@@ -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.
 
@@ -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
@@ -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.
@@ -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)
 
@@ -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"]
diff --git a/decoy/call_handler.py b/decoy/call_handler.py
index 066488e..e07f45a 100644
--- a/decoy/call_handler.py
+++ b/decoy/call_handler.py
@@ -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)
@@ -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)
diff --git a/decoy/core.py b/decoy/core.py
index 7753e7b..43256ee 100644
--- a/decoy/core.py
+++ b/decoy/core.py
@@ -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
@@ -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()
@@ -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)
diff --git a/decoy/errors.py b/decoy/errors.py
index 4a5719e..944258f 100644
--- a/decoy/errors.py
+++ b/decoy/errors.py
@@ -6,16 +6,17 @@
 """
 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.
 
@@ -23,7 +24,7 @@ class MissingRehearsalError(ValueError):
     """
 
     def __init__(self) -> None:
-        super().__init__("Rehearsal not found for when/verify.")
+        super().__init__("Rehearsal not found.")
 
 
 class VerifyError(AssertionError):
@@ -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:
diff --git a/decoy/spy.py b/decoy/spy.py
index 011345a..b6ac7a8 100644
--- a/decoy/spy.py
+++ b/decoy/spy.py
@@ -8,7 +8,7 @@
 
 from .call_handler import CallHandler
 from .spec import Spec
-from .spy_calls import SpyCall
+from .spy_events import SpyCall, SpyEvent, SpyPropAccess, PropAccessType
 
 
 class BaseSpy(ContextManager[Any]):
@@ -18,6 +18,12 @@ class BaseSpy(ContextManager[Any]):
     - Lazily constructs child spies when an attribute is accessed
     """
 
+    _spec: Spec
+    _call_handler: CallHandler
+    _spy_creator: "SpyCreator"
+    _spy_children: Dict[str, "BaseSpy"]
+    _spy_property_values: Dict[str, Any]
+
     def __init__(
         self,
         spec: Spec,
@@ -25,11 +31,12 @@ def __init__(
         spy_creator: "SpyCreator",
     ) -> None:
         """Initialize a BaseSpy from a call handler and an optional spec object."""
-        self._spec = spec
-        self._call_handler = call_handler
-        self._spy_creator = spy_creator
-        self._spy_children: Dict[str, BaseSpy] = {}
-        self.__signature__ = self._spec.get_signature()
+        super().__setattr__("_spec", spec)
+        super().__setattr__("_call_handler", call_handler)
+        super().__setattr__("_spy_creator", spy_creator)
+        super().__setattr__("_spy_children", {})
+        super().__setattr__("_spy_property_values", {})
+        super().__setattr__("__signature__", self._spec.get_signature())
 
     @property  # type: ignore[misc]
     def __class__(self) -> Any:
@@ -78,8 +85,50 @@ def __getattr__(self, name: str) -> Any:
 
         return self._get_or_create_child_spy(name)
 
+    def __setattr__(self, name: str, value: Any) -> None:
+        """Set a property on the spy, recording the call."""
+        event = SpyEvent(
+            spy_id=id(self),
+            spy_name=self._spec.get_name(),
+            payload=SpyPropAccess(
+                prop_name=name,
+                access_type=PropAccessType.SET,
+                value=value,
+            ),
+        )
+        self._call_handler.handle(event)
+        self._spy_property_values[name] = value
+
+    def __delattr__(self, name: str) -> None:
+        """Delete a property on the spy, recording the call."""
+        event = SpyEvent(
+            spy_id=id(self),
+            spy_name=self._spec.get_name(),
+            payload=SpyPropAccess(prop_name=name, access_type=PropAccessType.DELETE),
+        )
+        self._call_handler.handle(event)
+        self._spy_property_values.pop(name, None)
+
     def _get_or_create_child_spy(self, name: str, child_is_async: bool = False) -> Any:
         """Lazily construct a child spy, basing it on type hints if available."""
+        # check for any stubbed behaviors for property getter
+        get_result = self._call_handler.handle(
+            SpyEvent(
+                spy_id=id(self),
+                spy_name=self._spec.get_name(),
+                payload=SpyPropAccess(
+                    prop_name=name,
+                    access_type=PropAccessType.GET,
+                ),
+            )
+        )
+
+        if get_result:
+            return get_result.value
+
+        if name in self._spy_property_values:
+            return self._spy_property_values[name]
+
         # return previously constructed (and cached) child spies
         if name in self._spy_children:
             return self._spy_children[name]
@@ -92,14 +141,17 @@ def _get_or_create_child_spy(self, name: str, child_is_async: bool = False) -> A
 
     def _call(self, *args: Any, **kwargs: Any) -> Any:
         bound_args, bound_kwargs = self._spec.bind_args(*args, **kwargs)
-        call = SpyCall(
+        call = SpyEvent(
             spy_id=id(self),
             spy_name=self._spec.get_name(),
-            args=bound_args,
-            kwargs=bound_kwargs,
+            payload=SpyCall(
+                args=bound_args,
+                kwargs=bound_kwargs,
+            ),
         )
 
-        return self._call_handler.handle(call)
+        result = self._call_handler.handle(call)
+        return result.value if result else None
 
 
 class Spy(BaseSpy):
diff --git a/decoy/spy_calls.py b/decoy/spy_calls.py
deleted file mode 100644
index 41a2fe6..0000000
--- a/decoy/spy_calls.py
+++ /dev/null
@@ -1,72 +0,0 @@
-"""Spy call value objects."""
-from typing import Any, Dict, NamedTuple, Tuple
-
-
-class BaseSpyCall(NamedTuple):
-    """A value object representing a call to a spy.
-
-    This base class should not be used directly.
-
-    Attributes:
-        spy_id: Identifier of the spy that made the call.
-        spy_name: String name of the spy.
-        args: Arguments list of the call.
-        kwargs: Keyword arguments list of the call.
-        ignore_extra_args: Whether extra arguments to the call should
-            be ignored during comparison. Only used by rehearsals.
-    """
-
-    spy_id: int
-    spy_name: str
-    args: Tuple[Any, ...]
-    kwargs: Dict[str, Any]
-    ignore_extra_args: bool = False
-
-
-class BaseSpyRehearsal(BaseSpyCall):
-    """A base class for rehearsals made to `when` or `verify`.
-
-    This base class should not be used directly.
-    """
-
-    pass
-
-
-class SpyCall(BaseSpyCall):
-    """An call made to the spy by the code under test."""
-
-    pass
-
-
-class WhenRehearsal(BaseSpyRehearsal):
-    """A call that has been used as a rehearsal for `when`."""
-
-    pass
-
-
-class VerifyRehearsal(BaseSpyRehearsal):
-    """A call that has been used as a rehearsal for `when`."""
-
-    pass
-
-
-def match_call(call: BaseSpyCall, rehearsal: BaseSpyRehearsal) -> bool:
-    """Check if a call matches a given rehearsal."""
-    if call.spy_id != rehearsal.spy_id:
-        return False
-
-    if rehearsal.ignore_extra_args:
-        try:
-            args_match = all(
-                call.args[i] == value for i, value in enumerate(rehearsal.args)
-            )
-            kwargs_match = all(
-                call.kwargs[key] == value for key, value in rehearsal.kwargs.items()
-            )
-
-            return args_match and kwargs_match
-
-        except (IndexError, KeyError):
-            return False
-
-    return call.args == rehearsal.args and call.kwargs == rehearsal.kwargs
diff --git a/decoy/spy_events.py b/decoy/spy_events.py
new file mode 100644
index 0000000..112df88
--- /dev/null
+++ b/decoy/spy_events.py
@@ -0,0 +1,94 @@
+"""Spy interaction event value objects."""
+import enum
+from typing import Any, Dict, NamedTuple, Optional, Tuple, Union
+
+
+class PropAccessType(str, enum.Enum):
+    """Property access type."""
+
+    GET = "get"
+    SET = "set"
+    DELETE = "delete"
+
+
+class SpyCall(NamedTuple):
+    """Spy event payload representing a call to a function or method."""
+
+    args: Tuple[Any, ...]
+    kwargs: Dict[str, Any]
+    ignore_extra_args: bool = False
+
+
+class SpyPropAccess(NamedTuple):
+    """Spy event payload representing a get/set/delete of a property."""
+
+    prop_name: str
+    access_type: PropAccessType
+    value: Optional[Any] = None
+
+
+class SpyEvent(NamedTuple):
+    """An interaction with a spy by the code under test."""
+
+    spy_id: int
+    spy_name: str
+    payload: Union[SpyCall, SpyPropAccess]
+
+
+class WhenRehearsal(NamedTuple):
+    """A spy interaction that has been used as a rehearsal for `when`."""
+
+    spy_id: int
+    spy_name: str
+    payload: Union[SpyCall, SpyPropAccess]
+
+
+class VerifyRehearsal(NamedTuple):
+    """A spy interaction that has been used as a rehearsal for `verify`."""
+
+    spy_id: int
+    spy_name: str
+    payload: Union[SpyCall, SpyPropAccess]
+
+
+class PropRehearsal(NamedTuple):
+    """A spy interaction that has been used as a rehearsal for `prop`."""
+
+    spy_id: int
+    spy_name: str
+    payload: SpyPropAccess
+
+
+SpyRehearsal = Union[WhenRehearsal, VerifyRehearsal, PropRehearsal]
+
+AnySpyEvent = Union[SpyEvent, SpyRehearsal]
+
+
+def match_event(event: AnySpyEvent, rehearsal: SpyRehearsal) -> bool:
+    """Check if a call matches a given rehearsal."""
+    if event.spy_id != rehearsal.spy_id:
+        return False
+
+    if (
+        isinstance(event.payload, SpyCall)
+        and isinstance(rehearsal.payload, SpyCall)
+        and rehearsal.payload.ignore_extra_args
+    ):
+        call = event.payload
+        rehearsed_call = rehearsal.payload
+
+        try:
+            args_match = all(
+                call.args[i] == value for i, value in enumerate(rehearsed_call.args)
+            )
+            kwargs_match = all(
+                call.kwargs[key] == value
+                for key, value in rehearsed_call.kwargs.items()
+            )
+
+            return args_match and kwargs_match
+
+        except (IndexError, KeyError):
+            return False
+
+    return event.payload == rehearsal.payload
diff --git a/decoy/spy_log.py b/decoy/spy_log.py
index e63b33f..c649268 100644
--- a/decoy/spy_log.py
+++ b/decoy/spy_log.py
@@ -2,18 +2,27 @@
 from typing import List, Sequence
 
 from .errors import MissingRehearsalError
-from .spy_calls import BaseSpyCall, SpyCall, WhenRehearsal, VerifyRehearsal
+from .spy_events import (
+    AnySpyEvent,
+    SpyCall,
+    SpyEvent,
+    SpyPropAccess,
+    WhenRehearsal,
+    VerifyRehearsal,
+    PropAccessType,
+    PropRehearsal,
+)
 
 
 class SpyLog:
     """Log of all Spy activities in the Decoy container."""
 
     def __init__(self) -> None:
-        self._stack: List[BaseSpyCall] = []
+        self._log: List[AnySpyEvent] = []
 
-    def push(self, spy_call: SpyCall) -> None:
+    def push(self, spy_call: AnySpyEvent) -> None:
         """Add a new spy call to the stack."""
-        self._stack.append(spy_call)
+        self._log.append(spy_call)
 
     def consume_when_rehearsal(self, ignore_extra_args: bool) -> WhenRehearsal:
         """Consume the last call to a Spy as a `when` rehearsal.
@@ -21,14 +30,16 @@ def consume_when_rehearsal(self, ignore_extra_args: bool) -> WhenRehearsal:
         This marks a call as a rehearsal but does not remove it from the stack.
         """
         try:
-            call = self._stack[-1]
+            event = self._log[-1]
         except IndexError:
             raise MissingRehearsalError()
-        if not isinstance(call, SpyCall):
+        if not isinstance(event, SpyEvent):
             raise MissingRehearsalError()
 
-        rehearsal = WhenRehearsal(*call)._replace(ignore_extra_args=ignore_extra_args)
-        self._stack[-1] = rehearsal
+        spy_id, spy_name, payload = _apply_ignore_extra_args(event, ignore_extra_args)
+
+        rehearsal = WhenRehearsal(spy_id=spy_id, spy_name=spy_name, payload=payload)
+        self._log[-1] = rehearsal
         return rehearsal
 
     def consume_verify_rehearsals(
@@ -40,31 +51,80 @@ def consume_verify_rehearsals(
 
         This marks calls as rehearsals but does not remove them from the stack.
         """
-        calls = self._stack[-count:]
+        # events = self._log[-count:]
+        rehearsals: List[VerifyRehearsal] = []
+        index = len(self._log) - 1
+
+        while len(rehearsals) < count:
+            if index < 0:
+                raise MissingRehearsalError()
+
+            event = self._log[index]
+
+            if not isinstance(event, (SpyEvent, PropRehearsal)):
+                raise MissingRehearsalError()
+
+            if _is_verifiable(event):
+                rehearsal = VerifyRehearsal(
+                    *_apply_ignore_extra_args(event, ignore_extra_args)
+                )
+                rehearsals.append(rehearsal)
+                self._log[index] = rehearsal
 
-        if len(calls) != count or not all(isinstance(call, SpyCall) for call in calls):
+            index = index - 1
+
+        return list(reversed(rehearsals))
+
+    def consume_prop_rehearsal(self) -> PropRehearsal:
+        """Consume the last property get as a rehearsal."""
+        try:
+            event = self._log[-1]
+        except IndexError:
             raise MissingRehearsalError()
 
-        rehearsals = [
-            VerifyRehearsal(*call)._replace(ignore_extra_args=ignore_extra_args)
-            for call in calls
-        ]
-        self._stack[-count:] = rehearsals
-        return rehearsals
+        spy_id, spy_name, payload = event
 
-    def get_by_rehearsals(self, rehearsals: Sequence[VerifyRehearsal]) -> List[SpyCall]:
+        if (
+            not isinstance(event, SpyEvent)
+            or not isinstance(payload, SpyPropAccess)
+            or payload.access_type != PropAccessType.GET
+        ):
+            raise MissingRehearsalError()
+
+        rehearsal = PropRehearsal(spy_id, spy_name, payload)
+        self._log[-1] = rehearsal
+        return rehearsal
+
+    def get_calls_to_verify(self, spy_ids: Sequence[int]) -> List[SpyEvent]:
         """Get all non-rehearsal calls to the spies in the given rehearsals."""
         return [
-            call
-            for call in self._stack
-            if isinstance(call, SpyCall)
-            and any(rehearsal.spy_id == call.spy_id for rehearsal in rehearsals)
+            event
+            for event in self._log
+            if event.spy_id in spy_ids
+            and isinstance(event, SpyEvent)
+            and _is_verifiable(event)
         ]
 
-    def get_all(self) -> List[BaseSpyCall]:
+    def get_all(self) -> List[AnySpyEvent]:
         """Get a list of all calls and rehearsals made."""
-        return list(self._stack)
+        return list(self._log)
 
     def clear(self) -> None:
         """Remove all stored calls."""
-        self._stack.clear()
+        self._log.clear()
+
+
+def _is_verifiable(event: AnySpyEvent) -> bool:
+    return isinstance(event, SpyEvent) and (
+        isinstance(event.payload, SpyCall)
+        or event.payload.access_type != PropAccessType.GET
+    )
+
+
+def _apply_ignore_extra_args(event: AnySpyEvent, ignore_extra_args: bool) -> SpyEvent:
+    spy_id, spy_name, payload = event
+
+    if isinstance(payload, SpyCall):
+        payload = payload._replace(ignore_extra_args=ignore_extra_args)
+
+    return SpyEvent(spy_id=spy_id, spy_name=spy_name, payload=payload)
diff --git a/decoy/stringify.py b/decoy/stringify.py
index 862968b..31202cc 100644
--- a/decoy/stringify.py
+++ b/decoy/stringify.py
@@ -2,24 +2,44 @@
 import os
 from typing import Sequence
 
-from .spy_calls import BaseSpyCall, BaseSpyRehearsal, SpyCall
+from .spy_events import AnySpyEvent, SpyCall, SpyEvent, SpyRehearsal, PropAccessType
 
 
-def stringify_call(call: BaseSpyCall) -> str:
+def stringify_call(event: AnySpyEvent) -> str:
     """Stringify the call to something human readable.
 
-    `SpyCall(spy_id=42, spy_name="name", args=(1,), kwargs={"foo": False})`
-    would stringify as `"name(1, foo=False)"`
-    """
-    args_list = [repr(arg) for arg in call.args]
-    kwargs_list = [f"{key}={repr(val)}" for key, val in call.kwargs.items()]
-    extra_args_msg = (
-        " - ignoring unspecified arguments" if call.ignore_extra_args else ""
+    ```python
+    SpyEvent(
+        spy_id=42,
+        spy_name="some_func",
+        payload=SpyCall(args=(1,), kwargs={"foo": False})
     )
-    return f"{call.spy_name}({', '.join(args_list + kwargs_list)}){extra_args_msg}"
+    ```
+
+    ...would stringify as `"some_func(1, foo=False)"`
+    """
+    spy_id, spy_name, payload = event
+
+    if not isinstance(payload, SpyCall):
+        full_prop_name = f"{spy_name}.{payload.prop_name}"
+
+        if payload.access_type == PropAccessType.SET:
+            return f"{full_prop_name} = {payload.value}"
+        elif payload.access_type == PropAccessType.DELETE:
+            return f"del {full_prop_name}"
+
+        return full_prop_name
+
+    else:
+        args_list = [repr(arg) for arg in payload.args]
+        kwargs_list = [f"{key}={repr(val)}" for key, val in payload.kwargs.items()]
+        extra_args_msg = (
+            " - ignoring unspecified arguments" if payload.ignore_extra_args else ""
+        )
+        return f"{spy_name}({', '.join(args_list + kwargs_list)}){extra_args_msg}"
 
 
-def stringify_call_list(calls: Sequence[BaseSpyCall]) -> str:
+def stringify_call_list(calls: Sequence[AnySpyEvent]) -> str:
     """Stringify a sequence of calls into an ordered list."""
     return os.linesep.join(
         f"{i + 1}.\t{stringify_call(call)}" for i, call in enumerate(calls)
@@ -38,8 +58,8 @@ def join_lines(*lines: str) -> str:
 
 def stringify_error_message(
     heading: str,
-    rehearsals: Sequence[BaseSpyRehearsal],
-    calls: Sequence[SpyCall],
+    rehearsals: Sequence[SpyRehearsal],
+    calls: Sequence[SpyEvent],
     include_calls: bool = True,
 ) -> str:
     """Stringify an error message about a rehearsals to calls comparison."""
diff --git a/decoy/stub_store.py b/decoy/stub_store.py
index 89c1ff0..faca848 100644
--- a/decoy/stub_store.py
+++ b/decoy/stub_store.py
@@ -1,7 +1,7 @@
 """Stub creation and storage."""
 from typing import Any, Callable, List, NamedTuple, Optional
 
-from .spy_calls import SpyCall, WhenRehearsal, match_call
+from .spy_events import SpyEvent, WhenRehearsal, match_event
 
 
 class StubBehavior(NamedTuple):
@@ -36,14 +36,14 @@ def add(
         """Create and add a new StubBehavior to the store."""
         self._stubs.append(StubEntry(rehearsal=rehearsal, behavior=behavior))
 
-    def get_by_call(self, call: SpyCall) -> Optional[StubBehavior]:
+    def get_by_call(self, call: SpyEvent) -> Optional[StubBehavior]:
         """Get the latest StubBehavior matching this call."""
         reversed_indices = range(len(self._stubs) - 1, -1, -1)
 
         for i in reversed_indices:
             stub = self._stubs[i]
 
-            if match_call(call, stub.rehearsal):
+            if match_event(call, stub.rehearsal):
                 if stub.behavior.once:
                     self._stubs.pop(i)
 
diff --git a/decoy/verifier.py b/decoy/verifier.py
index 2ce01c0..2560db4 100644
--- a/decoy/verifier.py
+++ b/decoy/verifier.py
@@ -1,7 +1,7 @@
 """Spy call verification."""
 from typing import Optional, Sequence
 
-from .spy_calls import SpyCall, VerifyRehearsal, match_call
+from .spy_events import SpyEvent, VerifyRehearsal, match_event
 from .errors import VerifyError
 
 # ensure decoy.verifier does not pollute Pytest tracebacks
@@ -14,7 +14,7 @@ class Verifier:
     @staticmethod
     def verify(
         rehearsals: Sequence[VerifyRehearsal],
-        calls: Sequence[SpyCall],
+        calls: Sequence[SpyEvent],
         times: Optional[int] = None,
     ) -> None:
         """Verify that a list of calls satisfies a given list of rehearsals.
@@ -33,7 +33,7 @@ def verify(
 
         for i in range(len(calls)):
             calls_subset = calls[i : i + len(rehearsals)]
-            matches = [match_call(c, r) for c, r in zip(calls_subset, rehearsals)]
+            matches = [match_event(c, r) for c, r in zip(calls_subset, rehearsals)]
 
             if all(matches) and len(calls_subset) == len(rehearsals):
                 match_count = match_count + 1
diff --git a/decoy/warning_checker.py b/decoy/warning_checker.py
index 0af6b81..2a27388 100644
--- a/decoy/warning_checker.py
+++ b/decoy/warning_checker.py
@@ -2,7 +2,13 @@
 from typing import Dict, List, Sequence
 from warnings import warn
 
-from .spy_calls import BaseSpyCall, SpyCall, VerifyRehearsal, WhenRehearsal, match_call
+from .spy_events import (
+    AnySpyEvent,
+    SpyEvent,
+    VerifyRehearsal,
+    WhenRehearsal,
+    match_event,
+)
 from .warnings import MiscalledStubWarning, RedundantVerifyWarning
 
 
@@ -10,15 +16,15 @@ class WarningChecker:
     """An interface to inspect the call list and trigger any necessary warnings."""
 
     @staticmethod
-    def check(all_calls: Sequence[BaseSpyCall]) -> None:
+    def check(all_calls: Sequence[AnySpyEvent]) -> None:
         """Check full call and rehearsal list for any potential misuse."""
         _check_no_miscalled_stubs(all_calls)
         _check_no_redundant_verify(all_calls)
 
 
-def _check_no_miscalled_stubs(all_calls: Sequence[BaseSpyCall]) -> None:
+def _check_no_miscalled_stubs(all_calls: Sequence[AnySpyEvent]) -> None:
     """Ensure every call matches a rehearsal, if the spy has rehearsals."""
-    all_calls_by_id: Dict[int, List[BaseSpyCall]] = {}
+    all_calls_by_id: Dict[int, List[AnySpyEvent]] = {}
 
     for call in all_calls:
         spy_id = call.spy_id
@@ -26,23 +32,23 @@ def _check_no_miscalled_stubs(all_calls: Sequence[BaseSpyCall]) -> None:
         all_calls_by_id[spy_id] = spy_calls + [call]
 
     for spy_calls in all_calls_by_id.values():
-        unmatched: List[SpyCall] = []
+        unmatched: List[SpyEvent] = []
 
         for index, call in enumerate(spy_calls):
             past_stubs = [
                 wr for wr in spy_calls[0:index] if isinstance(wr, WhenRehearsal)
             ]
 
-            matched_past_stubs = [wr for wr in past_stubs if match_call(call, wr)]
+            matched_past_stubs = [wr for wr in past_stubs if match_event(call, wr)]
 
             matched_future_verifies = [
                 vr
                 for vr in spy_calls[index + 1 :]
-                if isinstance(vr, VerifyRehearsal) and match_call(call, vr)
+                if isinstance(vr, VerifyRehearsal) and match_event(call, vr)
             ]
 
             if (
-                isinstance(call, SpyCall)
+                isinstance(call, SpyEvent)
                 and len(past_stubs) > 0
                 and len(matched_past_stubs) == 0
                 and len(matched_future_verifies) == 0
@@ -55,7 +61,7 @@ def _check_no_miscalled_stubs(all_calls: Sequence[BaseSpyCall]) -> None:
                 unmatched = []
 
 
-def _check_no_redundant_verify(all_calls: Sequence[BaseSpyCall]) -> None:
+def _check_no_redundant_verify(all_calls: Sequence[AnySpyEvent]) -> None:
     when_rehearsals = [c for c in all_calls if isinstance(c, WhenRehearsal)]
     verify_rehearsals = [c for c in all_calls if isinstance(c, VerifyRehearsal)]
 
diff --git a/decoy/warnings.py b/decoy/warnings.py
index 44e0c6f..e38be21 100644
--- a/decoy/warnings.py
+++ b/decoy/warnings.py
@@ -7,7 +7,7 @@
 import os
 from typing import Sequence
 
-from .spy_calls import SpyCall, WhenRehearsal, VerifyRehearsal
+from .spy_events import SpyEvent, WhenRehearsal, VerifyRehearsal
 from .stringify import stringify_call, stringify_error_message, count
 
 
@@ -35,12 +35,12 @@ class MiscalledStubWarning(DecoyWarning):
     """
 
     rehearsals: Sequence[WhenRehearsal]
-    calls: Sequence[SpyCall]
+    calls: Sequence[SpyEvent]
 
     def __init__(
         self,
         rehearsals: Sequence[WhenRehearsal],
-        calls: Sequence[SpyCall],
+        calls: Sequence[SpyEvent],
     ) -> None:
         heading = os.linesep.join(
             [
diff --git a/docs/advanced/properties.md b/docs/advanced/properties.md
new file mode 100644
index 0000000..e4c7db2
--- /dev/null
+++ b/docs/advanced/properties.md
@@ -0,0 +1,218 @@
+# Mocking property attributes
+
+Python [property attributes][] provide an interface for creating read-only properties and properties with getters, setters, and deleters. You can use Decoy to stub properties and verify calls to property setters and deleters.
+
+[property attributes]: https://docs.python.org/3/library/functions.html#property
+
+## Default behavior
+
+Unlike mock method calls - which have a default return value of `None` - Decoy's default return value for attribute access is **another mock**. So you don't need to configure anything explicitly if you need a `@property` getter to return another mock; Decoy will do this for you.
+
+```python
+class SubDep:
+    ...
+
+class Dep:
+    @property
+    def sub(self) -> SubDep:
+        ...
+
+def test(decoy: Decoy) -> None:
+    dep = decoy.mock(cls=Dep)  # <- mock of class Dep
+    sub = dep.sub              # <- mock of class SubDep
+    ...
+```
+
+## Stubbing property access
+
+### Stubbing a getter
+
+If you would like to stub a return value for a property that is different than the default behavior, simply use the property itself as your rehearsal.
+
+```python
+dep = decoy.mock()
+
+decoy.when(
+    dep.some_property  # <- "rehearsal" of a property getter
+).then_return(42)
+
+assert dep.some_property == 42
+```
+
+You can also configure any other stubbing, like raising an error.
+
+```python
+dep = decoy.mock()
+
+decoy.when(
+    dep.some_property
+).then_raise(RuntimeError("oh no"))
+
+with pytest.raises(RuntimeError, match="oh no"):
+    dep.some_property
+```
+
+### Stubbing a setter or deleter
+
+While you cannot stub a return value for a getter or setter, you can stub a `raise` or a side effect by combining [decoy.Decoy.when][] with [decoy.Decoy.prop][].
+
+The `prop` method allows you to create rehearsals of setters and deleters. Use [decoy.Prop.set][] to create a setter rehearsal, and [decoy.Prop.delete][] to create a deleter rehearsal.
+
+```python
+dep = decoy.mock()
+
+decoy.when(
+    decoy.prop(dep.some_property).set(42)
+).then_return(RuntimeError("oh no"))
+
+decoy.when(
+    decoy.prop(dep.some_property).delete()
+).then_return(RuntimeError("what a disaster"))
+
+with pytest.raises(RuntimeError, match="oh no"):
+    dep.some_property = 42
+
+with pytest.raises(RuntimeError, match="what a disaster"):
+    del dep.some_property
+```
+
+!!! tip
+
+    You cannot use [decoy.Stub.then_return][] with property setters and deleters, because set and delete expressions do not return a value in Python.
+
+## Verifying property access
+
+You can verify calls to property setters and deleters by combining [decoy.Decoy.verify][] with [decoy.Decoy.prop][], the same way you would configure a stub.
+
+**Use this feature sparingly!** If you're designing a dependency that triggers a side-effect, consider using a regular method rather than a property setter/deleter. It'll probably make your code easier to read and reason with.
+
+Mocking and verifying property setters and deleters is most useful for testing code that needs to interact with older or legacy dependencies that would be prohibitively expensive to redesign.
+
+!!! tip
+
+    You cannot verify getters with `Decoy.verify`. The `verify` method is for verifying side-effects, and it is the opinion of the author that property getters should not trigger side-effects. Getter-triggered side effects are confusing and do not communicate the design intent of a system.
+
+### Verify a setter
+
+Use [decoy.Prop.set][] to create a setter rehearsal to use in [decoy.Decoy.verify][].
+
+```python
+dep = decoy.mock()
+
+dep.some_property = 42
+
+decoy.verify(
+    decoy.prop(dep.some_property).set(42)  # <- "rehearsal" of a property setter
+)
+```
+
+### Verify a deleter
+
+Use [decoy.Prop.delete][] to create a deleter rehearsal to use in [decoy.Decoy.verify][].
+
+```python
+dep = decoy.mock()
+
+del dep.some_property
+
+decoy.verify(
+    decoy.prop(dep.some_property).delete()  # <- "rehearsal" of a property setter
+).then_return(42)
+```
+
+## Example
+
+In this example, we're developing a `Core` unit, with a `Config` dependency. We want to test that we get, set, and delete the `port` property of the `Config` dependency when various methods of `Core` are used.
+
+```python
+from decoy import Decoy
+
+
+class InvalidPortValue(ValueError):
+    """Exception raised when a given port value is invalid."""
+
+
+class Config:
+    """Config dependency."""
+
+    @property
+    def port(self) -> int:
+        ...
+
+    @port.setter
+    def port(self) -> None:
+        ...
+
+    @port.deleter
+    def port(self) -> None:
+        ...
+
+
+class Core:
+    """Core test subject."""
+
+    def __init__(self, config: Config) -> None:
+        self._config = config
+
+    def get_port(self) -> int:
+        return self._config.port
+
+    def set_port(self, port: int) -> None:
+        try:
+            self._config.port = port
+        except ValueError as e:
+            raise InvalidPortValue(str(e)) from e
+
+    def reset_port(self) -> None:
+        del self._config.port
+
+
+def test_gets_port(decoy: Decoy) -> None:
+    """Core should get the port number from its Config dependency."""
+    config = decoy.mock(cls=Config)
+    subject = Core(config=config)
+
+    decoy.when(
+        config.port  # <- "rehearsal" of a property getter
+    ).then_return(42)
+
+    result = subject.get_port()
+
+    assert result == 42
+
+
+def test_rejects_invalid_port(decoy: Decoy) -> None:
+    """Core should re-raise if the port number is set to an invalid value."""
+    config = decoy.mock(cls=Config)
+    subject = Core(config=config)
+
+    decoy.when(
+        decoy.prop(config.port).set(9001)  # <- "rehearsal" of a property setter
+    ).then_raise(ValueError("there's no way that can be right"))
+
+    with pytest.raises(InvalidPortValue, "there's no way"):
+        subject.set_port(9001)
+
+
+def test_sets_port(decoy: Decoy) -> None:
+    """Core should set the port number in its Config dependency."""
+    config = decoy.mock(cls=Config)
+    subject = Core(config=config)
+
+    subject.set_port(101)
+
+    decoy.verify(
+        decoy.prop(config.port).set(101)  # <- "rehearsal" of a property setter
+    )
+
+def test_resets_port(decoy: Decoy) -> None:
+    """Core should delete the port number in its Config dependency."""
+    config = decoy.mock(cls=Config)
+    subject = Core(config=config)
+
+    subject.reset_port()
+
+    decoy.verify(
+        decoy.prop(config.port).delete()  # <- "rehearsal" of a property deleter
+    )
+```
diff --git a/docs/api.md b/docs/api.md
index 4112070..a1ee3f7 100644
--- a/docs/api.md
+++ b/docs/api.md
@@ -7,6 +7,8 @@
 
 ::: decoy.Stub
 
+::: decoy.Prop
+
 ::: decoy.matchers
 
 ::: decoy.errors
diff --git a/mkdocs.yml b/mkdocs.yml
index 729de67..61056a0 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -15,6 +15,7 @@ nav:
           - usage/matchers.md
           - usage/errors-and-warnings.md
     - Advanced Usage:
+          - advanced/properties.md
           - advanced/context-managers.md
     - api.md
     - contributing.md


Test patch:
diff --git a/tests/test_call_handler.py b/tests/test_call_handler.py
index 7307909..cf116e5 100644
--- a/tests/test_call_handler.py
+++ b/tests/test_call_handler.py
@@ -1,10 +1,10 @@
-"""Tests for the SpyCall handling."""
+"""Tests for the SpyEvent handling."""
 import pytest
 
 from decoy import Decoy
-from decoy.call_handler import CallHandler
+from decoy.call_handler import CallHandler, CallHandlerResult
 from decoy.spy_log import SpyLog
-from decoy.spy_calls import SpyCall
+from decoy.spy_events import SpyCall, SpyEvent, SpyPropAccess, PropAccessType
 from decoy.stub_store import StubBehavior, StubStore
 
 
@@ -40,7 +40,9 @@ def test_handle_call_with_no_stubbing(
     subject: CallHandler,
 ) -> None:
     """It should noop and add the call to the stack if no stubbing is configured."""
-    spy_call = SpyCall(spy_id=42, spy_name="spy_name", args=(), kwargs={})
+    spy_call = SpyEvent(
+        spy_id=42, spy_name="spy_name", payload=SpyCall(args=(), kwargs={})
+    )
     behavior = None
 
     decoy.when(stub_store.get_by_call(spy_call)).then_return(behavior)
@@ -58,14 +60,16 @@ def test_handle_call_with_return(
     subject: CallHandler,
 ) -> None:
     """It return a Stub's configured return value."""
-    spy_call = SpyCall(spy_id=42, spy_name="spy_name", args=(), kwargs={})
+    spy_call = SpyEvent(
+        spy_id=42, spy_name="spy_name", payload=SpyCall(args=(), kwargs={})
+    )
     behavior = StubBehavior(return_value="hello world")
 
     decoy.when(stub_store.get_by_call(spy_call)).then_return(behavior)
 
     result = subject.handle(spy_call)
 
-    assert result == "hello world"
+    assert result == CallHandlerResult("hello world")
     decoy.verify(spy_log.push(spy_call))
 
 
@@ -76,7 +80,9 @@ def test_handle_call_with_raise(
     subject: CallHandler,
 ) -> None:
     """It raise a Stub's configured error."""
-    spy_call = SpyCall(spy_id=42, spy_name="spy_name", args=(), kwargs={})
+    spy_call = SpyEvent(
+        spy_id=42, spy_name="spy_name", payload=SpyCall(args=(), kwargs={})
+    )
     behavior = StubBehavior(error=RuntimeError("oh no"))
 
     decoy.when(stub_store.get_by_call(spy_call)).then_return(behavior)
@@ -95,7 +101,11 @@ def test_handle_call_with_action(
 ) -> 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"})
+    spy_call = SpyEvent(
+        spy_id=42,
+        spy_name="spy_name",
+        payload=SpyCall(args=(1,), kwargs={"foo": "bar"}),
+    )
     behavior = StubBehavior(action=action)
 
     decoy.when(stub_store.get_by_call(spy_call)).then_return(behavior)
@@ -103,7 +113,30 @@ def test_handle_call_with_action(
 
     result = subject.handle(spy_call)
 
-    assert result == "hello world"
+    assert result == CallHandlerResult("hello world")
+
+
+def test_handle_prop_get_with_action(
+    decoy: Decoy,
+    spy_log: SpyLog,
+    stub_store: StubStore,
+    subject: CallHandler,
+) -> None:
+    """It should trigger a prop get stub's configured action."""
+    action = decoy.mock()
+    spy_call = SpyEvent(
+        spy_id=42,
+        spy_name="spy_name",
+        payload=SpyPropAccess(prop_name="prop", access_type=PropAccessType.GET),
+    )
+    behavior = StubBehavior(action=action)
+
+    decoy.when(stub_store.get_by_call(spy_call)).then_return(behavior)
+    decoy.when(action()).then_return("hello world")
+
+    result = subject.handle(spy_call)
+
+    assert result == CallHandlerResult("hello world")
 
 
 def test_handle_call_with_context_enter(
@@ -113,12 +146,14 @@ def test_handle_call_with_context_enter(
     subject: CallHandler,
 ) -> None:
     """It return a Stub's configured context value."""
-    spy_call = SpyCall(spy_id=42, spy_name="spy_name", args=(), kwargs={})
+    spy_call = SpyEvent(
+        spy_id=42, spy_name="spy_name", payload=SpyCall(args=(), kwargs={})
+    )
     behavior = StubBehavior(context_value="hello world")
 
     decoy.when(stub_store.get_by_call(spy_call)).then_return(behavior)
 
-    with subject.handle(spy_call) as result:
+    with subject.handle(spy_call).value as result:  # type: ignore[union-attr]
         assert result == "hello world"
 
     decoy.verify(spy_log.push(spy_call))
diff --git a/tests/test_core.py b/tests/test_core.py
index 72851bf..77b9729 100644
--- a/tests/test_core.py
+++ b/tests/test_core.py
@@ -6,7 +6,15 @@
 from decoy.spy_log import SpyLog
 from decoy.core import DecoyCore
 from decoy.spy import Spy, SpyCreator
-from decoy.spy_calls import SpyCall, VerifyRehearsal, WhenRehearsal
+from decoy.spy_events import (
+    SpyCall,
+    SpyPropAccess,
+    SpyEvent,
+    VerifyRehearsal,
+    WhenRehearsal,
+    PropRehearsal,
+    PropAccessType,
+)
 from decoy.stub_store import StubBehavior, StubStore
 from decoy.verifier import Verifier
 from decoy.warning_checker import WarningChecker
@@ -131,7 +139,9 @@ def test_when_then_return(
     subject: DecoyCore,
 ) -> None:
     """It should be able to register a new stubbing."""
-    rehearsal = WhenRehearsal(spy_id=1, spy_name="my_spy", args=(), kwargs={})
+    rehearsal = WhenRehearsal(
+        spy_id=1, spy_name="my_spy", payload=SpyCall(args=(), kwargs={})
+    )
     decoy.when(spy_log.consume_when_rehearsal(ignore_extra_args=False)).then_return(
         rehearsal
     )
@@ -154,7 +164,9 @@ def test_when_then_return_multiple_values(
     subject: DecoyCore,
 ) -> None:
     """It should add multiple return values to a stub."""
-    rehearsal = WhenRehearsal(spy_id=1, spy_name="my_spy", args=(), kwargs={})
+    rehearsal = WhenRehearsal(
+        spy_id=1, spy_name="my_spy", payload=SpyCall(args=(), kwargs={})
+    )
     decoy.when(spy_log.consume_when_rehearsal(ignore_extra_args=False)).then_return(
         rehearsal
     )
@@ -185,7 +197,9 @@ def test_when_then_raise(
     subject: DecoyCore,
 ) -> None:
     """It should add a raise behavior to a stub."""
-    rehearsal = WhenRehearsal(spy_id=1, spy_name="my_spy", args=(), kwargs={})
+    rehearsal = WhenRehearsal(
+        spy_id=1, spy_name="my_spy", payload=SpyCall(args=(), kwargs={})
+    )
     decoy.when(spy_log.consume_when_rehearsal(ignore_extra_args=False)).then_return(
         rehearsal
     )
@@ -209,7 +223,9 @@ def test_when_then_do(
     subject: DecoyCore,
 ) -> None:
     """It should add an action behavior to a stub."""
-    rehearsal = WhenRehearsal(spy_id=1, spy_name="my_spy", args=(), kwargs={})
+    rehearsal = WhenRehearsal(
+        spy_id=1, spy_name="my_spy", payload=SpyCall(args=(), kwargs={})
+    )
     decoy.when(spy_log.consume_when_rehearsal(ignore_extra_args=False)).then_return(
         rehearsal
     )
@@ -233,7 +249,9 @@ def test_when_then_enter_with(
     subject: DecoyCore,
 ) -> None:
     """It should be able to register a ContextManager stubbing."""
-    rehearsal = WhenRehearsal(spy_id=1, spy_name="my_spy", args=(), kwargs={})
+    rehearsal = WhenRehearsal(
+        spy_id=1, spy_name="my_spy", payload=SpyCall(args=(), kwargs={})
+    )
     decoy.when(spy_log.consume_when_rehearsal(ignore_extra_args=False)).then_return(
         rehearsal
     )
@@ -256,7 +274,9 @@ def test_when_ignore_extra_args(
     subject: DecoyCore,
 ) -> None:
     """It should be able to register a new stubbing."""
-    rehearsal = WhenRehearsal(spy_id=1, spy_name="my_spy", args=(), kwargs={})
+    rehearsal = WhenRehearsal(
+        spy_id=1, spy_name="my_spy", payload=SpyCall(args=(), kwargs={})
+    )
     decoy.when(spy_log.consume_when_rehearsal(ignore_extra_args=True)).then_return(
         rehearsal
     )
@@ -280,13 +300,18 @@ def test_verify(
 ) -> None:
     """It should be able to verify a call."""
     spy_id = 42
-    rehearsal = VerifyRehearsal(spy_id=spy_id, spy_name="my_spy", args=(), kwargs={})
-    call = SpyCall(spy_id=spy_id, spy_name="my_spy", args=(), kwargs={})
+    rehearsal = VerifyRehearsal(
+        spy_id=spy_id, spy_name="my_spy", payload=SpyCall(args=(), kwargs={})
+    )
+    call = SpyEvent(
+        spy_id=spy_id, spy_name="my_spy", payload=SpyCall(args=(), kwargs={})
+    )
 
     decoy.when(
         spy_log.consume_verify_rehearsals(count=1, ignore_extra_args=False)
     ).then_return([rehearsal])
-    decoy.when(spy_log.get_by_rehearsals([rehearsal])).then_return([call])
+
+    decoy.when(spy_log.get_calls_to_verify([spy_id])).then_return([call])
 
     subject.verify("__rehearsal__", times=None, ignore_extra_args=False)
 
@@ -304,15 +329,21 @@ def test_verify_multiple_calls(
     spy_id_2 = 9001
 
     rehearsals = [
-        VerifyRehearsal(spy_id=spy_id_1, spy_name="spy_1", args=(), kwargs={}),
-        VerifyRehearsal(spy_id=spy_id_2, spy_name="spy_2", args=(), kwargs={}),
+        VerifyRehearsal(
+            spy_id=spy_id_1, spy_name="spy_1", payload=SpyCall(args=(), kwargs={})
+        ),
+        VerifyRehearsal(
+            spy_id=spy_id_2, spy_name="spy_2", payload=SpyCall(args=(), kwargs={})
+        ),
+    ]
+    calls = [
+        SpyEvent(spy_id=spy_id_1, spy_name="spy_1", payload=SpyCall(args=(), kwargs={}))
     ]
-    calls = [SpyCall(spy_id=spy_id_1, spy_name="spy_1", args=(), kwargs={})]
 
     decoy.when(
         spy_log.consume_verify_rehearsals(count=2, ignore_extra_args=False)
     ).then_return(rehearsals)
-    decoy.when(spy_log.get_by_rehearsals(rehearsals)).then_return(calls)
+    decoy.when(spy_log.get_calls_to_verify([spy_id_1, spy_id_2])).then_return(calls)
 
     subject.verify(
         "__rehearsal_1__",
@@ -332,19 +363,62 @@ def test_verify_call_times(
 ) -> None:
     """It should be able to verify the call count."""
     spy_id = 42
-    rehearsal = VerifyRehearsal(spy_id=spy_id, spy_name="my_spy", args=(), kwargs={})
-    call = SpyCall(spy_id=spy_id, spy_name="my_spy", args=(), kwargs={})
+    rehearsal = VerifyRehearsal(
+        spy_id=spy_id, spy_name="my_spy", payload=SpyCall(args=(), kwargs={})
+    )
+    call = SpyEvent(
+        spy_id=spy_id, spy_name="my_spy", payload=SpyCall(args=(), kwargs={})
+    )
 
     decoy.when(
         spy_log.consume_verify_rehearsals(count=1, ignore_extra_args=False)
     ).then_return([rehearsal])
-    decoy.when(spy_log.get_by_rehearsals([rehearsal])).then_return([call])
+    decoy.when(spy_log.get_calls_to_verify([spy_id])).then_return([call])
 
     subject.verify("__rehearsal__", times=2, ignore_extra_args=False)
 
     decoy.verify(verifier.verify(rehearsals=[rehearsal], calls=[call], times=2))
 
 
+def test_prop(
+    decoy: Decoy,
+    spy_log: SpyLog,
+    subject: DecoyCore,
+) -> None:
+    """It should be able to create set and delete rehearsals."""
+    rehearsal = PropRehearsal(
+        spy_id=1,
+        spy_name="my_spy",
+        payload=SpyPropAccess(prop_name="my_prop", access_type=PropAccessType.GET),
+    )
+
+    decoy.when(spy_log.consume_prop_rehearsal()).then_return(rehearsal)
+
+    result = subject.prop("__rehearsal__")
+
+    result.set("hello")
+
+    expected_set_event = SpyEvent(
+        spy_id=1,
+        spy_name="my_spy",
+        payload=SpyPropAccess(
+            prop_name="my_prop",
+            access_type=PropAccessType.SET,
+            value="hello",
+        ),
+    )
+    decoy.verify(spy_log.push(expected_set_event), times=1)
+
+    result.delete()
+
+    expected_delete_event = SpyEvent(
+        spy_id=1,
+        spy_name="my_spy",
+        payload=SpyPropAccess(prop_name="my_prop", access_type=PropAccessType.DELETE),
+    )
+    decoy.verify(spy_log.push(expected_delete_event), times=1)
+
+
 def test_reset(
     decoy: Decoy,
     spy_log: SpyLog,
@@ -353,7 +427,7 @@ def test_reset(
     subject: DecoyCore,
 ) -> None:
     """It should reset the stores."""
-    call = SpyCall(spy_id=1, spy_name="my_spy", args=(), kwargs={})
+    call = SpyEvent(spy_id=1, spy_name="my_spy", payload=SpyCall(args=(), kwargs={}))
 
     decoy.when(spy_log.get_all()).then_return([call])
 
diff --git a/tests/test_decoy.py b/tests/test_decoy.py
index 3a15673..18d49ae 100644
--- a/tests/test_decoy.py
+++ b/tests/test_decoy.py
@@ -8,7 +8,7 @@
 from decoy import Decoy, errors
 from decoy.spy import AsyncSpy, Spy
 
-from .common import SomeAsyncClass, SomeClass, some_func
+from .common import SomeAsyncClass, SomeClass, SomeNestedClass, some_func
 
 pytestmark = pytest.mark.asyncio
 
@@ -176,6 +176,29 @@ def _get_a_thing(id: str, default: Optional[int] = None) -> int:
         )
 
 
+def test_verify_call_list(decoy: Decoy) -> None:
+    """It should be able to verify multiple calls."""
+    subject_1 = decoy.mock(cls=SomeClass)
+    subject_2 = decoy.mock(cls=SomeNestedClass)
+
+    subject_1.foo("hello")
+    subject_2.child.bar(1, 2.0, "3")
+    subject_1.foo("goodbye")
+
+    decoy.verify(
+        subject_1.foo("hello"),
+        subject_2.child.bar(1, 2.0, "3"),
+        subject_1.foo("goodbye"),
+    )
+
+    with pytest.raises(errors.VerifyError):
+        decoy.verify(
+            subject_1.foo("hello"),
+            subject_1.foo("goodbye"),
+            subject_2.child.bar(1, 2.0, "3"),
+        )
+
+
 async def test_when_async(decoy: Decoy) -> None:
     """It should be able to stub an async method."""
     subject = decoy.mock(cls=SomeAsyncClass)
@@ -361,3 +384,105 @@ def _handle_exit(*args: Any) -> None:
 
     with pytest.raises(AssertionError, match="exited"):
         subject.get_value()
+
+
+def test_property_getter_stub_then_return(decoy: Decoy) -> None:
+    """It should be able to stub a property getter."""
+    subject = decoy.mock()
+    decoy.when(subject.prop_name).then_return(42)
+
+    assert subject.prop_name == 42
+
+
+def test_property_getter_stub_then_return_multiple(decoy: Decoy) -> None:
+    """It should be able to stub a property getter with multiple return values."""
+    subject = decoy.mock()
+    decoy.when(subject.prop_name).then_return(43, 44)
+
+    assert subject.prop_name == 43
+    assert subject.prop_name == 44
+    assert subject.prop_name == 44
+
+
+def test_property_getter_stub_then_do(decoy: Decoy) -> None:
+    """It should be able to stub a property getter to act."""
+
+    def _handle_get(*args: Any, **kwargs: Any) -> int:
+        return 84
+
+    subject = decoy.mock()
+    decoy.when(subject.prop_name).then_do(_handle_get)
+
+    assert subject.prop_name == 84
+
+
+def test_property_getter_stub_then_raise(decoy: Decoy) -> None:
+    """It should be able to stub a property getter to raise."""
+    subject = decoy.mock()
+
+    decoy.when(subject.prop_name).then_raise(ValueError("oh no"))
+
+    with pytest.raises(ValueError, match="oh no"):
+        subject.prop_name
+
+
+def test_property_getter_stub_reconfigure(decoy: Decoy) -> None:
+    """It should be able to reconfigure a property getter."""
+    subject = decoy.mock()
+
+    decoy.when(subject.prop_name).then_return(42)
+    assert subject.prop_name == 42
+
+    decoy.when(subject.prop_name).then_return(43)
+    assert subject.prop_name == 43
+
+
+def test_property_setter_stub_then_raise(decoy: Decoy) -> None:
+    """It should be able to stub a property setter to raise."""
+    subject = decoy.mock()
+    prop_rehearser = decoy.prop(subject.prop_name)
+
+    decoy.when(prop_rehearser.set(42)).then_raise(ValueError("oh no"))
+
+    subject.prop_name = 41
+    assert subject.prop_name == 41
+
+    with pytest.raises(ValueError, match="oh no"):
+        subject.prop_name = 42
+
+
+def test_property_deleter_stub_then_rase(decoy: Decoy) -> None:
+    """It should be able to stub a property deleter to raise."""
+    subject = decoy.mock()
+    prop_rehearser = decoy.prop(subject.prop_name)
+
+    decoy.when(prop_rehearser.delete()).then_raise(ValueError("oh no"))
+
+    with pytest.raises(ValueError, match="oh no"):
+        del subject.prop_name
+
+
+def test_verify_property_access(decoy: Decoy) -> None:
+    """It should be able to verify property setters and deleters."""
+    subject_1 = decoy.mock()
+    subject_2 = decoy.mock()
+
+    subject_1.hello("world")
+    subject_1.some_property = "fizzbuzz"
+    del subject_2.another_property
+    subject_2.answer(42)
+
+    decoy.verify(
+        subject_1.hello("world"),
+        decoy.prop(subject_1.some_property).set("fizzbuzz"),
+        decoy.prop(subject_2.another_property).delete(),
+        subject_2.answer(42),
+    )
+
+    with pytest.raises(errors.VerifyError):
+        decoy.verify(
+            subject_1.hello("world"),
+            decoy.prop(subject_1.some_property).set("fizzbuzz"),
+            subject_2.answer(42),
+            decoy.prop(subject_2.another_property).delete(),
+        )
diff --git a/tests/test_errors.py b/tests/test_errors.py
index 0f74227..502ff30 100644
--- a/tests/test_errors.py
+++ b/tests/test_errors.py
@@ -3,7 +3,7 @@
 import os
 from typing import List, NamedTuple, Optional
 
-from decoy.spy_calls import SpyCall, VerifyRehearsal
+from decoy.spy_events import SpyCall, SpyEvent, VerifyRehearsal
 from decoy.errors import VerifyError
 
 
@@ -11,7 +11,7 @@ class VerifyErrorSpec(NamedTuple):
     """Spec data for VerifyError tests."""
 
     rehearsals: List[VerifyRehearsal]
-    calls: List[SpyCall]
+    calls: List[SpyEvent]
     times: Optional[int]
     expected_message: str
 
@@ -19,7 +19,9 @@ class VerifyErrorSpec(NamedTuple):
 verify_error_specs = [
     VerifyErrorSpec(
         rehearsals=[
-            VerifyRehearsal(spy_id=42, spy_name="my_spy", args=(), kwargs={}),
+            VerifyRehearsal(
+                spy_id=42, spy_name="my_spy", payload=SpyCall(args=(), kwargs={})
+            ),
         ],
         calls=[],
         times=None,
@@ -33,11 +35,21 @@ class VerifyErrorSpec(NamedTuple):
     ),
     VerifyErrorSpec(
         rehearsals=[
-            VerifyRehearsal(spy_id=42, spy_name="my_spy", args=(), kwargs={}),
+            VerifyRehearsal(
+                spy_id=42, spy_name="my_spy", payload=SpyCall(args=(), kwargs={})
+            ),
         ],
         calls=[
-            SpyCall(spy_id=101, spy_name="spy_101", args=(1, 2, 3), kwargs={}),
-            SpyCall(spy_id=101, spy_name="spy_101", args=(4, 5, 6), kwargs={}),
+            SpyEvent(
+                spy_id=101,
+                spy_name="spy_101",
+                payload=SpyCall(args=(1, 2, 3), kwargs={}),
+            ),
+            SpyEvent(
+                spy_id=101,
+                spy_name="spy_101",
+                payload=SpyCall(args=(4, 5, 6), kwargs={}),
+            ),
         ],
         times=None,
         expected_message=os.linesep.join(
@@ -52,14 +64,38 @@ class VerifyErrorSpec(NamedTuple):
     ),
     VerifyErrorSpec(
         rehearsals=[
-            VerifyRehearsal(spy_id=101, spy_name="spy_101", args=(1, 2, 3), kwargs={}),
-            VerifyRehearsal(spy_id=101, spy_name="spy_101", args=(4, 5, 6), kwargs={}),
-            VerifyRehearsal(spy_id=202, spy_name="spy_202", args=(7, 8, 9), kwargs={}),
+            VerifyRehearsal(
+                spy_id=101,
+                spy_name="spy_101",
+                payload=SpyCall(args=(1, 2, 3), kwargs={}),
+            ),
+            VerifyRehearsal(
+                spy_id=101,
+                spy_name="spy_101",
+                payload=SpyCall(args=(4, 5, 6), kwargs={}),
+            ),
+            VerifyRehearsal(
+                spy_id=202,
+                spy_name="spy_202",
+                payload=SpyCall(args=(7, 8, 9), kwargs={}),
+            ),
         ],
         calls=[
-            SpyCall(spy_id=101, spy_name="spy_101", args=(1, 2, 3), kwargs={}),
-            SpyCall(spy_id=101, spy_name="spy_101", args=(4, 5, 6), kwargs={}),
-            SpyCall(spy_id=202, spy_name="spy_202", args=("oh no",), kwargs={}),
+            SpyEvent(
+                spy_id=101,
+                spy_name="spy_101",
+                payload=SpyCall(args=(1, 2, 3), kwargs={}),
+            ),
+            SpyEvent(
+                spy_id=101,
+                spy_name="spy_101",
+                payload=SpyCall(args=(4, 5, 6), kwargs={}),
+            ),
+            SpyEvent(
+                spy_id=202,
+                spy_name="spy_202",
+                payload=SpyCall(args=("oh no",), kwargs={}),
+            ),
         ],
         times=None,
         expected_message=os.linesep.join(
@@ -77,11 +113,23 @@ class VerifyErrorSpec(NamedTuple):
     ),
     VerifyErrorSpec(
         rehearsals=[
-            VerifyRehearsal(spy_id=101, spy_name="spy_101", args=(1, 2, 3), kwargs={}),
+            VerifyRehearsal(
+                spy_id=101,
+                spy_name="spy_101",
+                payload=SpyCall(args=(1, 2, 3), kwargs={}),
+            ),
         ],
         calls=[
-            SpyCall(spy_id=101, spy_name="spy_101", args=(1, 2, 3), kwargs={}),
-            SpyCall(spy_id=101, spy_name="spy_101", args=(1, 2, 3), kwargs={}),
+            SpyEvent(
+                spy_id=101,
+                spy_name="spy_101",
+                payload=SpyCall(args=(1, 2, 3), kwargs={}),
+            ),
+            SpyEvent(
+                spy_id=101,
+                spy_name="spy_101",
+                payload=SpyCall(args=(1, 2, 3), kwargs={}),
+            ),
         ],
         times=1,
         expected_message=os.linesep.join(
@@ -94,10 +142,18 @@ class VerifyErrorSpec(NamedTuple):
     ),
     VerifyErrorSpec(
         rehearsals=[
-            VerifyRehearsal(spy_id=101, spy_name="spy_101", args=(1, 2, 3), kwargs={}),
+            VerifyRehearsal(
+                spy_id=101,
+                spy_name="spy_101",
+                payload=SpyCall(args=(1, 2, 3), kwargs={}),
+            ),
         ],
         calls=[
-            SpyCall(spy_id=101, spy_name="spy_101", args=(4, 5, 6), kwargs={}),
+            SpyEvent(
+                spy_id=101,
+                spy_name="spy_101",
+                payload=SpyCall(args=(4, 5, 6), kwargs={}),
+            ),
         ],
         times=1,
         expected_message=os.linesep.join(
@@ -115,7 +171,7 @@ class VerifyErrorSpec(NamedTuple):
 @pytest.mark.parametrize(VerifyErrorSpec._fields, verify_error_specs)
 def test_verify_error(
     rehearsals: List[VerifyRehearsal],
-    calls: List[SpyCall],
+    calls: List[SpyEvent],
     times: Optional[int],
     expected_message: str,
 ) -> None:
diff --git a/tests/test_spy.py b/tests/test_spy.py
index 692d17d..336a0f4 100644
--- a/tests/test_spy.py
+++ b/tests/test_spy.py
@@ -4,10 +4,10 @@
 import sys
 from decoy import Decoy
 
-from decoy.call_handler import CallHandler
+from decoy.call_handler import CallHandler, CallHandlerResult
 from decoy.spec import Spec, BoundArgs
 from decoy.spy import SpyCreator, Spy, AsyncSpy
-from decoy.spy_calls import SpyCall
+from decoy.spy_events import SpyCall, SpyEvent, SpyPropAccess, PropAccessType
 
 from .common import SomeClass, some_func
 
@@ -130,9 +130,13 @@ def test_spy_calls(
     )
     decoy.when(
         call_handler.handle(
-            SpyCall(spy_id=id(subject), spy_name="spy_name", args=(1, 2, 3), kwargs={})
+            SpyEvent(
+                spy_id=id(subject),
+                spy_name="spy_name",
+                payload=SpyCall(args=(1, 2, 3), kwargs={}),
+            )
         )
-    ).then_return(42)
+    ).then_return(CallHandlerResult(42))
 
     result = subject(1, 2, three=3)
 
@@ -181,7 +185,7 @@ async def test_spy_async_context_manager(
     spy_creator: SpyCreator,
     spec: Spec,
 ) -> None:
-    """It should be usable in a `with` statement."""
+    """It should be usable in an `async with` statement."""
     enter_spec = decoy.mock(cls=Spec)
     exit_spec = decoy.mock(cls=Spec)
     enter_spy = decoy.mock(cls=AsyncSpy)
@@ -211,31 +215,86 @@ async def test_spy_async_context_manager(
     decoy.verify(await exit_spy(RuntimeError, error, tb))
 
 
-# async def test_spy_async_context_manager() -> None:
-#     """It should be usable in an `async with` statement."""
-#     calls = []
-
-#     def _handle_call(call: SpyCall) -> Optional[CallResult]:
-#         nonlocal calls
-#         calls.append(call)
-#         return CallResult(42)
-
-#     subject = create_spy(SpyConfig(name="subject", handle_call=_handle_call))
-
-#     async with subject as result:
-#         assert result == 42
-
-#     assert calls == [
-#         SpyCall(
-#             spy_id=matchers.Anything(),
-#             spy_name="subject.__aenter__",
-#             args=(),
-#             kwargs={},
-#         ),
-#         SpyCall(
-#             spy_id=matchers.Anything(),
-#             spy_name="subject.__aexit__",
-#             args=(None, None, None),
-#             kwargs={},
-#         ),
-#     ]
+def test_spy_prop_get(
+    decoy: Decoy,
+    call_handler: CallHandler,
+    spy_creator: SpyCreator,
+    spec: Spec,
+) -> None:
+    """It should record a property get call."""
+    subject = Spy(spec=spec, call_handler=call_handler, spy_creator=spy_creator)
+
+    decoy.when(spec.get_name()).then_return("spy_name")
+    decoy.when(
+        call_handler.handle(
+            SpyEvent(
+                spy_id=id(subject),
+                spy_name="spy_name",
+                payload=SpyPropAccess(
+                    prop_name="some_property",
+                    access_type=PropAccessType.GET,
+                ),
+            ),
+        )
+    ).then_return(CallHandlerResult(42))
+
+    result = subject.some_property
+
+    assert result == 42
+
+
+def test_spy_prop_set(
+    decoy: Decoy,
+    call_handler: CallHandler,
+    spy_creator: SpyCreator,
+    spec: Spec,
+) -> None:
+    """It should record a property set call."""
+    decoy.when(spec.get_name()).then_return("spy_name")
+
+    subject = Spy(spec=spec, call_handler=call_handler, spy_creator=spy_creator)
+    subject.some_property = 42
+    assert subject.some_property == 42
+
+    decoy.verify(
+        call_handler.handle(
+            SpyEvent(
+                spy_id=id(subject),
+                spy_name="spy_name",
+                payload=SpyPropAccess(
+                    prop_name="some_property",
+                    access_type=PropAccessType.SET,
+                    value=42,
+                ),
+            ),
+        )
+    )
+
+
+def test_spy_prop_delete(
+    decoy: Decoy,
+    call_handler: CallHandler,
+    spy_creator: SpyCreator,
+    spec: Spec,
+) -> None:
+    """It should record a property set call."""
+    decoy.when(spec.get_name()).then_return("spy_name")
+
+    subject = Spy(spec=spec, call_handler=call_handler, spy_creator=spy_creator)
+    subject.some_property = 42
+    del subject.some_property
+
+    assert subject.some_property != 42
+
+    decoy.verify(
+        call_handler.handle(
+            SpyEvent(
+                spy_id=id(subject),
+                spy_name="spy_name",
+                payload=SpyPropAccess(
+                    prop_name="some_property",
+                    access_type=PropAccessType.DELETE,
+                ),
+            ),
+        )
+    )
diff --git a/tests/test_spy_calls.py b/tests/test_spy_calls.py
deleted file mode 100644
index 22ace59..0000000
--- a/tests/test_spy_calls.py
+++ /dev/null
@@ -1,143 +0,0 @@
-"""Tests for the spy call values."""
-import pytest
-from typing import List, NamedTuple
-
-from decoy.spy_calls import SpyCall, BaseSpyRehearsal, match_call
-
-
-class MatchCallSpec(NamedTuple):
-    """Spec data for testing `match_call`."""
-
-    call: SpyCall
-    rehearsal: BaseSpyRehearsal
-    expected_result: bool
-
-
-match_call_specs: List[MatchCallSpec] = [
-    MatchCallSpec(
-        call=SpyCall(spy_id=42, spy_name="my_spy", args=(), kwargs={}),
-        rehearsal=BaseSpyRehearsal(spy_id=42, spy_name="my_spy", args=(), kwargs={}),
-        expected_result=True,
-    ),
-    MatchCallSpec(
-        call=SpyCall(
-            spy_id=42,
-            spy_name="my_spy",
-            args=(1, 2),
-            kwargs={"foo": "bar", "baz": "qux"},
-        ),
-        rehearsal=BaseSpyRehearsal(
-            spy_id=42,
-            spy_name="my_spy",
-            args=(1, 2),
-            kwargs={"foo": "bar", "baz": "qux"},
-        ),
-        expected_result=True,
-    ),
-    MatchCallSpec(
-        call=SpyCall(
-            spy_id=42,
-            spy_name="my_spy",
-            args=(1, 2),
-            kwargs={"foo": "bar", "baz": "qux"},
-        ),
-        rehearsal=BaseSpyRehearsal(
-            spy_id=42,
-            spy_name="my_spy",
-            args=(1,),
-            kwargs={"baz": "qux"},
-            ignore_extra_args=True,
-        ),
-        expected_result=True,
-    ),
-    MatchCallSpec(
-        call=SpyCall(spy_id=42, spy_name="my_spy", args=(), kwargs={}),
-        rehearsal=BaseSpyRehearsal(spy_id=21, spy_name="my_spy", args=(), kwargs={}),
-        expected_result=False,
-    ),
-    MatchCallSpec(
-        call=SpyCall(spy_id=42, spy_name="my_spy", args=(1,), kwargs={}),
-        rehearsal=BaseSpyRehearsal(spy_id=42, spy_name="my_spy", args=(), kwargs={}),
-        expected_result=False,
-    ),
-    MatchCallSpec(
-        call=SpyCall(spy_id=42, spy_name="my_spy", args=(), kwargs={}),
-        rehearsal=BaseSpyRehearsal(spy_id=42, spy_name="my_spy", args=(1,), kwargs={}),
-        expected_result=False,
-    ),
-    MatchCallSpec(
-        call=SpyCall(
-            spy_id=42,
-            spy_name="my_spy",
-            args=(1, 2),
-            kwargs={"foo": "bar", "baz": "qux"},
-        ),
-        rehearsal=BaseSpyRehearsal(
-            spy_id=42,
-            spy_name="my_spy",
-            args=(2,),
-            kwargs={"baz": "qux"},
-            ignore_extra_args=True,
-        ),
-        expected_result=False,
-    ),
-    MatchCallSpec(
-        call=SpyCall(
-            spy_id=42,
-            spy_name="my_spy",
-            args=(1, 2),
-            kwargs={"foo": "bar", "baz": "qux"},
-        ),
-        rehearsal=BaseSpyRehearsal(
-            spy_id=42,
-            spy_name="my_spy",
-            args=(1, 2),
-            kwargs={"foo": "qux"},
-            ignore_extra_args=True,
-        ),
-        expected_result=False,
-    ),
-    MatchCallSpec(
-        call=SpyCall(
-            spy_id=42,
-            spy_name="my_spy",
-            args=(1, 2),
-            kwargs={"foo": "bar", "baz": "qux"},
-        ),
-        rehearsal=BaseSpyRehearsal(
-            spy_id=42,
-            spy_name="my_spy",
-            args=(1, 2, 3),
-            kwargs={"foo": "bar", "baz": "qux"},
-            ignore_extra_args=True,
-        ),
-        expected_result=False,
-    ),
-    MatchCallSpec(
-        call=SpyCall(
-            spy_id=42,
-            spy_name="my_spy",
-            args=(1, 2),
-            kwargs={"foo": "bar", "baz": "qux"},
-        ),
-        rehearsal=BaseSpyRehearsal(
-            spy_id=42,
-            spy_name="my_spy",
-            args=(1, 2),
-            kwargs={"foo": "bar", "baz": "qux", "fizz": "buzz"},
-            ignore_extra_args=True,
-        ),
-        expected_result=False,
-    ),
-]
-
-
-@pytest.mark.parametrize(MatchCallSpec._fields, match_call_specs)
-def test_match_call(
-    call: SpyCall,
-    rehearsal: BaseSpyRehearsal,
-    expected_result: bool,
-) -> None:
-    """It should match a call to a rehearsal."""
-    result = match_call(call, rehearsal)
-    assert result is expected_result
diff --git a/tests/test_spy_events.py b/tests/test_spy_events.py
new file mode 100644
index 0000000..69f8ca5
--- /dev/null
+++ b/tests/test_spy_events.py
@@ -0,0 +1,201 @@
+"""Tests for the spy interaction event values."""
+import pytest
+from typing import List, NamedTuple
+
+from decoy.spy_events import (
+    PropAccessType,
+    SpyCall,
+    SpyEvent,
+    SpyPropAccess,
+    SpyRehearsal,
+    WhenRehearsal,
+    VerifyRehearsal,
+    match_event,
+)
+
+
+class MatchEventSpec(NamedTuple):
+    """Spec data for testing `match_event`."""
+
+    event: SpyEvent
+    rehearsal: SpyRehearsal
+    expected_result: bool
+
+
+match_event_specs: List[MatchEventSpec] = [
+    MatchEventSpec(
+        event=SpyEvent(
+            spy_id=42,
+            spy_name="my_spy",
+            payload=SpyCall(args=(), kwargs={}),
+        ),
+        rehearsal=WhenRehearsal(
+            spy_id=42,
+            spy_name="my_spy",
+            payload=SpyCall(args=(), kwargs={}),
+        ),
+        expected_result=True,
+    ),
+    MatchEventSpec(
+        event=SpyEvent(
+            spy_id=42,
+            spy_name="my_spy",
+            payload=SpyCall(args=(1, 2), kwargs={"foo": "bar", "baz": "qux"}),
+        ),
+        rehearsal=VerifyRehearsal(
+            spy_id=42,
+            spy_name="my_spy",
+            payload=SpyCall(args=(1, 2), kwargs={"foo": "bar", "baz": "qux"}),
+        ),
+        expected_result=True,
+    ),
+    MatchEventSpec(
+        event=SpyEvent(
+            spy_id=42,
+            spy_name="my_spy",
+            payload=SpyCall(args=(1, 2), kwargs={"foo": "bar", "baz": "qux"}),
+        ),
+        rehearsal=WhenRehearsal(
+            spy_id=42,
+            spy_name="my_spy",
+            payload=SpyCall(args=(1,), kwargs={"baz": "qux"}, ignore_extra_args=True),
+        ),
+        expected_result=True,
+    ),
+    MatchEventSpec(
+        event=SpyEvent(
+            spy_id=42,
+            spy_name="my_spy",
+            payload=SpyCall(args=(), kwargs={}),
+        ),
+        rehearsal=VerifyRehearsal(
+            spy_id=21,
+            spy_name="my_spy",
+            payload=SpyCall(args=(), kwargs={}),
+        ),
+        expected_result=False,
+    ),
+    MatchEventSpec(
+        event=SpyEvent(
+            spy_id=42,
+            spy_name="my_spy",
+            payload=SpyCall(args=(1,), kwargs={}),
+        ),
+        rehearsal=WhenRehearsal(
+            spy_id=42,
+            spy_name="my_spy",
+            payload=SpyCall(args=(), kwargs={}),
+        ),
+        expected_result=False,
+    ),
+    MatchEventSpec(
+        event=SpyEvent(
+            spy_id=42,
+            spy_name="my_spy",
+            payload=SpyCall(args=(), kwargs={}),
+        ),
+        rehearsal=VerifyRehearsal(
+            spy_id=42,
+            spy_name="my_spy",
+            payload=SpyCall(args=(1,), kwargs={}),
+        ),
+        expected_result=False,
+    ),
+    MatchEventSpec(
+        event=SpyEvent(
+            spy_id=42,
+            spy_name="my_spy",
+            payload=SpyCall(args=(1, 2), kwargs={"foo": "bar", "baz": "qux"}),
+        ),
+        rehearsal=WhenRehearsal(
+            spy_id=42,
+            spy_name="my_spy",
+            payload=SpyCall(args=(2,), kwargs={"baz": "qux"}, ignore_extra_args=True),
+        ),
+        expected_result=False,
+    ),
+    MatchEventSpec(
+        event=SpyEvent(
+            spy_id=42,
+            spy_name="my_spy",
+            payload=SpyCall(args=(1, 2), kwargs={"foo": "bar", "baz": "qux"}),
+        ),
+        rehearsal=VerifyRehearsal(
+            spy_id=42,
+            spy_name="my_spy",
+            payload=SpyCall(args=(1, 2), kwargs={"foo": "qux"}, ignore_extra_args=True),
+        ),
+        expected_result=False,
+    ),
+    MatchEventSpec(
+        event=SpyEvent(
+            spy_id=42,
+            spy_name="my_spy",
+            payload=SpyCall(args=(1, 2), kwargs={"foo": "bar", "baz": "qux"}),
+        ),
+        rehearsal=WhenRehearsal(
+            spy_id=42,
+            spy_name="my_spy",
+            payload=SpyCall(
+                args=(1, 2, 3),
+                kwargs={"foo": "bar", "baz": "qux"},
+                ignore_extra_args=True,
+            ),
+        ),
+        expected_result=False,
+    ),
+    MatchEventSpec(
+        event=SpyEvent(
+            spy_id=42,
+            spy_name="my_spy",
+            payload=SpyCall(args=(1, 2), kwargs={"foo": "bar", "baz": "qux"}),
+        ),
+        rehearsal=VerifyRehearsal(
+            spy_id=42,
+            spy_name="my_spy",
+            payload=SpyCall(
+                args=(1, 2),
+                kwargs={"foo": "bar", "baz": "qux", "fizz": "buzz"},
+                ignore_extra_args=True,
+            ),
+        ),
+        expected_result=False,
+    ),
+    MatchEventSpec(
+        event=SpyEvent(
+            spy_id=42,
+            spy_name="my_spy",
+            payload=SpyPropAccess(prop_name="prop", access_type=PropAccessType.GET),
+        ),
+        rehearsal=WhenRehearsal(
+            spy_id=42,
+            spy_name="my_spy",
+            payload=SpyPropAccess(prop_name="prop", access_type=PropAccessType.GET),
+        ),
+        expected_result=True,
+    ),
+    MatchEventSpec(
+        event=SpyEvent(
+            spy_id=42,
+            spy_name="my_spy",
+            payload=SpyPropAccess(prop_name="prop", access_type=PropAccessType.DELETE),
+        ),
+        rehearsal=WhenRehearsal(
+            spy_id=42,
+            spy_name="my_spy",
+            payload=SpyPropAccess(prop_name="prop", access_type=PropAccessType.GET),
+        ),
+        expected_result=False,
+    ),
+]
+
+
+@pytest.mark.parametrize(MatchEventSpec._fields, match_event_specs)
+def test_match_event(
+    event: SpyEvent,
+    rehearsal: SpyRehearsal,
+    expected_result: bool,
+) -> None:
+    """It should match a call to a rehearsal."""
+    result = match_event(event, rehearsal)
+    assert result is expected_result
diff --git a/tests/test_spy_log.py b/tests/test_spy_log.py
index ca94ab7..bb4f2e1 100644
--- a/tests/test_spy_log.py
+++ b/tests/test_spy_log.py
@@ -2,14 +2,26 @@
 import pytest
 
 from decoy.errors import MissingRehearsalError
-from decoy.spy_calls import SpyCall, WhenRehearsal, VerifyRehearsal
+from decoy.spy_events import (
+    SpyCall,
+    SpyEvent,
+    SpyPropAccess,
+    PropAccessType,
+    WhenRehearsal,
+    VerifyRehearsal,
+    PropRehearsal,
+)
 from decoy.spy_log import SpyLog
 
 
 def test_push_and_consume_when_rehearsal() -> None:
     """It should be able to push and pop from the stack."""
     subject = SpyLog()
-    call = SpyCall(spy_id=42, spy_name="my_spy", args=(), kwargs={})
+    call = SpyEvent(
+        spy_id=42,
+        spy_name="my_spy",
+        payload=SpyCall(args=(), kwargs={}),
+    )
 
     subject.push(call)
     result = subject.consume_when_rehearsal(ignore_extra_args=False)
@@ -21,13 +33,33 @@ def test_push_and_consume_when_rehearsal() -> None:
 def test_push_and_consume_when_rehearsal_ignore_extra_args() -> None:
     """It should be able to push and pop from the stack while ignoring extra args."""
     subject = SpyLog()
-    call = SpyCall(spy_id=42, spy_name="my_spy", args=(), kwargs={})
+    call = SpyEvent(
+        spy_id=42,
+        spy_name="my_spy",
+        payload=SpyCall(args=(), kwargs={}),
+    )
 
     subject.push(call)
     result = subject.consume_when_rehearsal(ignore_extra_args=True)
 
     assert isinstance(result, WhenRehearsal)
-    assert result.ignore_extra_args is True
+    assert isinstance(result.payload, SpyCall)
+    assert result.payload.ignore_extra_args is True
+
+
+def test_push_and_consume_prop_rehearsal_for_when() -> None:
+    """It should be able to push and consume a prop rehearsal for stubbing."""
+    subject = SpyLog()
+    event = SpyEvent(
+        spy_id=42,
+        spy_name="my_spy",
+        payload=SpyPropAccess(prop_name="my_prop", access_type=PropAccessType.GET),
+    )
+
+    subject.push(event)
+    result = subject.consume_when_rehearsal(ignore_extra_args=False)
+    assert isinstance(result, WhenRehearsal)
+    assert result == event
 
 
 def test_consume_when_rehearsal_raises_empty_error() -> None:
@@ -37,7 +69,11 @@ def test_consume_when_rehearsal_raises_empty_error() -> None:
     with pytest.raises(MissingRehearsalError):
         subject.consume_when_rehearsal(ignore_extra_args=False)
 
-    call = SpyCall(spy_id=42, spy_name="my_spy", args=(), kwargs={})
+    call = SpyEvent(
+        spy_id=42,
+        spy_name="my_spy",
+        payload=SpyCall(args=(), kwargs={}),
+    )
     subject.push(call)
     subject.consume_when_rehearsal(ignore_extra_args=False)
 
@@ -45,19 +81,81 @@ def test_consume_when_rehearsal_raises_empty_error() -> None:
         subject.consume_when_rehearsal(ignore_extra_args=False)
 
 
+def test_push_and_consume_prop_rehearsal_for_prop() -> None:
+    """It should be able to push and consume a prop rehearsal for more rehearsals."""
+    subject = SpyLog()
+    event = SpyEvent(
+        spy_id=42,
+        spy_name="my_spy",
+        payload=SpyPropAccess(prop_name="my_prop", access_type=PropAccessType.GET),
+    )
+
+    subject.push(event)
+    result = subject.consume_prop_rehearsal()
+    assert isinstance(result, PropRehearsal)
+    assert result == event
+
+
+def test_consume_prop_rehearsal_raises_empty_error() -> None:
+    """It should raise an error a valid rehearsal event isn't found."""
+    subject = SpyLog()
+
+    with pytest.raises(MissingRehearsalError):
+        subject.consume_prop_rehearsal()
+
+    event = SpyEvent(
+        spy_id=42,
+        spy_name="my_spy",
+        payload=SpyPropAccess(prop_name="my_prop", access_type=PropAccessType.GET),
+    )
+    subject.push(event)
+    subject.consume_prop_rehearsal()
+
+    with pytest.raises(MissingRehearsalError):
+        subject.consume_prop_rehearsal()
+
+    call = SpyEvent(
+        spy_id=42,
+        spy_name="my_spy",
+        payload=SpyCall(args=(), kwargs={}),
+    )
+    subject.push(call)
+
+    with pytest.raises(MissingRehearsalError):
+        subject.consume_prop_rehearsal()
+
+    event = SpyEvent(
+        spy_id=42,
+        spy_name="my_spy",
+        payload=SpyPropAccess(prop_name="my_prop", access_type=PropAccessType.DELETE),
+    )
+    subject.push(event)
+
+    with pytest.raises(MissingRehearsalError):
+        subject.consume_prop_rehearsal()
+
+
 def test_consume_verify_rehearsals() -> None:
     """It should be able to pop a slice off the stack, retaining order."""
     subject = SpyLog()
-    call_1 = SpyCall(spy_id=1, spy_name="spy_1", args=(), kwargs={})
-    call_2 = SpyCall(spy_id=2, spy_name="spy_2", args=(), kwargs={})
+    call_1 = SpyEvent(spy_id=1, spy_name="spy_1", payload=SpyCall(args=(), kwargs={}))
+    call_2 = SpyEvent(spy_id=2, spy_name="spy_2", payload=SpyCall(args=(), kwargs={}))
 
     subject.push(call_1)
     subject.push(call_2)
 
     result = subject.consume_verify_rehearsals(count=2, ignore_extra_args=False)
     assert result == [
-        VerifyRehearsal(spy_id=1, spy_name="spy_1", args=(), kwargs={}),
-        VerifyRehearsal(spy_id=2, spy_name="spy_2", args=(), kwargs={}),
+        VerifyRehearsal(
+            spy_id=1,
+            spy_name="spy_1",
+            payload=SpyCall(args=(), kwargs={}),
+        ),
+        VerifyRehearsal(
+            spy_id=2,
+            spy_name="spy_2",
+            payload=SpyCall(args=(), kwargs={}),
+        ),
     ]
 
     with pytest.raises(MissingRehearsalError):
@@ -70,8 +168,8 @@ def test_consume_verify_rehearsals() -> None:
 def test_consume_verify_rehearsals_ignore_extra_args() -> None:
     """It should be able to pop a slice off the stack, retaining order."""
     subject = SpyLog()
-    call_1 = SpyCall(spy_id=1, spy_name="spy_1", args=(), kwargs={})
-    call_2 = SpyCall(spy_id=2, spy_name="spy_2", args=(), kwargs={})
+    call_1 = SpyEvent(spy_id=1, spy_name="spy_1", payload=SpyCall(args=(), kwargs={}))
+    call_2 = SpyEvent(spy_id=2, spy_name="spy_2", payload=SpyCall(args=(), kwargs={}))
 
     subject.push(call_1)
     subject.push(call_2)
@@ -79,10 +177,60 @@ def test_consume_verify_rehearsals_ignore_extra_args() -> None:
     result = subject.consume_verify_rehearsals(count=2, ignore_extra_args=True)
     assert result == [
         VerifyRehearsal(
-            spy_id=1, spy_name="spy_1", args=(), kwargs={}, ignore_extra_args=True
+            spy_id=1,
+            spy_name="spy_1",
+            payload=SpyCall(args=(), kwargs={}, ignore_extra_args=True),
+        ),
+        VerifyRehearsal(
+            spy_id=2,
+            spy_name="spy_2",
+            payload=SpyCall(args=(), kwargs={}, ignore_extra_args=True),
+        ),
+    ]
+
+
+def test_consume_verify_rehearsals_ignores_prop_gets() -> None:
+    """It should be able to pop a slice off the stack, retaining order."""
+    subject = SpyLog()
+    call_1 = SpyEvent(
+        spy_id=101, spy_name="spy_1", payload=SpyCall(args=(1,), kwargs={})
+    )
+    call_2 = SpyEvent(
+        spy_id=101,
+        spy_name="spy_1",
+        payload=SpyPropAccess(prop_name="child", access_type=PropAccessType.GET),
+    )
+    call_3 = SpyEvent(
+        spy_id=102,
+        spy_name="spy_1.child",
+        payload=SpyCall(args=(2,), kwargs={}),
+    )
+    call_4 = SpyEvent(
+        spy_id=102,
+        spy_name="spy_1.child",
+        payload=SpyPropAccess(prop_name="fizz", access_type=PropAccessType.DELETE),
+    )
+    subject.push(call_1)
+    subject.push(call_2)
+    subject.push(call_3)
+    subject.push(call_4)
+
+    result = subject.consume_verify_rehearsals(count=3, ignore_extra_args=False)
+    assert result == [
+        VerifyRehearsal(
+            spy_id=101,
+            spy_name="spy_1",
+            payload=SpyCall(args=(1,), kwargs={}),
+        ),
+        VerifyRehearsal(
+            spy_id=102,
+            spy_name="spy_1.child",
+            payload=SpyCall(args=(2,), kwargs={}),
         ),
         VerifyRehearsal(
-            spy_id=2, spy_name="spy_2", args=(), kwargs={}, ignore_extra_args=True
+            spy_id=102,
+            spy_name="spy_1.child",
+            payload=SpyPropAccess(prop_name="fizz", access_type=PropAccessType.DELETE),
         ),
     ]
 
@@ -90,7 +238,7 @@ def test_consume_verify_rehearsals_ignore_extra_args() -> None:
 def test_consume_verify_rehearsals_raises_error() -> None:
     """It should raise an error if the stack has too few members to pop a slice."""
     subject = SpyLog()
-    call_1 = SpyCall(spy_id=1, spy_name="spy_1", args=(), kwargs={})
+    call_1 = SpyEvent(spy_id=1, spy_name="spy_1", payload=SpyCall(args=(), kwargs={}))
 
     subject.push(call_1)
 
@@ -98,13 +246,29 @@ def test_consume_verify_rehearsals_raises_error() -> None:
         subject.consume_verify_rehearsals(count=2, ignore_extra_args=False)
 
 
-def test_get_by_rehearsal() -> None:
+def test_get_calls_to_verify() -> None:
     """It can get a list of calls made matching spy IDs of given rehearsals."""
     subject = SpyLog()
-    call_1 = SpyCall(spy_id=101, spy_name="spy_1", args=(1,), kwargs={})
-    call_2 = SpyCall(spy_id=101, spy_name="spy_1", args=(2,), kwargs={})
-    call_3 = SpyCall(spy_id=202, spy_name="spy_2", args=(1,), kwargs={})
-    call_4 = SpyCall(spy_id=101, spy_name="spy_1", args=(1,), kwargs={})
+    call_1 = SpyEvent(
+        spy_id=101,
+        spy_name="spy_1",
+        payload=SpyCall(args=(1,), kwargs={}),
+    )
+    call_2 = SpyEvent(
+        spy_id=101,
+        spy_name="spy_1",
+        payload=SpyCall(args=(2,), kwargs={}),
+    )
+    call_3 = SpyEvent(
+        spy_id=202,
+        spy_name="spy_2",
+        payload=SpyCall(args=(1,), kwargs={}),
+    )
+    call_4 = SpyEvent(
+        spy_id=101,
+        spy_name="spy_1",
+        payload=SpyCall(args=(1,), kwargs={}),
+    )
 
     subject.push(call_1)
     subject.push(call_2)
@@ -112,31 +276,55 @@ def test_get_by_rehearsal() -> None:
     subject.push(call_3)
     subject.push(call_4)
 
-    result = subject.get_by_rehearsals(
-        [VerifyRehearsal(spy_id=101, spy_name="spy_1", args=(1,), kwargs={})]
-    )
+    result = subject.get_calls_to_verify([101])
     assert result == [call_1, call_4]
 
-    result = subject.get_by_rehearsals(
-        [
-            VerifyRehearsal(spy_id=101, spy_name="spy_1", args=(2,), kwargs={}),
-            VerifyRehearsal(spy_id=202, spy_name="spy_2", args=(1,), kwargs={}),
-        ]
-    )
+    result = subject.get_calls_to_verify([101, 202])
     assert result == [call_1, call_3, call_4]
 
-    result = subject.get_by_rehearsals(
-        [VerifyRehearsal(spy_id=303, spy_name="spy_3", args=(1,), kwargs={})]
-    )
+    result = subject.get_calls_to_verify([303])
     assert result == []
 
 
+def test_get_calls_to_verify_skips_prop_gets() -> None:
+    """It does not return prop getters for verification."""
+    subject = SpyLog()
+
+    call_1 = SpyEvent(
+        spy_id=101,
+        spy_name="spy_1",
+        payload=SpyPropAccess(prop_name="child", access_type=PropAccessType.GET),
+    )
+    call_2 = PropRehearsal(
+        spy_id=101,
+        spy_name="spy_1",
+        payload=SpyPropAccess(prop_name="child", access_type=PropAccessType.GET),
+    )
+    call_3 = SpyEvent(
+        spy_id=102,
+        spy_name="spy_1.child",
+        payload=SpyCall(args=(2,), kwargs={}),
+    )
+    call_4 = SpyEvent(
+        spy_id=102,
+        spy_name="spy_1.child",
+        payload=SpyPropAccess(prop_name="fizz", access_type=PropAccessType.DELETE),
+    )
+
+    subject.push(call_1)
+    subject.push(call_2)
+    subject.push(call_3)
+    subject.push(call_4)
+    result = subject.get_calls_to_verify([101, 102])
+    assert result == [call_3, call_4]
+
+
 def test_get_all() -> None:
     """It can get a list of all calls and rehearsals."""
     subject = SpyLog()
-    call_1 = SpyCall(spy_id=101, spy_name="spy_1", args=(), kwargs={})
-    call_2 = SpyCall(spy_id=101, spy_name="spy_1", args=(), kwargs={})
-    call_3 = SpyCall(spy_id=202, spy_name="spy_2", args=(), kwargs={})
+    call_1 = SpyEvent(spy_id=101, spy_name="spy_1", payload=SpyCall(args=(), kwargs={}))
+    call_2 = SpyEvent(spy_id=101, spy_name="spy_1", payload=SpyCall(args=(), kwargs={}))
+    call_3 = SpyEvent(spy_id=202, spy_name="spy_2", payload=SpyCall(args=(), kwargs={}))
 
     subject.push(call_1)
     subject.consume_when_rehearsal(ignore_extra_args=False)
@@ -145,18 +333,26 @@ def test_get_all() -> None:
     subject.consume_when_rehearsal(ignore_extra_args=False)
 
     assert subject.get_all() == [
-        WhenRehearsal(spy_id=101, spy_name="spy_1", args=(), kwargs={}),
-        SpyCall(spy_id=101, spy_name="spy_1", args=(), kwargs={}),
-        WhenRehearsal(spy_id=202, spy_name="spy_2", args=(), kwargs={}),
+        WhenRehearsal(
+            spy_id=101,
+            spy_name="spy_1",
+            payload=SpyCall(args=(), kwargs={}),
+        ),
+        SpyEvent(
+            spy_id=101,
+            spy_name="spy_1",
+            payload=SpyCall(args=(), kwargs={}),
+        ),
+        SpyEvent(spy_id=202, spy_name="spy_2", payload=SpyCall(args=(), kwargs={})),
     ]
 
 
 def test_clear() -> None:
     """It can clear all calls and rehearsals."""
     subject = SpyLog()
-    call_1 = SpyCall(spy_id=101, spy_name="spy_1", args=(), kwargs={})
-    call_2 = SpyCall(spy_id=101, spy_name="spy_1", args=(), kwargs={})
-    call_3 = SpyCall(spy_id=202, spy_name="spy_2", args=(), kwargs={})
+    call_1 = SpyEvent(spy_id=101, spy_name="spy_1", payload=SpyCall(args=(), kwargs={}))
+    call_2 = SpyEvent(spy_id=101, spy_name="spy_1", payload=SpyCall(args=(), kwargs={}))
+    call_3 = SpyEvent(spy_id=202, spy_name="spy_2", payload=SpyCall(args=(), kwargs={}))
 
     subject.push(call_1)
     subject.consume_when_rehearsal(ignore_extra_args=False)
diff --git a/tests/test_stringify.py b/tests/test_stringify.py
index d2b1aa3..47b7763 100644
--- a/tests/test_stringify.py
+++ b/tests/test_stringify.py
@@ -2,57 +2,99 @@
 import pytest
 from typing import NamedTuple
 
-from decoy.spy_calls import SpyCall
+from decoy.spy_events import SpyCall, SpyEvent, SpyPropAccess, PropAccessType
 from decoy.stringify import stringify_call
 
 
 class StringifyCallSpec(NamedTuple):
     """Spec data for stringify_call tests."""
 
-    call: SpyCall
+    call: SpyEvent
     expected: str
 
 
 stringify_call_specs = [
     StringifyCallSpec(
-        call=SpyCall(spy_id=42, spy_name="some.name", args=(), kwargs={}),
+        call=SpyEvent(
+            spy_id=42, spy_name="some.name", payload=SpyCall(args=(), kwargs={})
+        ),
         expected="some.name()",
     ),
     StringifyCallSpec(
-        call=SpyCall(spy_id=42, spy_name="some.name", args=(1,), kwargs={}),
+        call=SpyEvent(
+            spy_id=42, spy_name="some.name", payload=SpyCall(args=(1,), kwargs={})
+        ),
         expected="some.name(1)",
     ),
     StringifyCallSpec(
-        call=SpyCall(spy_id=42, spy_name="some.name", args=(1, "2"), kwargs={}),
+        call=SpyEvent(
+            spy_id=42, spy_name="some.name", payload=SpyCall(args=(1, "2"), kwargs={})
+        ),
         expected="some.name(1, '2')",
     ),
     StringifyCallSpec(
-        call=SpyCall(spy_id=42, spy_name="some.name", args=(), kwargs={"foo": "bar"}),
+        call=SpyEvent(
+            spy_id=42,
+            spy_name="some.name",
+            payload=SpyCall(args=(), kwargs={"foo": "bar"}),
+        ),
         expected="some.name(foo='bar')",
     ),
     StringifyCallSpec(
-        call=SpyCall(
+        call=SpyEvent(
             spy_id=42,
             spy_name="some.name",
-            args=(1, 2),
-            kwargs={"foo": "bar", "baz": False},
+            payload=SpyCall(
+                args=(1, 2),
+                kwargs={"foo": "bar", "baz": False},
+            ),
         ),
         expected="some.name(1, 2, foo='bar', baz=False)",
     ),
     StringifyCallSpec(
-        call=SpyCall(
+        call=SpyEvent(
             spy_id=42,
             spy_name="some.name",
-            args=(),
-            kwargs={},
-            ignore_extra_args=True,
+            payload=SpyCall(
+                args=(),
+                kwargs={},
+                ignore_extra_args=True,
+            ),
         ),
         expected="some.name() - ignoring unspecified arguments",
     ),
+    StringifyCallSpec(
+        call=SpyEvent(
+            spy_id=42,
+            spy_name="some",
+            payload=SpyPropAccess(prop_name="name", access_type=PropAccessType.GET),
+        ),
+        expected="some.name",
+    ),
+    StringifyCallSpec(
+        call=SpyEvent(
+            spy_id=42,
+            spy_name="some",
+            payload=SpyPropAccess(
+                prop_name="name",
+                access_type=PropAccessType.SET,
+                value=42,
+            ),
+        ),
+        expected="some.name = 42",
+    ),
+    StringifyCallSpec(
+        call=SpyEvent(
+            spy_id=42,
+            spy_name="some",
+            payload=SpyPropAccess(prop_name="name", access_type=PropAccessType.DELETE),
+        ),
+        expected="del some.name",
+    ),
 ]
 
 
 @pytest.mark.parametrize(StringifyCallSpec._fields, stringify_call_specs)
-def test_spy_call_stringifies(call: SpyCall, expected: str) -> None:
-    """It should serialize SpyCalls to strings."""
+def test_spy_call_stringifies(call: SpyEvent, expected: str) -> None:
+    """It should serialize SpyEvents to strings."""
     assert stringify_call(call) == expected
diff --git a/tests/test_stub_store.py b/tests/test_stub_store.py
index 710b7dd..5f49acb 100644
--- a/tests/test_stub_store.py
+++ b/tests/test_stub_store.py
@@ -1,19 +1,21 @@
 """Tests for stub behavior storage."""
 import pytest
 
-from decoy.spy_calls import SpyCall, WhenRehearsal
+from decoy.spy_events import SpyCall, SpyEvent, WhenRehearsal
 from decoy.stub_store import StubStore, StubBehavior
 
 
 def test_get_by_call() -> None:
     """It should be able to add a StubBehavior to the store and get it back."""
     subject = StubStore()
-    rehearsal = WhenRehearsal(spy_id=42, spy_name="my_spy", args=(), kwargs={})
+    rehearsal = WhenRehearsal(
+        spy_id=42, spy_name="my_spy", payload=SpyCall(args=(), kwargs={})
+    )
     behavior = StubBehavior(return_value="hello world")
 
     subject.add(rehearsal=rehearsal, behavior=behavior)
     result = subject.get_by_call(
-        call=SpyCall(spy_id=42, spy_name="my_spy", args=(), kwargs={})
+        call=SpyEvent(spy_id=42, spy_name="my_spy", payload=SpyCall(args=(), kwargs={}))
     )
 
     assert result == behavior
@@ -22,15 +24,19 @@ def test_get_by_call() -> None:
 def test_get_by_call_prefers_latest() -> None:
     """It should be prefer later stubs if multiple exist."""
     subject = StubStore()
-    rehearsal_1 = WhenRehearsal(spy_id=42, spy_name="my_spy", args=(), kwargs={})
+    rehearsal_1 = WhenRehearsal(
+        spy_id=42, spy_name="my_spy", payload=SpyCall(args=(), kwargs={})
+    )
     behavior_1 = StubBehavior(return_value="hello")
-    rehearsal_2 = WhenRehearsal(spy_id=42, spy_name="my_spy", args=(), kwargs={})
+    rehearsal_2 = WhenRehearsal(
+        spy_id=42, spy_name="my_spy", payload=SpyCall(args=(), kwargs={})
+    )
     behavior_2 = StubBehavior(return_value="goodbye")
 
     subject.add(rehearsal=rehearsal_1, behavior=behavior_1)
     subject.add(rehearsal=rehearsal_2, behavior=behavior_2)
     result = subject.get_by_call(
-        call=SpyCall(spy_id=42, spy_name="my_spy", args=(), kwargs={})
+        call=SpyEvent(spy_id=42, spy_name="my_spy", payload=SpyCall(args=(), kwargs={}))
     )
 
     assert result == behavior_2
@@ -40,7 +46,7 @@ def test_get_by_call_empty() -> None:
     """It should return None if store is empty."""
     subject = StubStore()
     result = subject.get_by_call(
-        call=SpyCall(spy_id=42, spy_name="my_spy", args=(), kwargs={})
+        call=SpyEvent(spy_id=42, spy_name="my_spy", payload=SpyCall(args=(), kwargs={}))
     )
 
     assert result is None
@@ -49,37 +55,45 @@ def test_get_by_call_empty() -> None:
 @pytest.mark.parametrize(
     "call",
     [
-        SpyCall(
+        SpyEvent(
             # spy_id does not match
             spy_id=24,
             spy_name="my_spy",
-            args=("hello", "world"),
-            kwargs={"goodbye": "so long"},
+            payload=SpyCall(
+                args=("hello", "world"),
+                kwargs={"goodbye": "so long"},
+            ),
         ),
-        SpyCall(
+        SpyEvent(
             spy_id=42,
             spy_name="my_spy",
             # args do not match
-            args=("hello", "wisconsin"),
-            kwargs={"goodbye": "so long"},
+            payload=SpyCall(
+                args=("hello", "wisconsin"),
+                kwargs={"goodbye": "so long"},
+            ),
         ),
-        SpyCall(
+        SpyEvent(
             spy_id=42,
             spy_name="my_spy",
-            args=("hello", "wisconsin"),
-            # kwargs do not match
-            kwargs={"goodbye": "sofa"},
+            payload=SpyCall(
+                args=("hello", "wisconsin"),
+                # kwargs do not match
+                kwargs={"goodbye": "sofa"},
+            ),
         ),
     ],
 )
-def test_get_by_call_no_match(call: SpyCall) -> None:
+def test_get_by_call_no_match(call: SpyEvent) -> None:
     """It should return a no-op StubBehavior if there are no matching calls."""
     subject = StubStore()
     rehearsal = WhenRehearsal(
         spy_id=42,
         spy_name="my_spy",
-        args=("hello", "world"),
-        kwargs={"goodbye": "so long"},
+        payload=SpyCall(
+            args=("hello", "world"),
+            kwargs={"goodbye": "so long"},
+        ),
     )
     behavior = StubBehavior(return_value="fizzbuzz")
 
@@ -92,19 +106,25 @@ def test_get_by_call_no_match(call: SpyCall) -> None:
 def test_get_by_call_once_behavior() -> None:
     """It should consume any behavior marked with the `once` flag."""
     subject = StubStore()
-    rehearsal = WhenRehearsal(spy_id=42, spy_name="my_spy", args=(1, 2, 3), kwargs={})
+    rehearsal = WhenRehearsal(
+        spy_id=42, spy_name="my_spy", payload=SpyCall(args=(1, 2, 3), kwargs={})
+    )
     behavior = StubBehavior(return_value="fizzbuzz", once=True)
 
     subject.add(rehearsal=rehearsal, behavior=behavior)
 
     result = subject.get_by_call(
-        call=SpyCall(spy_id=42, spy_name="my_spy", args=(1, 2, 3), kwargs={})
+        call=SpyEvent(
+            spy_id=42, spy_name="my_spy", payload=SpyCall(args=(1, 2, 3), kwargs={})
+        )
     )
 
     assert result == behavior
 
     result = subject.get_by_call(
-        call=SpyCall(spy_id=42, spy_name="my_spy", args=(1, 2, 3), kwargs={})
+        call=SpyEvent(
+            spy_id=42, spy_name="my_spy", payload=SpyCall(args=(1, 2, 3), kwargs={})
+        )
     )
 
     assert result is None
@@ -113,8 +133,10 @@ def test_get_by_call_once_behavior() -> None:
 def test_clear() -> None:
     """It should consume any behavior marked with the `once` flag."""
     subject = StubStore()
-    call = SpyCall(spy_id=42, spy_name="my_spy", args=(), kwargs={})
-    rehearsal = WhenRehearsal(spy_id=42, spy_name="my_spy", args=(), kwargs={})
+    call = SpyEvent(spy_id=42, spy_name="my_spy", payload=SpyCall(args=(), kwargs={}))
+    rehearsal = WhenRehearsal(
+        spy_id=42, spy_name="my_spy", payload=SpyCall(args=(), kwargs={})
+    )
     behavior = StubBehavior(return_value="fizzbuzz")
 
     subject.add(rehearsal=rehearsal, behavior=behavior)
diff --git a/tests/test_verifier.py b/tests/test_verifier.py
index 355407e..e554c17 100644
--- a/tests/test_verifier.py
+++ b/tests/test_verifier.py
@@ -2,7 +2,7 @@
 import pytest
 from typing import List, NamedTuple, Optional
 
-from decoy.spy_calls import SpyCall, VerifyRehearsal
+from decoy.spy_events import SpyCall, SpyEvent, VerifyRehearsal
 from decoy.errors import VerifyError
 from decoy.verifier import Verifier
 
@@ -11,66 +11,146 @@ class VerifySpec(NamedTuple):
     """Spec data for verifier.verify tests."""
 
     rehearsals: List[VerifyRehearsal]
-    calls: List[SpyCall]
+    calls: List[SpyEvent]
     times: Optional[int] = None
 
 
 verify_raise_specs = [
     VerifySpec(
         rehearsals=[
-            VerifyRehearsal(spy_id=42, spy_name="my_spy", args=(), kwargs={}),
+            VerifyRehearsal(
+                spy_id=42, spy_name="my_spy", payload=SpyCall(args=(), kwargs={})
+            ),
         ],
         calls=[],
     ),
     VerifySpec(
         rehearsals=[
-            VerifyRehearsal(spy_id=42, spy_name="my_spy", args=(), kwargs={}),
+            VerifyRehearsal(
+                spy_id=42, spy_name="my_spy", payload=SpyCall(args=(), kwargs={})
+            ),
         ],
         calls=[
-            SpyCall(spy_id=101, spy_name="spy_101", args=(1, 2, 3), kwargs={}),
-            SpyCall(spy_id=101, spy_name="spy_101", args=(4, 5, 6), kwargs={}),
+            SpyEvent(
+                spy_id=101,
+                spy_name="spy_101",
+                payload=SpyCall(args=(1, 2, 3), kwargs={}),
+            ),
+            SpyEvent(
+                spy_id=101,
+                spy_name="spy_101",
+                payload=SpyCall(args=(4, 5, 6), kwargs={}),
+            ),
         ],
     ),
     VerifySpec(
         rehearsals=[
-            VerifyRehearsal(spy_id=101, spy_name="spy_101", args=(1, 2, 3), kwargs={}),
-            VerifyRehearsal(spy_id=101, spy_name="spy_101", args=(4, 5, 6), kwargs={}),
-            VerifyRehearsal(spy_id=202, spy_name="spy_202", args=(7, 8, 9), kwargs={}),
+            VerifyRehearsal(
+                spy_id=101,
+                spy_name="spy_101",
+                payload=SpyCall(args=(1, 2, 3), kwargs={}),
+            ),
+            VerifyRehearsal(
+                spy_id=101,
+                spy_name="spy_101",
+                payload=SpyCall(args=(4, 5, 6), kwargs={}),
+            ),
+            VerifyRehearsal(
+                spy_id=202,
+                spy_name="spy_202",
+                payload=SpyCall(args=(7, 8, 9), kwargs={}),
+            ),
         ],
         calls=[
-            SpyCall(spy_id=101, spy_name="spy_101", args=(1, 2, 3), kwargs={}),
-            SpyCall(spy_id=101, spy_name="spy_101", args=(4, 5, 6), kwargs={}),
-            SpyCall(spy_id=202, spy_name="spy_202", args=("oh no",), kwargs={}),
+            SpyEvent(
+                spy_id=101,
+                spy_name="spy_101",
+                payload=SpyCall(args=(1, 2, 3), kwargs={}),
+            ),
+            SpyEvent(
+                spy_id=101,
+                spy_name="spy_101",
+                payload=SpyCall(args=(4, 5, 6), kwargs={}),
+            ),
+            SpyEvent(
+                spy_id=202,
+                spy_name="spy_202",
+                payload=SpyCall(args=("oh no",), kwargs={}),
+            ),
         ],
     ),
     VerifySpec(
         rehearsals=[
-            VerifyRehearsal(spy_id=101, spy_name="spy_101", args=(1, 2, 3), kwargs={}),
-            VerifyRehearsal(spy_id=101, spy_name="spy_101", args=(4, 5, 6), kwargs={}),
-            VerifyRehearsal(spy_id=202, spy_name="spy_202", args=(7, 8, 9), kwargs={}),
+            VerifyRehearsal(
+                spy_id=101,
+                spy_name="spy_101",
+                payload=SpyCall(args=(1, 2, 3), kwargs={}),
+            ),
+            VerifyRehearsal(
+                spy_id=101,
+                spy_name="spy_101",
+                payload=SpyCall(args=(4, 5, 6), kwargs={}),
+            ),
+            VerifyRehearsal(
+                spy_id=202,
+                spy_name="spy_202",
+                payload=SpyCall(args=(7, 8, 9), kwargs={}),
+            ),
         ],
         calls=[
-            SpyCall(spy_id=101, spy_name="spy_101", args=(1, 2, 3), kwargs={}),
-            SpyCall(spy_id=101, spy_name="spy_101", args=(4, 5, 6), kwargs={}),
+            SpyEvent(
+                spy_id=101,
+                spy_name="spy_101",
+                payload=SpyCall(args=(1, 2, 3), kwargs={}),
+            ),
+            SpyEvent(
+                spy_id=101,
+                spy_name="spy_101",
+                payload=SpyCall(args=(4, 5, 6), kwargs={}),
+            ),
         ],
     ),
     VerifySpec(
         rehearsals=[
-            VerifyRehearsal(spy_id=101, spy_name="spy_101", args=(1, 2, 3), kwargs={}),
+            VerifyRehearsal(
+                spy_id=101,
+                spy_name="spy_101",
+                payload=SpyCall(args=(1, 2, 3), kwargs={}),
+            ),
         ],
         calls=[
-            SpyCall(spy_id=101, spy_name="spy_101", args=(1, 2, 3), kwargs={}),
-            SpyCall(spy_id=101, spy_name="spy_101", args=(1, 2, 3), kwargs={}),
+            SpyEvent(
+                spy_id=101,
+                spy_name="spy_101",
+                payload=SpyCall(args=(1, 2, 3), kwargs={}),
+            ),
+            SpyEvent(
+                spy_id=101,
+                spy_name="spy_101",
+                payload=SpyCall(args=(1, 2, 3), kwargs={}),
+            ),
         ],
         times=1,
     ),
     VerifySpec(
         rehearsals=[
-            VerifyRehearsal(spy_id=101, spy_name="spy_101", args=(1, 2, 3), kwargs={}),
+            VerifyRehearsal(
+                spy_id=101,
+                spy_name="spy_101",
+                payload=SpyCall(args=(1, 2, 3), kwargs={}),
+            ),
         ],
         calls=[
-            SpyCall(spy_id=101, spy_name="spy_101", args=(1, 2, 3), kwargs={}),
-            SpyCall(spy_id=101, spy_name="spy_101", args=(1, 2, 3), kwargs={}),
+            SpyEvent(
+                spy_id=101,
+                spy_name="spy_101",
+                payload=SpyCall(args=(1, 2, 3), kwargs={}),
+            ),
+            SpyEvent(
+                spy_id=101,
+                spy_name="spy_101",
+                payload=SpyCall(args=(1, 2, 3), kwargs={}),
+            ),
         ],
         times=0,
     ),
@@ -79,60 +159,144 @@ class VerifySpec(NamedTuple):
 verify_pass_specs = [
     VerifySpec(
         rehearsals=[
-            VerifyRehearsal(spy_id=42, spy_name="my_spy", args=(1, 2, 3), kwargs={}),
+            VerifyRehearsal(
+                spy_id=42, spy_name="my_spy", payload=SpyCall(args=(1, 2, 3), kwargs={})
+            ),
         ],
         calls=[
-            SpyCall(spy_id=42, spy_name="my_spy", args=(1, 2, 3), kwargs={}),
+            SpyEvent(
+                spy_id=42, spy_name="my_spy", payload=SpyCall(args=(1, 2, 3), kwargs={})
+            ),
         ],
     ),
     VerifySpec(
         rehearsals=[
-            VerifyRehearsal(spy_id=101, spy_name="spy_101", args=(1, 2, 3), kwargs={}),
-            VerifyRehearsal(spy_id=101, spy_name="spy_101", args=(4, 5, 6), kwargs={}),
-            VerifyRehearsal(spy_id=202, spy_name="spy_202", args=(7, 8, 9), kwargs={}),
+            VerifyRehearsal(
+                spy_id=101,
+                spy_name="spy_101",
+                payload=SpyCall(args=(1, 2, 3), kwargs={}),
+            ),
+            VerifyRehearsal(
+                spy_id=101,
+                spy_name="spy_101",
+                payload=SpyCall(args=(4, 5, 6), kwargs={}),
+            ),
+            VerifyRehearsal(
+                spy_id=202,
+                spy_name="spy_202",
+                payload=SpyCall(args=(7, 8, 9), kwargs={}),
+            ),
         ],
         calls=[
-            SpyCall(spy_id=101, spy_name="spy_101", args=(1, 2, 3), kwargs={}),
-            SpyCall(spy_id=101, spy_name="spy_101", args=(4, 5, 6), kwargs={}),
-            SpyCall(spy_id=202, spy_name="spy_202", args=(7, 8, 9), kwargs={}),
+            SpyEvent(
+                spy_id=101,
+                spy_name="spy_101",
+                payload=SpyCall(args=(1, 2, 3), kwargs={}),
+            ),
+            SpyEvent(
+                spy_id=101,
+                spy_name="spy_101",
+                payload=SpyCall(args=(4, 5, 6), kwargs={}),
+            ),
+            SpyEvent(
+                spy_id=202,
+                spy_name="spy_202",
+                payload=SpyCall(args=(7, 8, 9), kwargs={}),
+            ),
         ],
     ),
     VerifySpec(
         rehearsals=[
-            VerifyRehearsal(spy_id=101, spy_name="spy_101", args=(1, 2, 3), kwargs={}),
-            VerifyRehearsal(spy_id=101, spy_name="spy_101", args=(4, 5, 6), kwargs={}),
-            VerifyRehearsal(spy_id=202, spy_name="spy_202", args=(7, 8, 9), kwargs={}),
+            VerifyRehearsal(
+                spy_id=101,
+                spy_name="spy_101",
+                payload=SpyCall(args=(1, 2, 3), kwargs={}),
+            ),
+            VerifyRehearsal(
+                spy_id=101,
+                spy_name="spy_101",
+                payload=SpyCall(args=(4, 5, 6), kwargs={}),
+            ),
+            VerifyRehearsal(
+                spy_id=202,
+                spy_name="spy_202",
+                payload=SpyCall(args=(7, 8, 9), kwargs={}),
+            ),
         ],
         calls=[
-            SpyCall(spy_id=101, spy_name="spy_101", args=(0, 0, 0), kwargs={}),
-            SpyCall(spy_id=101, spy_name="spy_101", args=(1, 2, 3), kwargs={}),
-            SpyCall(spy_id=101, spy_name="spy_101", args=(4, 5, 6), kwargs={}),
-            SpyCall(spy_id=202, spy_name="spy_202", args=(7, 8, 9), kwargs={}),
+            SpyEvent(
+                spy_id=101,
+                spy_name="spy_101",
+                payload=SpyCall(args=(0, 0, 0), kwargs={}),
+            ),
+            SpyEvent(
+                spy_id=101,
+                spy_name="spy_101",
+                payload=SpyCall(args=(1, 2, 3), kwargs={}),
+            ),
+            SpyEvent(
+                spy_id=101,
+                spy_name="spy_101",
+                payload=SpyCall(args=(4, 5, 6), kwargs={}),
+            ),
+            SpyEvent(
+                spy_id=202,
+                spy_name="spy_202",
+                payload=SpyCall(args=(7, 8, 9), kwargs={}),
+            ),
         ],
     ),
     VerifySpec(
         rehearsals=[
-            VerifyRehearsal(spy_id=101, spy_name="spy_101", args=(1, 2, 3), kwargs={}),
+            VerifyRehearsal(
+                spy_id=101,
+                spy_name="spy_101",
+                payload=SpyCall(args=(1, 2, 3), kwargs={}),
+            ),
         ],
         calls=[
-            SpyCall(spy_id=101, spy_name="spy_101", args=(1, 2, 3), kwargs={}),
-            SpyCall(spy_id=101, spy_name="spy_101", args=(1, 2, 3), kwargs={}),
+            SpyEvent(
+                spy_id=101,
+                spy_name="spy_101",
+                payload=SpyCall(args=(1, 2, 3), kwargs={}),
+            ),
+            SpyEvent(
+                spy_id=101,
+                spy_name="spy_101",
+                payload=SpyCall(args=(1, 2, 3), kwargs={}),
+            ),
         ],
         times=2,
     ),
     VerifySpec(
         rehearsals=[
-            VerifyRehearsal(spy_id=101, spy_name="spy_101", args=(1, 2, 3), kwargs={}),
+            VerifyRehearsal(
+                spy_id=101,
+                spy_name="spy_101",
+                payload=SpyCall(args=(1, 2, 3), kwargs={}),
+            ),
         ],
         calls=[
-            SpyCall(spy_id=101, spy_name="spy_101", args=(4, 5, 6), kwargs={}),
-            SpyCall(spy_id=101, spy_name="spy_101", args=(1, 2, 3), kwargs={}),
+            SpyEvent(
+                spy_id=101,
+                spy_name="spy_101",
+                payload=SpyCall(args=(4, 5, 6), kwargs={}),
+            ),
+            SpyEvent(
+                spy_id=101,
+                spy_name="spy_101",
+                payload=SpyCall(args=(1, 2, 3), kwargs={}),
+            ),
         ],
         times=1,
     ),
     VerifySpec(
         rehearsals=[
-            VerifyRehearsal(spy_id=101, spy_name="spy_101", args=(1, 2, 3), kwargs={}),
+            VerifyRehearsal(
+                spy_id=101,
+                spy_name="spy_101",
+                payload=SpyCall(args=(1, 2, 3), kwargs={}),
+            ),
         ],
         calls=[],
         times=0,
@@ -143,7 +307,7 @@ class VerifySpec(NamedTuple):
 @pytest.mark.parametrize(VerifySpec._fields, verify_raise_specs)
 def test_verify_raises(
     rehearsals: List[VerifyRehearsal],
-    calls: List[SpyCall],
+    calls: List[SpyEvent],
     times: Optional[int],
 ) -> None:
     """It should raise an error if calls do not match rehearsals."""
@@ -160,7 +324,7 @@ def test_verify_raises(
 @pytest.mark.parametrize(VerifySpec._fields, verify_pass_specs)
 def test_verify_passes(
     rehearsals: List[VerifyRehearsal],
-    calls: List[SpyCall],
+    calls: List[SpyEvent],
     times: Optional[int],
 ) -> None:
     """It should no-op if the calls match the rehearsals."""
diff --git a/tests/test_warning_checker.py b/tests/test_warning_checker.py
index 785d39d..0ae2c3a 100644
--- a/tests/test_warning_checker.py
+++ b/tests/test_warning_checker.py
@@ -3,7 +3,13 @@
 from typing import List, NamedTuple, Sequence
 
 from decoy import matchers
-from decoy.spy_calls import BaseSpyCall, SpyCall, WhenRehearsal, VerifyRehearsal
+from decoy.spy_events import (
+    AnySpyEvent,
+    SpyCall,
+    SpyEvent,
+    WhenRehearsal,
+    VerifyRehearsal,
+)
 from decoy.warnings import DecoyWarning, MiscalledStubWarning, RedundantVerifyWarning
 from decoy.warning_checker import WarningChecker
 
@@ -11,7 +17,7 @@
 class WarningCheckerSpec(NamedTuple):
     """Spec data for MiscalledStubWarning tests."""
 
-    all_calls: Sequence[BaseSpyCall]
+    all_calls: Sequence[AnySpyEvent]
     expected_warnings: Sequence[DecoyWarning]
 
 
@@ -24,56 +30,78 @@ class WarningCheckerSpec(NamedTuple):
     # it should not warn if rehearsals and calls match
     WarningCheckerSpec(
         all_calls=[
-            WhenRehearsal(spy_id=1, spy_name="spy", args=(1,), kwargs={}),
-            SpyCall(spy_id=1, spy_name="spy", args=(1,), kwargs={}),
+            WhenRehearsal(
+                spy_id=1, spy_name="spy", payload=SpyCall(args=(1,), kwargs={})
+            ),
+            SpyEvent(spy_id=1, spy_name="spy", payload=SpyCall(args=(1,), kwargs={})),
         ],
         expected_warnings=[],
     ),
     # it should not warn if a call is made and there are no rehearsals
     WarningCheckerSpec(
         all_calls=[
-            SpyCall(spy_id=1, spy_name="spy", args=(1,), kwargs={}),
+            SpyEvent(spy_id=1, spy_name="spy", payload=SpyCall(args=(1,), kwargs={})),
         ],
         expected_warnings=[],
     ),
     # it should warn if a spy has a rehearsal and a call that doesn't match
     WarningCheckerSpec(
         all_calls=[
-            WhenRehearsal(spy_id=1, spy_name="spy", args=(), kwargs={}),
-            SpyCall(spy_id=1, spy_name="spy", args=(1,), kwargs={}),
+            WhenRehearsal(
+                spy_id=1, spy_name="spy", payload=SpyCall(args=(), kwargs={})
+            ),
+            SpyEvent(spy_id=1, spy_name="spy", payload=SpyCall(args=(1,), kwargs={})),
         ],
         expected_warnings=[
             MiscalledStubWarning(
                 rehearsals=[
-                    WhenRehearsal(spy_id=1, spy_name="spy", args=(), kwargs={})
+                    WhenRehearsal(
+                        spy_id=1, spy_name="spy", payload=SpyCall(args=(), kwargs={})
+                    )
+                ],
+                calls=[
+                    SpyEvent(
+                        spy_id=1, spy_name="spy", payload=SpyCall(args=(1,), kwargs={})
+                    )
                 ],
-                calls=[SpyCall(spy_id=1, spy_name="spy", args=(1,), kwargs={})],
             )
         ],
     ),
     # it should not warn if a spy's verify rehearsal doesn't match
     WarningCheckerSpec(
         all_calls=[
-            VerifyRehearsal(spy_id=1, spy_name="spy", args=(), kwargs={}),
-            SpyCall(spy_id=1, spy_name="spy", args=(1,), kwargs={}),
+            VerifyRehearsal(
+                spy_id=1, spy_name="spy", payload=SpyCall(args=(), kwargs={})
+            ),
+            SpyEvent(spy_id=1, spy_name="spy", payload=SpyCall(args=(1,), kwargs={})),
         ],
         expected_warnings=[],
     ),
     # it should warn if a spy has multiple rehearsals without a matching call
     WarningCheckerSpec(
         all_calls=[
-            WhenRehearsal(spy_id=1, spy_name="spy", args=(), kwargs={}),
-            WhenRehearsal(spy_id=1, spy_name="spy", args=(0,), kwargs={}),
-            SpyCall(spy_id=1, spy_name="spy", args=(1,), kwargs={}),
+            WhenRehearsal(
+                spy_id=1, spy_name="spy", payload=SpyCall(args=(), kwargs={})
+            ),
+            WhenRehearsal(
+                spy_id=1, spy_name="spy", payload=SpyCall(args=(0,), kwargs={})
+            ),
+            SpyEvent(spy_id=1, spy_name="spy", payload=SpyCall(args=(1,), kwargs={})),
         ],
         expected_warnings=[
             MiscalledStubWarning(
                 rehearsals=[
-                    WhenRehearsal(spy_id=1, spy_name="spy", args=(), kwargs={}),
-                    WhenRehearsal(spy_id=1, spy_name="spy", args=(0,), kwargs={}),
+                    WhenRehearsal(
+                        spy_id=1, spy_name="spy", payload=SpyCall(args=(), kwargs={})
+                    ),
+                    WhenRehearsal(
+                        spy_id=1, spy_name="spy", payload=SpyCall(args=(0,), kwargs={})
+                    ),
                 ],
                 calls=[
-                    SpyCall(spy_id=1, spy_name="spy", args=(1,), kwargs={}),
+                    SpyEvent(
+                        spy_id=1, spy_name="spy", payload=SpyCall(args=(1,), kwargs={})
+                    ),
                 ],
             )
         ],
@@ -81,17 +109,23 @@ class WarningCheckerSpec(NamedTuple):
     # it should ignore spies that don't need warnings
     WarningCheckerSpec(
         all_calls=[
-            WhenRehearsal(spy_id=1, spy_name="spy", args=(), kwargs={}),
-            SpyCall(spy_id=1, spy_name="spy", args=(1,), kwargs={}),
-            SpyCall(spy_id=2, spy_name="yps", args=(2,), kwargs={}),
+            WhenRehearsal(
+                spy_id=1, spy_name="spy", payload=SpyCall(args=(), kwargs={})
+            ),
+            SpyEvent(spy_id=1, spy_name="spy", payload=SpyCall(args=(1,), kwargs={})),
+            SpyEvent(spy_id=2, spy_name="yps", payload=SpyCall(args=(2,), kwargs={})),
         ],
         expected_warnings=[
             MiscalledStubWarning(
                 rehearsals=[
-                    WhenRehearsal(spy_id=1, spy_name="spy", args=(), kwargs={}),
+                    WhenRehearsal(
+                        spy_id=1, spy_name="spy", payload=SpyCall(args=(), kwargs={})
+                    ),
                 ],
                 calls=[
-                    SpyCall(spy_id=1, spy_name="spy", args=(1,), kwargs={}),
+                    SpyEvent(
+                        spy_id=1, spy_name="spy", payload=SpyCall(args=(1,), kwargs={})
+                    ),
                 ],
             )
         ],
@@ -99,17 +133,25 @@ class WarningCheckerSpec(NamedTuple):
     # it should ignore rehearsals that come after a given call
     WarningCheckerSpec(
         all_calls=[
-            WhenRehearsal(spy_id=1, spy_name="spy", args=(), kwargs={}),
-            SpyCall(spy_id=1, spy_name="spy", args=(1,), kwargs={}),
-            WhenRehearsal(spy_id=1, spy_name="spy", args=(1,), kwargs={}),
+            WhenRehearsal(
+                spy_id=1, spy_name="spy", payload=SpyCall(args=(), kwargs={})
+            ),
+            SpyEvent(spy_id=1, spy_name="spy", payload=SpyCall(args=(1,), kwargs={})),
+            WhenRehearsal(
+                spy_id=1, spy_name="spy", payload=SpyCall(args=(1,), kwargs={})
+            ),
         ],
         expected_warnings=[
             MiscalledStubWarning(
                 rehearsals=[
-                    WhenRehearsal(spy_id=1, spy_name="spy", args=(), kwargs={}),
+                    WhenRehearsal(
+                        spy_id=1, spy_name="spy", payload=SpyCall(args=(), kwargs={})
+                    ),
                 ],
                 calls=[
-                    SpyCall(spy_id=1, spy_name="spy", args=(1,), kwargs={}),
+                    SpyEvent(
+                        spy_id=1, spy_name="spy", payload=SpyCall(args=(1,), kwargs={})
+                    ),
                 ],
             )
         ],
@@ -117,26 +159,38 @@ class WarningCheckerSpec(NamedTuple):
     # it should issue multiple warnings for multiple spies
     WarningCheckerSpec(
         all_calls=[
-            WhenRehearsal(spy_id=1, spy_name="spy", args=(), kwargs={}),
-            WhenRehearsal(spy_id=2, spy_name="yps", args=(1,), kwargs={}),
-            SpyCall(spy_id=1, spy_name="spy", args=(1,), kwargs={}),
-            SpyCall(spy_id=2, spy_name="yps", args=(), kwargs={}),
+            WhenRehearsal(
+                spy_id=1, spy_name="spy", payload=SpyCall(args=(), kwargs={})
+            ),
+            WhenRehearsal(
+                spy_id=2, spy_name="yps", payload=SpyCall(args=(1,), kwargs={})
+            ),
+            SpyEvent(spy_id=1, spy_name="spy", payload=SpyCall(args=(1,), kwargs={})),
+            SpyEvent(spy_id=2, spy_name="yps", payload=SpyCall(args=(), kwargs={})),
         ],
         expected_warnings=[
             MiscalledStubWarning(
                 rehearsals=[
-                    WhenRehearsal(spy_id=1, spy_name="spy", args=(), kwargs={}),
+                    WhenRehearsal(
+                        spy_id=1, spy_name="spy", payload=SpyCall(args=(), kwargs={})
+                    ),
                 ],
                 calls=[
-                    SpyCall(spy_id=1, spy_name="spy", args=(1,), kwargs={}),
+                    SpyEvent(
+                        spy_id=1, spy_name="spy", payload=SpyCall(args=(1,), kwargs={})
+                    ),
                 ],
             ),
             MiscalledStubWarning(
                 rehearsals=[
-                    WhenRehearsal(spy_id=2, spy_name="yps", args=(1,), kwargs={}),
+                    WhenRehearsal(
+                        spy_id=2, spy_name="yps", payload=SpyCall(args=(1,), kwargs={})
+                    ),
                 ],
                 calls=[
-                    SpyCall(spy_id=2, spy_name="yps", args=(), kwargs={}),
+                    SpyEvent(
+                        spy_id=2, spy_name="yps", payload=SpyCall(args=(), kwargs={})
+                    ),
                 ],
             ),
         ],
@@ -145,29 +199,45 @@ class WarningCheckerSpec(NamedTuple):
     # if the rehearsal list changes
     WarningCheckerSpec(
         all_calls=[
-            WhenRehearsal(spy_id=1, spy_name="spy", args=(), kwargs={}),
-            SpyCall(spy_id=1, spy_name="spy", args=(1,), kwargs={}),
-            SpyCall(spy_id=1, spy_name="spy", args=(2,), kwargs={}),
-            WhenRehearsal(spy_id=1, spy_name="spy", args=(1,), kwargs={}),
-            SpyCall(spy_id=1, spy_name="spy", args=(3,), kwargs={}),
+            WhenRehearsal(
+                spy_id=1, spy_name="spy", payload=SpyCall(args=(), kwargs={})
+            ),
+            SpyEvent(spy_id=1, spy_name="spy", payload=SpyCall(args=(1,), kwargs={})),
+            SpyEvent(spy_id=1, spy_name="spy", payload=SpyCall(args=(2,), kwargs={})),
+            WhenRehearsal(
+                spy_id=1, spy_name="spy", payload=SpyCall(args=(1,), kwargs={})
+            ),
+            SpyEvent(spy_id=1, spy_name="spy", payload=SpyCall(args=(3,), kwargs={})),
         ],
         expected_warnings=[
             MiscalledStubWarning(
                 rehearsals=[
-                    WhenRehearsal(spy_id=1, spy_name="spy", args=(), kwargs={}),
+                    WhenRehearsal(
+                        spy_id=1, spy_name="spy", payload=SpyCall(args=(), kwargs={})
+                    ),
                 ],
                 calls=[
-                    SpyCall(spy_id=1, spy_name="spy", args=(1,), kwargs={}),
-                    SpyCall(spy_id=1, spy_name="spy", args=(2,), kwargs={}),
+                    SpyEvent(
+                        spy_id=1, spy_name="spy", payload=SpyCall(args=(1,), kwargs={})
+                    ),
+                    SpyEvent(
+                        spy_id=1, spy_name="spy", payload=SpyCall(args=(2,), kwargs={})
+                    ),
                 ],
             ),
             MiscalledStubWarning(
                 rehearsals=[
-                    WhenRehearsal(spy_id=1, spy_name="spy", args=(), kwargs={}),
-                    WhenRehearsal(spy_id=1, spy_name="spy", args=(1,), kwargs={}),
+                    WhenRehearsal(
+                        spy_id=1, spy_name="spy", payload=SpyCall(args=(), kwargs={})
+                    ),
+                    WhenRehearsal(
+                        spy_id=1, spy_name="spy", payload=SpyCall(args=(1,), kwargs={})
+                    ),
                 ],
                 calls=[
-                    SpyCall(spy_id=1, spy_name="spy", args=(3,), kwargs={}),
+                    SpyEvent(
+                        spy_id=1, spy_name="spy", payload=SpyCall(args=(3,), kwargs={})
+                    ),
                 ],
             ),
         ],
@@ -175,27 +245,39 @@ class WarningCheckerSpec(NamedTuple):
     # it should not warn if a call misses a stubbing but is later verified
     WarningCheckerSpec(
         all_calls=[
-            WhenRehearsal(spy_id=1, spy_name="spy", args=(1,), kwargs={}),
-            SpyCall(spy_id=1, spy_name="spy", args=(2,), kwargs={}),
-            VerifyRehearsal(spy_id=1, spy_name="spy", args=(2,), kwargs={}),
+            WhenRehearsal(
+                spy_id=1, spy_name="spy", payload=SpyCall(args=(1,), kwargs={})
+            ),
+            SpyEvent(spy_id=1, spy_name="spy", payload=SpyCall(args=(2,), kwargs={})),
+            VerifyRehearsal(
+                spy_id=1, spy_name="spy", payload=SpyCall(args=(2,), kwargs={})
+            ),
         ],
         expected_warnings=[],
     ),
     # it should warn if a call misses a stubbing after it is verified
     WarningCheckerSpec(
         all_calls=[
-            SpyCall(spy_id=1, spy_name="spy", args=(2,), kwargs={}),
-            VerifyRehearsal(spy_id=1, spy_name="spy", args=(2,), kwargs={}),
-            WhenRehearsal(spy_id=1, spy_name="spy", args=(1,), kwargs={}),
-            SpyCall(spy_id=1, spy_name="spy", args=(2,), kwargs={}),
+            SpyEvent(spy_id=1, spy_name="spy", payload=SpyCall(args=(2,), kwargs={})),
+            VerifyRehearsal(
+                spy_id=1, spy_name="spy", payload=SpyCall(args=(2,), kwargs={})
+            ),
+            WhenRehearsal(
+                spy_id=1, spy_name="spy", payload=SpyCall(args=(1,), kwargs={})
+            ),
+            SpyEvent(spy_id=1, spy_name="spy", payload=SpyCall(args=(2,), kwargs={})),
         ],
         expected_warnings=[
             MiscalledStubWarning(
                 rehearsals=[
-                    WhenRehearsal(spy_id=1, spy_name="spy", args=(1,), kwargs={}),
+                    WhenRehearsal(
+                        spy_id=1, spy_name="spy", payload=SpyCall(args=(1,), kwargs={})
+                    ),
                 ],
                 calls=[
-                    SpyCall(spy_id=1, spy_name="spy", args=(2,), kwargs={}),
+                    SpyEvent(
+                        spy_id=1, spy_name="spy", payload=SpyCall(args=(2,), kwargs={})
+                    ),
                 ],
             ),
         ],
@@ -203,16 +285,22 @@ class WarningCheckerSpec(NamedTuple):
     # it should issue a redundant verify warning if a call has a when and a verify
     WarningCheckerSpec(
         all_calls=[
-            WhenRehearsal(spy_id=1, spy_name="spy", args=(1,), kwargs={}),
-            VerifyRehearsal(spy_id=1, spy_name="spy", args=(1,), kwargs={}),
+            WhenRehearsal(
+                spy_id=1, spy_name="spy", payload=SpyCall(args=(1,), kwargs={})
+            ),
+            VerifyRehearsal(
+                spy_id=1, spy_name="spy", payload=SpyCall(args=(1,), kwargs={})
+            ),
         ],
         expected_warnings=[
             RedundantVerifyWarning(
                 rehearsal=VerifyRehearsal(
                     spy_id=1,
                     spy_name="spy",
-                    args=(1,),
-                    kwargs={},
+                    payload=SpyCall(
+                        args=(1,),
+                        kwargs={},
+                    ),
                 ),
             ),
         ],
@@ -220,8 +308,12 @@ class WarningCheckerSpec(NamedTuple):
     # it should not warn if the verify and when rehearsals are different
     WarningCheckerSpec(
         all_calls=[
-            WhenRehearsal(spy_id=1, spy_name="spy", args=(1,), kwargs={}),
-            VerifyRehearsal(spy_id=1, spy_name="spy", args=(2,), kwargs={}),
+            WhenRehearsal(
+                spy_id=1, spy_name="spy", payload=SpyCall(args=(1,), kwargs={})
+            ),
+            VerifyRehearsal(
+                spy_id=1, spy_name="spy", payload=SpyCall(args=(2,), kwargs={})
+            ),
         ],
         expected_warnings=[],
     ),
@@ -230,7 +322,7 @@ class WarningCheckerSpec(NamedTuple):
 
 @pytest.mark.parametrize(WarningCheckerSpec._fields, warning_checker_specs)
 def test_verify_no_misscalled_stubs(
-    all_calls: List[SpyCall],
+    all_calls: List[SpyEvent],
     expected_warnings: List[MiscalledStubWarning],
     recwarn: pytest.WarningsRecorder,
 ) -> None:
diff --git a/tests/test_warnings.py b/tests/test_warnings.py
index 3c722bd..593df2b 100644
--- a/tests/test_warnings.py
+++ b/tests/test_warnings.py
@@ -3,7 +3,7 @@
 import os
 from typing import NamedTuple
 
-from decoy.spy_calls import SpyCall, WhenRehearsal, VerifyRehearsal
+from decoy.spy_events import SpyCall, SpyEvent, WhenRehearsal, VerifyRehearsal
 from decoy.warnings import DecoyWarning, MiscalledStubWarning, RedundantVerifyWarning
 
 
@@ -18,10 +18,14 @@ class WarningSpec(NamedTuple):
     WarningSpec(
         warning=MiscalledStubWarning(
             rehearsals=[
-                WhenRehearsal(spy_id=1, spy_name="spy", args=(), kwargs={}),
+                WhenRehearsal(
+                    spy_id=1, spy_name="spy", payload=SpyCall(args=(), kwargs={})
+                ),
             ],
             calls=[
-                SpyCall(spy_id=1, spy_name="spy", args=(1,), kwargs={}),
+                SpyEvent(
+                    spy_id=1, spy_name="spy", payload=SpyCall(args=(1,), kwargs={})
+                ),
             ],
         ),
         expected_message=os.linesep.join(
@@ -37,11 +41,17 @@ class WarningSpec(NamedTuple):
     WarningSpec(
         warning=MiscalledStubWarning(
             rehearsals=[
-                WhenRehearsal(spy_id=1, spy_name="spy", args=(), kwargs={}),
-                WhenRehearsal(spy_id=1, spy_name="spy", args=(0,), kwargs={}),
+                WhenRehearsal(
+                    spy_id=1, spy_name="spy", payload=SpyCall(args=(), kwargs={})
+                ),
+                WhenRehearsal(
+                    spy_id=1, spy_name="spy", payload=SpyCall(args=(0,), kwargs={})
+                ),
             ],
             calls=[
-                SpyCall(spy_id=1, spy_name="spy", args=(1,), kwargs={}),
+                SpyEvent(
+                    spy_id=1, spy_name="spy", payload=SpyCall(args=(1,), kwargs={})
+                ),
             ],
         ),
         expected_message=os.linesep.join(
@@ -58,11 +68,17 @@ class WarningSpec(NamedTuple):
     WarningSpec(
         warning=MiscalledStubWarning(
             rehearsals=[
-                WhenRehearsal(spy_id=1, spy_name="spy", args=(), kwargs={}),
+                WhenRehearsal(
+                    spy_id=1, spy_name="spy", payload=SpyCall(args=(), kwargs={})
+                ),
             ],
             calls=[
-                SpyCall(spy_id=1, spy_name="spy", args=(1,), kwargs={}),
-                SpyCall(spy_id=1, spy_name="spy", args=(2,), kwargs={}),
+                SpyEvent(
+                    spy_id=1, spy_name="spy", payload=SpyCall(args=(1,), kwargs={})
+                ),
+                SpyEvent(
+                    spy_id=1, spy_name="spy", payload=SpyCall(args=(2,), kwargs={})
+                ),
             ],
         ),
         expected_message=os.linesep.join(
@@ -78,7 +94,9 @@ class WarningSpec(NamedTuple):
     ),
     WarningSpec(
         warning=RedundantVerifyWarning(
-            rehearsal=VerifyRehearsal(spy_id=1, spy_name="spy", args=(1,), kwargs={}),
+            rehearsal=VerifyRehearsal(
+                spy_id=1, spy_name="spy", payload=SpyCall(args=(1,), kwargs={})
+            ),
         ),
         expected_message=os.linesep.join(
             [