Skip to content

Commit 72cefe4

Browse files
authored
feat(spy): warn if mock used with a missing attribute (#218)
In the next major version of Decoy, this warning will become an error. Closes #204
1 parent d8c6923 commit 72cefe4

File tree

4 files changed

+74
-3
lines changed

4 files changed

+74
-3
lines changed

decoy/spy_core.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from typing import Any, Dict, NamedTuple, Optional, Tuple, Type, Union, get_type_hints
66

77
from .spy_events import SpyInfo
8-
from .warnings import IncorrectCallWarning
8+
from .warnings import IncorrectCallWarning, MissingSpecAttributeWarning
99

1010

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

139+
if child_source is None and source is not None:
140+
# stacklevel: 4 ensures warning is linked to call location
141+
warnings.warn(
142+
MissingSpecAttributeWarning(f"{self._name} has no attribute '{name}'"),
143+
stacklevel=4,
144+
)
145+
139146
return SpyCore(
140147
source=child_source,
141148
name=child_name,

decoy/warnings.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,3 +95,13 @@ class IncorrectCallWarning(DecoyWarning):
9595
9696
[IncorrectCallWarning guide]: usage/errors-and-warnings.md#incorrectcallwarning
9797
"""
98+
99+
100+
class MissingSpecAttributeWarning(DecoyWarning):
101+
"""A warning raised if a Decoy mock with a spec is used with a missing attribute.
102+
103+
This will become an error in the next major version of Decoy.
104+
See the [MissingSpecAttributeWarning guide][] for more details.
105+
106+
[MissingSpecAttributeWarning guide]: usage/errors-and-warnings.md#missingspecattributewarning
107+
"""

docs/usage/errors-and-warnings.md

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -187,7 +187,7 @@ If Decoy detects a `verify` with the same configuration of a `when`, it will rai
187187

188188
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][].
189189

190-
While Decoy will merely issue a warning, this call would likely cause the Python engine to error at runtime and should not be ignored.
190+
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.
191191

192192
```python
193193
def some_func(val: string) -> int:
@@ -200,3 +200,27 @@ spy(val="world") # ok
200200
spy(wrong_name="ah!") # triggers an IncorrectCallWarning
201201
spy("too", "many", "args") # triggers an IncorrectCallWarning
202202
```
203+
204+
### MissingSpecAttributeWarning
205+
206+
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][].
207+
208+
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.
209+
210+
```python
211+
class SomeClass:
212+
def foo(self, val: str) -> str:
213+
...
214+
215+
def some_func(val: string) -> int:
216+
...
217+
218+
class_spy = decoy.mock(cls=SomeClass)
219+
func_spy = decoy.mock(func=some_func)
220+
221+
class_spy.foo("hello") # ok
222+
class_spy.bar("world") # triggers a MissingSpecAttributeWarning
223+
224+
func_spy("hello") # ok
225+
func_spy.foo("world") # triggers a MissingSpecAttributeWarning
226+
```

tests/test_spy_core.py

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
"""Tests for SpyCore instances."""
22
import pytest
33
import inspect
4+
import warnings
45
from typing import Any, Dict, NamedTuple, Optional, Tuple, Type
56

67
from decoy.spy_core import SpyCore, BoundArgs
7-
from decoy.warnings import IncorrectCallWarning
8+
from decoy.warnings import IncorrectCallWarning, MissingSpecAttributeWarning
89
from .fixtures import (
910
SomeClass,
1011
SomeAsyncClass,
@@ -439,3 +440,32 @@ def test_warn_if_called_incorrectly() -> None:
439440

440441
with pytest.warns(IncorrectCallWarning, match="missing a required argument"):
441442
subject.bind_args(wrong_arg_name="1")
443+
444+
445+
def test_warn_if_spec_does_not_have_method() -> None:
446+
"""It should trigger a warning if bound_args is called incorrectly."""
447+
class_subject = SpyCore(source=SomeClass, name=None)
448+
func_subject = SpyCore(source=some_func, name=None)
449+
specless_subject = SpyCore(source=None, name="anonymous")
450+
451+
# specless mocks and correct usage should not warn
452+
with warnings.catch_warnings():
453+
warnings.simplefilter("error")
454+
specless_subject.create_child_core("foo", False)
455+
456+
# proper class usage should not warn
457+
with warnings.catch_warnings():
458+
warnings.simplefilter("error")
459+
class_subject.create_child_core("foo", False)
460+
461+
# incorrect class usage should warn
462+
with pytest.warns(
463+
MissingSpecAttributeWarning, match="has no attribute 'this_is_wrong'"
464+
):
465+
class_subject.create_child_core("this_is_wrong", False)
466+
467+
# incorrect function usage should warn
468+
with pytest.warns(
469+
MissingSpecAttributeWarning, match="has no attribute 'this_is_wrong'"
470+
):
471+
func_subject.create_child_core("this_is_wrong", False)

0 commit comments

Comments
 (0)