From 72cefe487cab2478a0ab275ea44a6e7244a406fb Mon Sep 17 00:00:00 2001 From: Michael Cousins Date: Sun, 13 Aug 2023 17:13:53 -0400 Subject: [PATCH] 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 --- decoy/spy_core.py | 9 ++++++++- decoy/warnings.py | 10 ++++++++++ docs/usage/errors-and-warnings.md | 26 ++++++++++++++++++++++++- tests/test_spy_core.py | 32 ++++++++++++++++++++++++++++++- 4 files changed, 74 insertions(+), 3 deletions(-) diff --git a/decoy/spy_core.py b/decoy/spy_core.py index c683b9a..a2b8fd0 100644 --- a/decoy/spy_core.py +++ b/decoy/spy_core.py @@ -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: @@ -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, diff --git a/decoy/warnings.py b/decoy/warnings.py index f49f555..140301e 100644 --- a/decoy/warnings.py +++ b/decoy/warnings.py @@ -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 + """ diff --git a/docs/usage/errors-and-warnings.md b/docs/usage/errors-and-warnings.md index a55aa79..1f61071 100644 --- a/docs/usage/errors-and-warnings.md +++ b/docs/usage/errors-and-warnings.md @@ -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: @@ -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 +``` diff --git a/tests/test_spy_core.py b/tests/test_spy_core.py index 14ed30b..934386f 100644 --- a/tests/test_spy_core.py +++ b/tests/test_spy_core.py @@ -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, @@ -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)