Skip to content

Commit

Permalink
feat(when): warn if stub is called with arguments that do not match r…
Browse files Browse the repository at this point in the history
…ehearsals (#16)

This commit corrects the bug that was present in the previously reverted
implementation of this feature.

Closes #14
  • Loading branch information
mcous authored May 4, 2021
1 parent 1dab3f9 commit f0190e7
Show file tree
Hide file tree
Showing 7 changed files with 179 additions and 10 deletions.
41 changes: 36 additions & 5 deletions decoy/__init__.py
Original file line number Diff line number Diff line change
@@ -1,23 +1,34 @@
"""Decoy test double stubbing and verification library."""
from os import linesep
from typing import cast, Any, Optional, Sequence, Type
from warnings import warn

from .registry import Registry
from .spy import create_spy, SpyCall
from .stub import Stub
from .types import ClassT, FuncT, ReturnT
from .warnings import MissingStubWarning


class Decoy:
"""Decoy test double state container."""

_registry: Registry
_warn_on_missing_stubs: bool
_next_call_is_when_rehearsal: bool

def __init__(self) -> None:
def __init__(
self,
warn_on_missing_stubs: bool = True,
) -> None:
"""Initialize the state container for test doubles and stubs.
You should initialize a new Decoy instance for every test.
Arguments:
warn_on_missing_stubs: Trigger a warning if a stub is called
with arguments that do not match any of its rehearsals.
Example:
```python
import pytest
Expand All @@ -29,6 +40,21 @@ def decoy() -> Decoy:
```
"""
self._registry = Registry()
self._warn_on_missing_stubs = warn_on_missing_stubs
self._next_call_is_when_rehearsal = False

def __getattribute__(self, name: str) -> Any:
"""Proxy to catch calls to `when` and mark the subsequent spy call as a rehearsal.
This is to ensure that rehearsal calls don't accidentally trigger a
`MissingStubWarning`.
"""
actual_method = super().__getattribute__(name)

if name == "when":
self._next_call_is_when_rehearsal = True

return actual_method

def create_decoy(self, spec: Type[ClassT], *, is_async: bool = False) -> ClassT:
"""Create a class decoy for `spec`.
Expand Down Expand Up @@ -103,10 +129,10 @@ def when(self, _rehearsal_result: ReturnT) -> Stub[ReturnT]:
```
Note:
The "rehearsal" is an actual call to the test fake. The fact that
the call is written inside `when` is purely for typechecking and
API sugar. Decoy will pop the last call to _any_ fake off its
call stack, which will end up being the call inside `when`.
The "rehearsal" is an actual call to the test fake. Because the
call is written inside `when`, Decoy is able to infer that the call
is a rehearsal for stub configuration purposes rather than a call
from the code-under-test.
"""
rehearsal = self._pop_last_rehearsal()
stub = Stub[ReturnT](rehearsal=rehearsal)
Expand Down Expand Up @@ -173,11 +199,16 @@ def _handle_spy_call(self, call: SpyCall) -> Any:
self._registry.register_call(call)

stubs = self._registry.get_stubs_by_spy_id(call.spy_id)
is_when_rehearsal = self._next_call_is_when_rehearsal
self._next_call_is_when_rehearsal = False

for stub in reversed(stubs):
if stub._rehearsal == call:
return stub._act()

if not is_when_rehearsal and self._warn_on_missing_stubs and len(stubs) > 0:
warn(MissingStubWarning(call, stubs))

return None

def _build_verify_error(
Expand Down
6 changes: 3 additions & 3 deletions decoy/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ def get_stubs_by_spy_id(self, spy_id: int) -> List[Stub[Any]]:
"""Get a spy's stub list by identifier.
Arguments:
spy_id: The unique identifer of the Spy to look up.
spy_id: The unique identifier of the Spy to look up.
Returns:
The list of stubs matching the given Spy.
Expand All @@ -51,7 +51,7 @@ def get_calls_by_spy_id(self, *spy_id: int) -> List[SpyCall]:
"""Get a spy's call list by identifier.
Arguments:
spy_id: The unique identifer of the Spy to look up.
spy_id: The unique identifier of the Spy to look up.
Returns:
The list of calls matching the given Spy.
Expand Down Expand Up @@ -83,7 +83,7 @@ def register_stub(self, spy_id: int, stub: Stub[Any]) -> None:
"""Register a stub for tracking.
Arguments:
spy_id: The unique identifer of the Spy to look up.
spy_id: The unique identifier of the Spy to look up.
stub: The stub to track.
"""
stub_list = self.get_stubs_by_spy_id(spy_id)
Expand Down
30 changes: 30 additions & 0 deletions decoy/warnings.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
"""Warnings produced by Decoy."""
from os import linesep
from typing import Any, Sequence

from .spy import SpyCall
from .stub import Stub


class MissingStubWarning(UserWarning):
"""A warning raised when a configured stub is called with different arguments."""

def __init__(self, call: SpyCall, stubs: Sequence[Stub[Any]]) -> None:
"""Initialize the warning message with the actual and expected calls."""
stubs_len = len(stubs)
stubs_plural = stubs_len != 1
stubs_printout = linesep.join(
[f"{n + 1}.\t{str(stubs[n]._rehearsal)}" for n in range(stubs_len)]
)

message = linesep.join(
[
"Stub was called but no matching rehearsal found.",
f"Found {stubs_len} rehearsal{'s' if stubs_plural else ''}:",
stubs_printout,
"Actual call:",
f"\t{str(call)}",
]
)

super().__init__(message)
2 changes: 2 additions & 0 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,5 @@
::: decoy.stub.Stub

::: decoy.matchers

::: decoy.warnings
15 changes: 14 additions & 1 deletion tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,18 @@

@pytest.fixture
def decoy() -> Decoy:
"""Get a new instance of the Decoy state container."""
"""Get a new instance of the Decoy state container.
Warnings are disabled for more quiet tests.
"""
return Decoy(warn_on_missing_stubs=False)


@pytest.fixture
def strict_decoy() -> Decoy:
"""Get a new instance of the Decoy state container.
Warnings are left in the default enabled state. Use this fixture
to test warning behavior.
"""
return Decoy()
64 changes: 64 additions & 0 deletions tests/test_warnings.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
"""Tests for warning messages."""
from os import linesep
from typing import Any, List

from decoy.spy import SpyCall
from decoy.stub import Stub
from decoy.warnings import MissingStubWarning


def test_no_stubbing_found_warning() -> None:
"""It should print a helpful error message if a call misses a stub."""
call = SpyCall(spy_id=123, spy_name="spy", args=(1, 2), kwargs={"foo": "bar"})
stub: Stub[Any] = Stub(
rehearsal=SpyCall(
spy_id=123,
spy_name="spy",
args=(3, 4),
kwargs={"baz": "qux"},
)
)

result = MissingStubWarning(call=call, stubs=[stub])

assert str(result) == (
f"Stub was called but no matching rehearsal found.{linesep}"
f"Found 1 rehearsal:{linesep}"
f"1.\tspy(3, 4, baz='qux'){linesep}"
f"Actual call:{linesep}"
"\tspy(1, 2, foo='bar')"
)


def test_no_stubbing_found_warning_plural() -> None:
"""It should print a helpful message if a call misses multiple stubs."""
call = SpyCall(spy_id=123, spy_name="spy", args=(1, 2), kwargs={"foo": "bar"})
stubs: List[Stub[Any]] = [
Stub(
rehearsal=SpyCall(
spy_id=123,
spy_name="spy",
args=(3, 4),
kwargs={"baz": "qux"},
)
),
Stub(
rehearsal=SpyCall(
spy_id=123,
spy_name="spy",
args=(5, 6),
kwargs={"fizz": "buzz"},
)
),
]

result = MissingStubWarning(call=call, stubs=stubs)

assert str(result) == (
f"Stub was called but no matching rehearsal found.{linesep}"
f"Found 2 rehearsals:{linesep}"
f"1.\tspy(3, 4, baz='qux'){linesep}"
f"2.\tspy(5, 6, fizz='buzz'){linesep}"
f"Actual call:{linesep}"
"\tspy(1, 2, foo='bar')"
)
31 changes: 30 additions & 1 deletion tests/test_when.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"""Tests for the Decoy double creator."""
import pytest

from decoy import Decoy, matchers
from decoy import Decoy, matchers, warnings
from .common import some_func, SomeClass, SomeAsyncClass, SomeNestedClass


Expand Down Expand Up @@ -159,3 +159,32 @@ def _async_child(self) -> SomeAsyncClass:
decoy.when(await stub._async_child.foo("hello")).then_return("world")

assert await stub._async_child.foo("hello") == "world"


def test_no_stubbing_found_warning(strict_decoy: Decoy) -> None:
"""It should raise a warning if a stub is configured and then called incorrectly."""
stub = strict_decoy.create_decoy_func(spec=some_func)

strict_decoy.when(stub("hello")).then_return("world")

with pytest.warns(warnings.MissingStubWarning):
stub("h3110")


@pytest.mark.filterwarnings("error::UserWarning")
def test_no_stubbing_found_warnings_disabled(decoy: Decoy) -> None:
"""It should not raise a warning if warn_on_missing_stub is disabled."""
stub = decoy.create_decoy_func(spec=some_func)

decoy.when(stub("hello")).then_return("world")

stub("h3110")


@pytest.mark.filterwarnings("error::UserWarning")
def test_additional_stubbings_do_not_warn(strict_decoy: Decoy) -> None:
"""It should not raise a warning if warn_on_missing_stub is disabled."""
stub = strict_decoy.create_decoy_func(spec=some_func)

strict_decoy.when(stub("hello")).then_return("world")
strict_decoy.when(stub("goodbye")).then_return("so long")

0 comments on commit f0190e7

Please sign in to comment.