Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(spy): warn if mock used with a missing attribute #218

Merged
merged 1 commit into from
Aug 13, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
"""
26 changes: 25 additions & 1 deletion 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 @@ -200,3 +200,27 @@ spy(val="world") # ok
spy(wrong_name="ah!") # triggers an IncorrectCallWarning
spy("too", "many", "args") # triggers an IncorrectCallWarning
```

### 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)