Skip to content

Commit

Permalink
feat(spy): warn if mock used with a missing attribute
Browse files Browse the repository at this point in the history
  • Loading branch information
mcous committed Aug 13, 2023
1 parent 7b24491 commit 5be5a7f
Show file tree
Hide file tree
Showing 4 changed files with 73 additions and 6 deletions.
9 changes: 8 additions & 1 deletion decoy/spy_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from typing import Any, Dict, NamedTuple, Optional, Tuple, Type, Union, get_type_hints

from .spy_events import SpyInfo
from .warnings import IncorrectCallWarning
from .warnings import IncorrectCallWarning, MissingSpecAttributeWarning


class _FROM_SOURCE:
Expand Down Expand Up @@ -136,6 +136,13 @@ def create_child_core(self, name: str, is_async: bool) -> "SpyCore":
# signature reporting by wrapping it in a partial
child_source = functools.partial(child_source, None)

if child_source is None and source is not None:
# stacklevel: 4 ensures warning is linked to call location
warnings.warn(
MissingSpecAttributeWarning(f"{self._name} has no attribute '{name}'"),
stacklevel=4,
)

return SpyCore(
source=child_source,
name=child_name,
Expand Down
10 changes: 10 additions & 0 deletions decoy/warnings.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,3 +95,13 @@ class IncorrectCallWarning(DecoyWarning):
[IncorrectCallWarning guide]: usage/errors-and-warnings.md#incorrectcallwarning
"""


class MissingSpecAttributeWarning(DecoyWarning):
"""A warning raised if a Decoy mock with a spec is used with a missing attribute.
This will become an error in the next major version of Decoy.
See the [MissingSpecAttributeWarning guide][] for more details.
[MissingSpecAttributeWarning guide]: usage/errors-and-warnings.md#missingspecattributewarning
"""
28 changes: 24 additions & 4 deletions docs/usage/errors-and-warnings.md
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,7 @@ If Decoy detects a `verify` with the same configuration of a `when`, it will rai

If you provide a Decoy mock with a specification `cls` or `func`, any calls to that mock will be checked according to `inspect.signature`. If the call does not match the signature, Decoy will raise a [decoy.warnings.IncorrectCallWarning][].

While Decoy will merely issue a warning, this call would likely cause the Python engine to error at runtime and should not be ignored.
While Decoy will merely issue a warning, this call would likely cause the Python engine to error at runtime and should not be ignored. In the next major version of Decoy, this warning will become an error.

```python
def some_func(val: string) -> int:
Expand All @@ -201,6 +201,26 @@ spy(wrong_name="ah!") # triggers an IncorrectCallWarning
spy("too", "many", "args") # triggers an IncorrectCallWarning
```

[warnings system]: https://docs.python.org/3/library/warnings.html
[warning filters]: https://docs.pytest.org/en/latest/how-to/capture-warnings.html
[unittest.mock]: https://docs.python.org/3/library/unittest.mock.html
### MissingSpecAttributeWarning

If you provide a Decoy mock with a specification `cls` or `func` and you attempt to access an attribute of the mock that does not exist on the specification, Decoy will raise a [decoy.warnings.MissingSpecAttributeWarning][].

While Decoy will merely issue a warning, this call would likely cause the Python engine to error at runtime and should not be ignored. In the next major version of Decoy, this warning will become an error.

```python
class SomeClass:
def foo(self, val: str) -> str:
...

def some_func(val: string) -> int:
...

class_spy = decoy.mock(cls=SomeClass)
func_spy = decoy.mock(func=some_func)

class_spy.foo("hello") # ok
class_spy.bar("world") # triggers a MissingSpecAttributeWarning

func_spy("hello") # ok
func_spy.foo("world") # triggers a MissingSpecAttributeWarning
```
32 changes: 31 additions & 1 deletion tests/test_spy_core.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
"""Tests for SpyCore instances."""
import pytest
import inspect
import warnings
from typing import Any, Dict, NamedTuple, Optional, Tuple, Type

from decoy.spy_core import SpyCore, BoundArgs
from decoy.warnings import IncorrectCallWarning
from decoy.warnings import IncorrectCallWarning, MissingSpecAttributeWarning
from .fixtures import (
SomeClass,
SomeAsyncClass,
Expand Down Expand Up @@ -439,3 +440,32 @@ def test_warn_if_called_incorrectly() -> None:

with pytest.warns(IncorrectCallWarning, match="missing a required argument"):
subject.bind_args(wrong_arg_name="1")


def test_warn_if_spec_does_not_have_method() -> None:
"""It should trigger a warning if bound_args is called incorrectly."""
class_subject = SpyCore(source=SomeClass, name=None)
func_subject = SpyCore(source=some_func, name=None)
specless_subject = SpyCore(source=None, name="anonymous")

# specless mocks and correct usage should not warn
with warnings.catch_warnings():
warnings.simplefilter("error")
specless_subject.create_child_core("foo", False)

# proper class usage should not warn
with warnings.catch_warnings():
warnings.simplefilter("error")
class_subject.create_child_core("foo", False)

# incorrect class usage should warn
with pytest.warns(
MissingSpecAttributeWarning, match="has no attribute 'this_is_wrong'"
):
class_subject.create_child_core("this_is_wrong", False)

# incorrect function usage should warn
with pytest.warns(
MissingSpecAttributeWarning, match="has no attribute 'this_is_wrong'"
):
func_subject.create_child_core("this_is_wrong", False)

0 comments on commit 5be5a7f

Please sign in to comment.