Skip to content

Commit

Permalink
feat(when): warn if stub is called with args that do not match (#15)
Browse files Browse the repository at this point in the history
Closes #14
  • Loading branch information
mcous authored May 4, 2021
1 parent ae2827e commit 5fd4e6d
Show file tree
Hide file tree
Showing 7 changed files with 149 additions and 6 deletions.
16 changes: 15 additions & 1 deletion decoy/__init__.py
Original file line number Diff line number Diff line change
@@ -1,23 +1,33 @@
"""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

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 +39,7 @@ def decoy() -> Decoy:
```
"""
self._registry = Registry()
self._warn_on_missing_stubs = warn_on_missing_stubs

def create_decoy(self, spec: Type[ClassT], *, is_async: bool = False) -> ClassT:
"""Create a class decoy for `spec`.
Expand Down Expand Up @@ -178,6 +189,9 @@ def _handle_spy_call(self, call: SpyCall) -> Any:
if stub._rehearsal == call:
return stub._act()

if 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')"
)
22 changes: 21 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,23 @@ 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")

0 comments on commit 5fd4e6d

Please sign in to comment.