From 34e946ebd780c7bce8def89c0b8f2d060f4095d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Fern=C3=A1ndez?= <61871580+Alejandro-FA@users.noreply.github.com> Date: Sun, 6 Jul 2025 22:10:02 +0200 Subject: [PATCH 1/4] Improve static typing support for argument matchers --- CONTRIBUTING.md | 6 +- decoy/matchers.py | 195 ++++++++++++++++++++++++++--------- docs/usage/matchers.md | 19 +++- pyproject.toml | 2 +- tests/test_matchers.py | 73 +++++++++++-- tests/typing/test_typing.yml | 54 ++++++++-- 6 files changed, 275 insertions(+), 74 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 96f2656..a715559 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -41,8 +41,8 @@ poetry run poe coverage In an exciting twist, since version 1.6.0, Decoy's tests rely on Decoy itself to test (and more importantly, design) the relationships between Decoy's internal APIs. This means: -- Decoy's unit test suite serves as an end-to-end test of Decoy by virtue of existing (wow, very meta, actually kind of cool). -- Changes that break a small part of Decoy may result in a large number of test failures, because if Decoy breaks it can't be used to test itself. +- Decoy's unit test suite serves as an end-to-end test of Decoy by virtue of existing (wow, very meta, actually kind of cool). +- Changes that break a small part of Decoy may result in a large number of test failures, because if Decoy breaks it can't be used to test itself. If you find yourself in a situation where Decoy's test suite has blown up, **concentrate on getting the test suites that don't use Decoy to pass**. From there, lean on the type-checker to guide you to any components that aren't properly hooked up. Decoy also has a end-to-end smoke test suite (`tests/test_decoy.py`) that can be helpful in getting things back to green. @@ -61,7 +61,7 @@ poetry run poe format Decoy's documentation is built with [mkdocs][], which you can use to preview the documentation site locally. ```bash -poetry run docs +poetry run poe docs ``` ## Deploying diff --git a/decoy/matchers.py b/decoy/matchers.py index a31942c..71f6d82 100644 --- a/decoy/matchers.py +++ b/decoy/matchers.py @@ -27,20 +27,29 @@ def test_logger_called(decoy: Decoy): equality comparisons (`==`) for stubbing and verification. """ -from re import compile as compile_re -from typing import cast, Any, List, Mapping, Optional, Pattern, Type, TypeVar +from abc import ABC, abstractmethod +from re import compile as compile_re, Pattern +from typing import cast, TypeVar, Generic, Any, override, overload +from collections.abc import Iterable, Mapping +from warnings import deprecated __all__ = [ "Anything", + "AnythingOrNone", + "ArgumentCaptor", "Captor", "ErrorMatching", "IsA", "IsNot", "StringMatching", + "argument_captor", ] +MatchT = TypeVar("MatchT", default=Any) + + class _AnythingOrNone: def __eq__(self, target: object) -> bool: return True @@ -50,7 +59,7 @@ def __repr__(self) -> str: return "" -def AnythingOrNone() -> Any: +def AnythingOrNone() -> MatchT: # type: ignore[type-var] """Match anything including None. !!! example @@ -59,7 +68,7 @@ def AnythingOrNone() -> Any: assert None == AnythingOrNone() ``` """ - return _AnythingOrNone() + return cast(MatchT, _AnythingOrNone()) class _Anything: @@ -72,7 +81,7 @@ def __repr__(self) -> str: return "" -def Anything() -> Any: +def Anything() -> MatchT: # type: ignore[type-var] """Match anything except None. !!! example @@ -81,17 +90,17 @@ def Anything() -> Any: assert None != Anything() ``` """ - return _Anything() + return cast(MatchT, _Anything()) class _IsA: - _match_type: type - _attributes: Optional[Mapping[str, Any]] + _match_type: type[object] + _attributes: Mapping[str, object] | None def __init__( self, - match_type: type, - attributes: Optional[Mapping[str, Any]] = None, + match_type: type[object], + attributes: Mapping[str, object] | None = None, ) -> None: """Initialize the matcher with a type and optional attributes.""" self._match_type = match_type @@ -114,7 +123,9 @@ def __repr__(self) -> str: return f"" -def IsA(match_type: type, attributes: Optional[Mapping[str, Any]] = None) -> Any: +def IsA( + match_type: type[MatchT], attributes: Mapping[str, object] | None = None +) -> MatchT: """Match anything that satisfies the passed in type. Arguments: @@ -135,7 +146,7 @@ class HelloWorld: assert HelloWorld() == IsA(HelloWorld, {"hello": "world"}) ``` """ - return _IsA(match_type, attributes) + return cast(MatchT, _IsA(match_type, attributes)) class _IsNot: @@ -154,7 +165,7 @@ def __repr__(self) -> str: return f"" -def IsNot(value: object) -> Any: +def IsNot(value: MatchT) -> MatchT: """Match anything that isn't the passed in value. Arguments: @@ -167,13 +178,13 @@ def IsNot(value: object) -> Any: assert 1 != IsNot(1) ``` """ - return _IsNot(value) + return cast(MatchT, _IsNot(value)) class _HasAttributes: - _attributes: Mapping[str, Any] + _attributes: Mapping[str, object] - def __init__(self, attributes: Mapping[str, Any]) -> None: + def __init__(self, attributes: Mapping[str, object]) -> None: self._attributes = attributes def __eq__(self, target: object) -> bool: @@ -192,7 +203,7 @@ def __repr__(self) -> str: return f"" -def HasAttributes(attributes: Mapping[str, Any]) -> Any: +def HasAttributes(attributes: Mapping[str, object]) -> MatchT: # type: ignore[type-var] """Match anything with the passed in attributes. Arguments: @@ -208,23 +219,25 @@ class HelloWorld: assert HelloWorld() == matchers.HasAttributes({"hello": "world"}) ``` """ - return _HasAttributes(attributes) + return cast(MatchT, _HasAttributes(attributes)) class _DictMatching: - _values: Mapping[str, Any] + _values: Mapping[str, object] - def __init__(self, values: Mapping[str, Any]) -> None: + def __init__(self, values: Mapping[str, object]) -> None: self._values = values def __eq__(self, target: object) -> bool: """Return true if target matches all given keys/values.""" + if not isinstance(target, Mapping): + return False is_match = True for key, value in self._values.items(): if is_match: try: - is_match = key in target and target[key] == value # type: ignore[index,operator] + is_match = key in target and target[key] == value except TypeError: is_match = False @@ -235,7 +248,7 @@ def __repr__(self) -> str: return f"" -def DictMatching(values: Mapping[str, Any]) -> Any: +def DictMatching(values: Mapping[str, MatchT]) -> Mapping[str, MatchT]: """Match any dictionary with the passed in keys / values. Arguments: @@ -247,18 +260,18 @@ def DictMatching(values: Mapping[str, Any]) -> Any: assert value == matchers.DictMatching({"hello": "world"}) ``` """ - return _DictMatching(values) + return cast(Mapping[str, MatchT], _DictMatching(values)) class _ListMatching: - _values: List[Any] + _values: Iterable[object] - def __init__(self, values: List[Any]) -> None: + def __init__(self, values: Iterable[object]) -> None: self._values = values def __eq__(self, target: object) -> bool: """Return true if target matches all given values.""" - if not hasattr(target, "__iter__"): + if not isinstance(target, Iterable): return False return all( @@ -270,7 +283,7 @@ def __repr__(self) -> str: return f"" -def ListMatching(values: List[Any]) -> Any: +def ListMatching(values: list[MatchT]) -> list[MatchT]: """Match any list with the passed in values. Arguments: @@ -282,7 +295,7 @@ def ListMatching(values: List[Any]) -> Any: assert value == matchers.ListMatching([1, 2]) ``` """ - return _ListMatching(values) + return cast(list[MatchT], _ListMatching(values)) class _StringMatching: @@ -317,10 +330,10 @@ def StringMatching(match: str) -> str: class _ErrorMatching: - _error_type: Type[BaseException] - _string_matcher: Optional[_StringMatching] + _error_type: type[BaseException] + _string_matcher: _StringMatching | None - def __init__(self, error: Type[BaseException], match: Optional[str] = None) -> None: + def __init__(self, error: type[BaseException], match: str | None = None) -> None: """Initialize with the Exception type and optional message matcher.""" self._error_type = error self._string_matcher = _StringMatching(match) if match is not None else None @@ -346,7 +359,7 @@ def __repr__(self) -> str: ErrorT = TypeVar("ErrorT", bound=BaseException) -def ErrorMatching(error: Type[ErrorT], match: Optional[str] = None) -> ErrorT: +def ErrorMatching(error: type[ErrorT], match: str | None = None) -> ErrorT: """Match any error matching an Exception type and optional message matcher. Arguments: @@ -362,44 +375,105 @@ def ErrorMatching(error: Type[ErrorT], match: Optional[str] = None) -> ErrorT: return cast(ErrorT, _ErrorMatching(error, match)) -class _Captor: - def __init__(self) -> None: - self._values: List[Any] = [] +CapturedT = TypeVar("CapturedT", covariant=True) - def __eq__(self, target: object) -> bool: - """Capture compared value, always returning True.""" - self._values.append(target) - return True - def __repr__(self) -> str: - """Return a string representation of the matcher.""" - return "" +class ArgumentCaptor(ABC, Generic[CapturedT]): + """Captures method arguments for later assertions. + + Use the `capture()` method to pass the captor as an argument when stubbing a method. + The last captured argument is available via `captor.value`, and all captured arguments + are stored in `captor.values`. + + !!! example + ```python + captor: ArgumentCaptor[str] = argument_captor(match_type=str) + assert "foobar" == captor.capture() + assert 2 != captor.capture() + print(captor.value) # "foobar" + print(captor.values) # ["foobar"] + ``` + """ + + @abstractmethod + def capture(self) -> CapturedT: + """Match anything, capturing its value. + + !!! note + This method exists solely to match the target argument type and suppress type checker warnings. + """ @property - def value(self) -> Any: + @abstractmethod + def value(self) -> CapturedT: """Get the captured value. Raises: AssertionError: if no value was captured. """ + + @property + @abstractmethod + def values(self) -> list[CapturedT]: + """Get all captured values.""" + + +class _Captor(ArgumentCaptor[CapturedT]): + _values: list[CapturedT] + _match_type: type[CapturedT] + + def __init__(self, match_type: type[CapturedT]) -> None: + self._values = [] + self._match_type = match_type + + @override + def __eq__(self, target: object) -> bool: + if isinstance(target, self._match_type): + self._values.append(target) + return True + return False + + @override + def __repr__(self) -> str: + """Return a string representation of the matcher.""" + return "" + + @override + def capture(self) -> CapturedT: + return cast(CapturedT, self) + + @property + @override + def value(self) -> CapturedT: if len(self._values) == 0: raise AssertionError("No value captured by captor.") - return self._values[-1] @property - def values(self) -> List[Any]: - """Get all captured values.""" + def values(self) -> list[CapturedT]: return self._values -def Captor() -> Any: - """Match anything, capturing its value. +@overload +def Captor() -> Any: ... +@overload +def Captor(match_type: type[MatchT]) -> MatchT: ... +@deprecated( + "Use ArgumentCaptor() instead, and then call capture() to pass the matcher as an argument." +) +def Captor(match_type: type[object] = object) -> object: + """Match anything, capturing its value for further assertions. + + !!! warning Deprecated + This matcher is deprecated. Use [decoy.matchers.ArgumentCaptor][] instead. The last captured value will be set to `captor.value`. All captured values will be placed in the `captor.values` list, which can be helpful if a captor needs to be triggered multiple times. + Arguments: + match_type: Optional type to match. + !!! example ```python captor = Captor() @@ -408,4 +482,27 @@ def Captor() -> Any: print(captor.values) # ["foobar"] ``` """ - return _Captor() + return _Captor(match_type) + + +@overload +def argument_captor() -> ArgumentCaptor[Any]: ... +@overload +def argument_captor(match_type: type[MatchT]) -> ArgumentCaptor[MatchT]: ... +def argument_captor(match_type: type[object] = object) -> ArgumentCaptor[object]: + """Create an [decoy.matchers.ArgumentCaptor][] to capture arguments of the given type. + + Arguments: + match_type: Optional type to match. + + !!! example + ```python + fake = decoy.mock(cls=Dependency) + captor = matchers.argument_captor() + + decoy.when(fake.do_thing(captor.capture())).then_return(42) + + assert captor.value == "Expected value" + ``` + """ + return _Captor(match_type) diff --git a/docs/usage/matchers.md b/docs/usage/matchers.md index 05b5896..740b4a8 100644 --- a/docs/usage/matchers.md +++ b/docs/usage/matchers.md @@ -9,13 +9,14 @@ Decoy includes the [decoy.matchers][] module, which is a set of Python classes w | Matcher | Description | | --------------------------------- | ---------------------------------------------------- | | [decoy.matchers.Anything][] | Matches any value that isn't `None` | +| [decoy.matchers.AnythingOrNone][] | Matches any value including `None` | | [decoy.matchers.DictMatching][] | Matches a `dict` based on some of its values | | [decoy.matchers.ErrorMatching][] | Matches an `Exception` based on its type and message | | [decoy.matchers.HasAttributes][] | Matches an object based on its attributes | | [decoy.matchers.IsA][] | Matches using `isinstance` | | [decoy.matchers.IsNot][] | Matches anything that isn't a given value | | [decoy.matchers.StringMatching][] | Matches a string against a regular expression | -| [decoy.matchers.Captor][] | Captures the comparison value (see below) | +| [decoy.matchers.ArgumentCaptor][] | Captures the comparison value (see below) | ## Basic usage @@ -45,7 +46,11 @@ def test_log_warning(decoy: Decoy): ## Capturing values -When testing certain APIs, especially callback APIs, it can be helpful to capture the values of arguments passed to a given dependency. For this, Decoy provides [decoy.matchers.Captor][]. +When testing certain APIs, especially callback APIs, it can be helpful to capture the values of arguments passed to a given dependency. For this, Decoy provides the [decoy.matchers.ArgumentCaptor][] matcher, which can be created with the [decoy.matchers.argument_captor][] function. + +!!! note + + Prefer using [decoy.matchers.argument_captor][] to create a [decoy.matchers.ArgumentCaptor][] instance over the deprecated [decoy.matchers.Captor][]. The new function has better support for type checkers. For example, our test subject may register an event listener handler, and we want to test our subject's behavior when the event listener is triggered. @@ -61,13 +66,13 @@ from .event_consumer import EventConsumer def test_event_listener(decoy: Decoy): event_source = decoy.mock(cls=EventSource) subject = EventConsumer(event_source=event_source) - captor = matchers.Captor() + captor = matchers.argument_captor() # subject registers its listener when started subject.start_consuming() # verify listener attached and capture the listener - decoy.verify(event_source.register(event_listener=captor)) + decoy.verify(event_source.register(event_listener=captor.capture())) # trigger the listener event_handler = captor.value # or, equivalently, captor.values[0] @@ -77,7 +82,11 @@ def test_event_listener(decoy: Decoy): assert subject.has_heard_event is True ``` -This is a pretty verbose way of writing a test, so in general, you may want to approach using `matchers.Captor` as a form of potential code smell / test pain. There are often better ways to structure your code for these sorts of interactions that don't involve private functions. +!!! tip + + If you want to only capture values of a specific type, or you would like to have stricter type checking in your tests, consider passing a type to [decoy.matchers.argument_captor][] (e.g. `argument_captor(match_type=str)`). + +This is a pretty verbose way of writing a test, so in general, you may want to approach using `matchers.argument_captor` as a form of potential code smell / test pain. There are often better ways to structure your code for these sorts of interactions that don't involve private functions. For further reading on when (or rather, when not) to use argument captors, check out [testdouble's documentation on its argument captor matcher](https://github.com/testdouble/testdouble.js/blob/main/docs/6-verifying-invocations.md#tdmatcherscaptor). diff --git a/pyproject.toml b/pyproject.toml index db7162f..89e6224 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "decoy" -version = "2.2.0" +version = "2.3.0" description = "Opinionated mocking library for Python" authors = ["Michael Cousins "] license = "MIT" diff --git a/tests/test_matchers.py b/tests/test_matchers.py index c99be73..2c91045 100644 --- a/tests/test_matchers.py +++ b/tests/test_matchers.py @@ -3,8 +3,9 @@ import pytest from collections import namedtuple from decoy import Decoy, matchers -from typing import Any, List, NamedTuple +from typing import NamedTuple from .fixtures import SomeClass +import warnings class _HelloClass(NamedTuple): @@ -101,9 +102,8 @@ def test_dict_matching_matcher() -> None: ) assert {"hello": "world"} != matchers.DictMatching({"goodbye": "so long"}) - assert 1 != matchers.DictMatching({"hello": "world"}) - assert False != matchers.DictMatching({"hello": "world"}) # noqa: E712 - assert [] != matchers.DictMatching({"hello": "world"}) + assert 1 != matchers.DictMatching({"hello": "world"}) # type: ignore[comparison-overlap] + assert [] != matchers.DictMatching({"hello": "world"}) # type: ignore[comparison-overlap] def test_list_matching_matcher() -> None: @@ -124,7 +124,7 @@ def test_list_matching_matcher() -> None: [{"yoo": "mann"}] ) - assert 1 != matchers.ListMatching([1]) + assert 1 != matchers.ListMatching([1]) # type: ignore[comparison-overlap] assert str(matchers.ListMatching([1])) == "" @@ -145,8 +145,18 @@ def test_error_matching_matcher() -> None: def test_captor_matcher() -> None: """It should have a captor matcher that captures the compared value.""" - captor = matchers.Captor() - comparisons: List[Any] = [1, False, None, {}, [], ("hello", "world"), SomeClass()] + with warnings.catch_warnings(): + warnings.filterwarnings("ignore", category=DeprecationWarning) + captor = matchers.Captor() + comparisons: list[object] = [ + 1, + False, + None, + {}, + [], + ("hello", "world"), + SomeClass(), + ] for i, compare in enumerate(comparisons): assert compare == captor @@ -154,9 +164,56 @@ def test_captor_matcher() -> None: assert captor.values == comparisons[0 : i + 1] +def test_argument_captor_matcher() -> None: + """It should have a captor matcher that captures the compared value.""" + captor = matchers.argument_captor() + comparisons: list[object] = [ + 1, + False, + None, + {}, + [], + ("hello", "world"), + SomeClass(), + ] + + for i, compare in enumerate(comparisons): + assert compare == captor.capture() + assert captor.value is compare + assert captor.values == comparisons[0 : i + 1] + + +def test_argument_captor_matcher_with_match_type() -> None: + """It should have a captor matcher that captures the compared value.""" + captor = matchers.argument_captor(int) + comparisons: list[object] = [ + 1, + False, + None, + {}, + [], + ("hello", "world"), + SomeClass(), + ] + + for compare in comparisons: + if isinstance(compare, int): + assert compare == captor.capture() + assert captor.value is compare + else: + assert compare != captor.capture() + assert captor.values == [1, False] + + def test_captor_matcher_raises_if_no_value() -> None: """The captor matcher should raise an assertion error if no value.""" - captor = matchers.Captor() + with warnings.catch_warnings(): + warnings.filterwarnings("ignore", category=DeprecationWarning) + captor = matchers.Captor() + + with pytest.raises(AssertionError, match="No value captured"): + captor.value # noqa: B018 + captor = matchers.argument_captor() with pytest.raises(AssertionError, match="No value captured"): captor.value # noqa: B018 diff --git a/tests/typing/test_typing.yml b/tests/typing/test_typing.yml index 707a484..40ed773 100644 --- a/tests/typing/test_typing.yml +++ b/tests/typing/test_typing.yml @@ -108,25 +108,63 @@ decoy.when(fake_any("hello")).then_enter_with(42) out: | - main:10: error: Invalid self argument "Stub\[int]" to attribute function "then_enter_with" with type "Callable\[\[Stub\[.*ContextManager\[ContextValueT]], ContextValueT], None]" \[misc] + main:10: error: Invalid self argument "Stub\[int]" to attribute function "then_enter_with" with type "Callable\[\[Stub\[.*ContextManager\[ContextValueT, bool | None]], ContextValueT], None]" \[misc] main:10: error: No overload variant of "then_enter_with" of "Stub" matches argument type "int" \[call-overload] main:10: note: Possible overload variants: main:10: note: def then_enter_with\(self, value.+\) -> None -- case: matchers_mimic_types +- case: matchers_mimic_types_when_not_passed_to_a_function main: | from decoy import matchers + captor = matchers.argument_captor() + reveal_type(matchers.Anything()) + reveal_type(matchers.AnythingOrNone()) reveal_type(matchers.IsA(str)) - reveal_type(matchers.IsNot(str)) + reveal_type(matchers.IsNot(False)) + reveal_type(matchers.HasAttributes({"foo": "bar"})) + reveal_type(matchers.DictMatching({"foo": 1})) + reveal_type(matchers.ListMatching([1])) reveal_type(matchers.StringMatching("foobar")) reveal_type(matchers.ErrorMatching(RuntimeError)) reveal_type(matchers.Captor()) + reveal_type(captor.capture()) + reveal_type(captor.values) out: | - main:3: note: Revealed type is "Any" - main:4: note: Revealed type is "Any" main:5: note: Revealed type is "Any" - main:6: note: Revealed type is "builtins.str" - main:7: note: Revealed type is "builtins.RuntimeError" - main:8: note: Revealed type is "Any" + main:6: note: Revealed type is "Any" + main:7: note: Revealed type is "builtins.str" + main:8: note: Revealed type is "builtins.bool" + main:9: note: Revealed type is "Any" + main:10: note: Revealed type is "typing.Mapping[builtins.str, builtins.int]" + main:11: note: Revealed type is "builtins.list[builtins.int]" + main:12: note: Revealed type is "builtins.str" + main:13: note: Revealed type is "builtins.RuntimeError" + main:14: note: Revealed type is "Any" + main:15: note: Revealed type is "Any" + main:16: note: Revealed type is "builtins.list[Any]" + +- case: matchers_mimic_types_when_passed_to_a_function + main: | + from decoy import Decoy, matchers + + class Dependency(): + def do_thing(self, input: str) -> int: + return 42 + + decoy = Decoy() + fake = decoy.mock(cls=Dependency) + captor1 = matchers.argument_captor() + captor2 = matchers.argument_captor(str) + captor3 = matchers.argument_captor(int) + + decoy.when(fake.do_thing(matchers.Anything())).then_return(42) + decoy.when(fake.do_thing(matchers.AnythingOrNone())).then_return(42) + decoy.when(fake.do_thing(matchers.HasAttributes({"foo": "bar"}))).then_return(42) + decoy.when(fake.do_thing(matchers.Captor())).then_return(42) + decoy.when(fake.do_thing(captor1.capture())).then_return(42) + decoy.when(fake.do_thing(captor2.capture())).then_return(42) + decoy.when(fake.do_thing(captor3.capture())).then_return(42) + out: | + main:19: error: Argument 1 to "do_thing" of "Dependency" has incompatible type "int"; expected "str" [arg-type] From cb38433a9eab7f661f64b8b0335a759d73cc7534 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Fern=C3=A1ndez?= <61871580+Alejandro-FA@users.noreply.github.com> Date: Thu, 21 Aug 2025 09:44:19 +0200 Subject: [PATCH 2/4] Revert matcher changes and fix simple issues --- CONTRIBUTING.md | 4 +- decoy/matchers.py | 86 +++++++++++++++--------------------- docs/usage/matchers.md | 6 +-- pyproject.toml | 2 +- tests/test_matchers.py | 28 ++++-------- tests/typing/test_typing.yml | 52 ++++++++-------------- 6 files changed, 67 insertions(+), 111 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a715559..7b1f323 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -41,8 +41,8 @@ poetry run poe coverage In an exciting twist, since version 1.6.0, Decoy's tests rely on Decoy itself to test (and more importantly, design) the relationships between Decoy's internal APIs. This means: -- Decoy's unit test suite serves as an end-to-end test of Decoy by virtue of existing (wow, very meta, actually kind of cool). -- Changes that break a small part of Decoy may result in a large number of test failures, because if Decoy breaks it can't be used to test itself. +- Decoy's unit test suite serves as an end-to-end test of Decoy by virtue of existing (wow, very meta, actually kind of cool). +- Changes that break a small part of Decoy may result in a large number of test failures, because if Decoy breaks it can't be used to test itself. If you find yourself in a situation where Decoy's test suite has blown up, **concentrate on getting the test suites that don't use Decoy to pass**. From there, lean on the type-checker to guide you to any components that aren't properly hooked up. Decoy also has a end-to-end smoke test suite (`tests/test_decoy.py`) that can be helpful in getting things back to green. diff --git a/decoy/matchers.py b/decoy/matchers.py index 71f6d82..530c161 100644 --- a/decoy/matchers.py +++ b/decoy/matchers.py @@ -28,10 +28,8 @@ def test_logger_called(decoy: Decoy): """ from abc import ABC, abstractmethod -from re import compile as compile_re, Pattern -from typing import cast, TypeVar, Generic, Any, override, overload -from collections.abc import Iterable, Mapping -from warnings import deprecated +from re import compile as compile_re +from typing import cast, overload, Any, Generic, List, Mapping, Optional, Pattern, Type, TypeVar __all__ = [ @@ -47,9 +45,6 @@ def test_logger_called(decoy: Decoy): ] -MatchT = TypeVar("MatchT", default=Any) - - class _AnythingOrNone: def __eq__(self, target: object) -> bool: return True @@ -59,7 +54,7 @@ def __repr__(self) -> str: return "" -def AnythingOrNone() -> MatchT: # type: ignore[type-var] +def AnythingOrNone() -> Any: """Match anything including None. !!! example @@ -68,7 +63,7 @@ def AnythingOrNone() -> MatchT: # type: ignore[type-var] assert None == AnythingOrNone() ``` """ - return cast(MatchT, _AnythingOrNone()) + return _AnythingOrNone() class _Anything: @@ -81,7 +76,7 @@ def __repr__(self) -> str: return "" -def Anything() -> MatchT: # type: ignore[type-var] +def Anything() -> Any: """Match anything except None. !!! example @@ -90,17 +85,17 @@ def Anything() -> MatchT: # type: ignore[type-var] assert None != Anything() ``` """ - return cast(MatchT, _Anything()) + return _Anything() class _IsA: - _match_type: type[object] - _attributes: Mapping[str, object] | None + _match_type: type + _attributes: Optional[Mapping[str, Any]] def __init__( self, - match_type: type[object], - attributes: Mapping[str, object] | None = None, + match_type: type, + attributes: Optional[Mapping[str, Any]] = None, ) -> None: """Initialize the matcher with a type and optional attributes.""" self._match_type = match_type @@ -123,9 +118,7 @@ def __repr__(self) -> str: return f"" -def IsA( - match_type: type[MatchT], attributes: Mapping[str, object] | None = None -) -> MatchT: +def IsA(match_type: type, attributes: Optional[Mapping[str, Any]] = None) -> Any: """Match anything that satisfies the passed in type. Arguments: @@ -146,7 +139,7 @@ class HelloWorld: assert HelloWorld() == IsA(HelloWorld, {"hello": "world"}) ``` """ - return cast(MatchT, _IsA(match_type, attributes)) + return _IsA(match_type, attributes) class _IsNot: @@ -165,7 +158,7 @@ def __repr__(self) -> str: return f"" -def IsNot(value: MatchT) -> MatchT: +def IsNot(value: object) -> Any: """Match anything that isn't the passed in value. Arguments: @@ -178,13 +171,13 @@ def IsNot(value: MatchT) -> MatchT: assert 1 != IsNot(1) ``` """ - return cast(MatchT, _IsNot(value)) + return _IsNot(value) class _HasAttributes: - _attributes: Mapping[str, object] + _attributes: Mapping[str, Any] - def __init__(self, attributes: Mapping[str, object]) -> None: + def __init__(self, attributes: Mapping[str, Any]) -> None: self._attributes = attributes def __eq__(self, target: object) -> bool: @@ -203,7 +196,7 @@ def __repr__(self) -> str: return f"" -def HasAttributes(attributes: Mapping[str, object]) -> MatchT: # type: ignore[type-var] +def HasAttributes(attributes: Mapping[str, Any]) -> Any: """Match anything with the passed in attributes. Arguments: @@ -219,25 +212,23 @@ class HelloWorld: assert HelloWorld() == matchers.HasAttributes({"hello": "world"}) ``` """ - return cast(MatchT, _HasAttributes(attributes)) + return _HasAttributes(attributes) class _DictMatching: - _values: Mapping[str, object] + _values: Mapping[str, Any] - def __init__(self, values: Mapping[str, object]) -> None: + def __init__(self, values: Mapping[str, Any]) -> None: self._values = values def __eq__(self, target: object) -> bool: """Return true if target matches all given keys/values.""" - if not isinstance(target, Mapping): - return False is_match = True for key, value in self._values.items(): if is_match: try: - is_match = key in target and target[key] == value + is_match = key in target and target[key] == value # type: ignore[index,operator] except TypeError: is_match = False @@ -248,7 +239,7 @@ def __repr__(self) -> str: return f"" -def DictMatching(values: Mapping[str, MatchT]) -> Mapping[str, MatchT]: +def DictMatching(values: Mapping[str, Any]) -> Any: """Match any dictionary with the passed in keys / values. Arguments: @@ -260,18 +251,18 @@ def DictMatching(values: Mapping[str, MatchT]) -> Mapping[str, MatchT]: assert value == matchers.DictMatching({"hello": "world"}) ``` """ - return cast(Mapping[str, MatchT], _DictMatching(values)) + return _DictMatching(values) class _ListMatching: - _values: Iterable[object] + _values: List[Any] - def __init__(self, values: Iterable[object]) -> None: + def __init__(self, values: List[Any]) -> None: self._values = values def __eq__(self, target: object) -> bool: """Return true if target matches all given values.""" - if not isinstance(target, Iterable): + if not hasattr(target, "__iter__"): return False return all( @@ -283,7 +274,7 @@ def __repr__(self) -> str: return f"" -def ListMatching(values: list[MatchT]) -> list[MatchT]: +def ListMatching(values: List[Any]) -> Any: """Match any list with the passed in values. Arguments: @@ -295,7 +286,7 @@ def ListMatching(values: list[MatchT]) -> list[MatchT]: assert value == matchers.ListMatching([1, 2]) ``` """ - return cast(list[MatchT], _ListMatching(values)) + return _ListMatching(values) class _StringMatching: @@ -330,10 +321,10 @@ def StringMatching(match: str) -> str: class _ErrorMatching: - _error_type: type[BaseException] - _string_matcher: _StringMatching | None + _error_type: Type[BaseException] + _string_matcher: Optional[_StringMatching] - def __init__(self, error: type[BaseException], match: str | None = None) -> None: + def __init__(self, error: Type[BaseException], match: Optional[str] = None) -> None: """Initialize with the Exception type and optional message matcher.""" self._error_type = error self._string_matcher = _StringMatching(match) if match is not None else None @@ -359,7 +350,7 @@ def __repr__(self) -> str: ErrorT = TypeVar("ErrorT", bound=BaseException) -def ErrorMatching(error: type[ErrorT], match: str | None = None) -> ErrorT: +def ErrorMatching(error: Type[ErrorT], match: Optional[str] = None) -> ErrorT: """Match any error matching an Exception type and optional message matcher. Arguments: @@ -426,24 +417,20 @@ def __init__(self, match_type: type[CapturedT]) -> None: self._values = [] self._match_type = match_type - @override def __eq__(self, target: object) -> bool: if isinstance(target, self._match_type): self._values.append(target) return True return False - @override def __repr__(self) -> str: """Return a string representation of the matcher.""" return "" - @override def capture(self) -> CapturedT: return cast(CapturedT, self) @property - @override def value(self) -> CapturedT: if len(self._values) == 0: raise AssertionError("No value captured by captor.") @@ -454,19 +441,16 @@ def values(self) -> list[CapturedT]: return self._values +MatchT = TypeVar("MatchT") + + @overload def Captor() -> Any: ... @overload def Captor(match_type: type[MatchT]) -> MatchT: ... -@deprecated( - "Use ArgumentCaptor() instead, and then call capture() to pass the matcher as an argument." -) def Captor(match_type: type[object] = object) -> object: """Match anything, capturing its value for further assertions. - !!! warning Deprecated - This matcher is deprecated. Use [decoy.matchers.ArgumentCaptor][] instead. - The last captured value will be set to `captor.value`. All captured values will be placed in the `captor.values` list, which can be helpful if a captor needs to be triggered multiple times. diff --git a/docs/usage/matchers.md b/docs/usage/matchers.md index 740b4a8..cef0928 100644 --- a/docs/usage/matchers.md +++ b/docs/usage/matchers.md @@ -82,14 +82,12 @@ def test_event_listener(decoy: Decoy): assert subject.has_heard_event is True ``` -!!! tip - - If you want to only capture values of a specific type, or you would like to have stricter type checking in your tests, consider passing a type to [decoy.matchers.argument_captor][] (e.g. `argument_captor(match_type=str)`). - This is a pretty verbose way of writing a test, so in general, you may want to approach using `matchers.argument_captor` as a form of potential code smell / test pain. There are often better ways to structure your code for these sorts of interactions that don't involve private functions. For further reading on when (or rather, when not) to use argument captors, check out [testdouble's documentation on its argument captor matcher](https://github.com/testdouble/testdouble.js/blob/main/docs/6-verifying-invocations.md#tdmatcherscaptor). +If you would like to have stricter type checking in your tests, consider passing a type to [decoy.matchers.argument_captor][] (e.g. `argument_captor(match_type=str)`). By doing so, captured values will be cast to the specified type. + ## Writing custom matchers You can write your own matcher class and use it wherever you would use a built-in matcher. All you need to do is define a class with an `__eq__` method: diff --git a/pyproject.toml b/pyproject.toml index 89e6224..db7162f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "decoy" -version = "2.3.0" +version = "2.2.0" description = "Opinionated mocking library for Python" authors = ["Michael Cousins "] license = "MIT" diff --git a/tests/test_matchers.py b/tests/test_matchers.py index 2c91045..3ad4e86 100644 --- a/tests/test_matchers.py +++ b/tests/test_matchers.py @@ -3,9 +3,8 @@ import pytest from collections import namedtuple from decoy import Decoy, matchers -from typing import NamedTuple +from typing import Any, List, NamedTuple from .fixtures import SomeClass -import warnings class _HelloClass(NamedTuple): @@ -102,8 +101,9 @@ def test_dict_matching_matcher() -> None: ) assert {"hello": "world"} != matchers.DictMatching({"goodbye": "so long"}) - assert 1 != matchers.DictMatching({"hello": "world"}) # type: ignore[comparison-overlap] - assert [] != matchers.DictMatching({"hello": "world"}) # type: ignore[comparison-overlap] + assert 1 != matchers.DictMatching({"hello": "world"}) + assert False != matchers.DictMatching({"hello": "world"}) # noqa: E712 + assert [] != matchers.DictMatching({"hello": "world"}) def test_list_matching_matcher() -> None: @@ -124,7 +124,7 @@ def test_list_matching_matcher() -> None: [{"yoo": "mann"}] ) - assert 1 != matchers.ListMatching([1]) # type: ignore[comparison-overlap] + assert 1 != matchers.ListMatching([1]) assert str(matchers.ListMatching([1])) == "" @@ -145,18 +145,8 @@ def test_error_matching_matcher() -> None: def test_captor_matcher() -> None: """It should have a captor matcher that captures the compared value.""" - with warnings.catch_warnings(): - warnings.filterwarnings("ignore", category=DeprecationWarning) - captor = matchers.Captor() - comparisons: list[object] = [ - 1, - False, - None, - {}, - [], - ("hello", "world"), - SomeClass(), - ] + captor = matchers.Captor() + comparisons: List[Any] = [1, False, None, {}, [], ("hello", "world"), SomeClass()] for i, compare in enumerate(comparisons): assert compare == captor @@ -207,9 +197,7 @@ def test_argument_captor_matcher_with_match_type() -> None: def test_captor_matcher_raises_if_no_value() -> None: """The captor matcher should raise an assertion error if no value.""" - with warnings.catch_warnings(): - warnings.filterwarnings("ignore", category=DeprecationWarning) - captor = matchers.Captor() + captor = matchers.Captor() with pytest.raises(AssertionError, match="No value captured"): captor.value # noqa: B018 diff --git a/tests/typing/test_typing.yml b/tests/typing/test_typing.yml index 40ed773..234a2af 100644 --- a/tests/typing/test_typing.yml +++ b/tests/typing/test_typing.yml @@ -108,7 +108,7 @@ decoy.when(fake_any("hello")).then_enter_with(42) out: | - main:10: error: Invalid self argument "Stub\[int]" to attribute function "then_enter_with" with type "Callable\[\[Stub\[.*ContextManager\[ContextValueT, bool | None]], ContextValueT], None]" \[misc] + main:10: error: Invalid self argument "Stub\[int]" to attribute function "then_enter_with" with type "Callable\[\[Stub\[.*ContextManager\[ContextValueT]], ContextValueT], None]" \[misc] main:10: error: No overload variant of "then_enter_with" of "Stub" matches argument type "int" \[call-overload] main:10: note: Possible overload variants: main:10: note: def then_enter_with\(self, value.+\) -> None @@ -133,38 +133,24 @@ reveal_type(captor.values) out: | main:5: note: Revealed type is "Any" - main:6: note: Revealed type is "Any" - main:7: note: Revealed type is "builtins.str" - main:8: note: Revealed type is "builtins.bool" - main:9: note: Revealed type is "Any" - main:10: note: Revealed type is "typing.Mapping[builtins.str, builtins.int]" - main:11: note: Revealed type is "builtins.list[builtins.int]" - main:12: note: Revealed type is "builtins.str" - main:13: note: Revealed type is "builtins.RuntimeError" - main:14: note: Revealed type is "Any" - main:15: note: Revealed type is "Any" - main:16: note: Revealed type is "builtins.list[Any]" - -- case: matchers_mimic_types_when_passed_to_a_function - main: | - from decoy import Decoy, matchers + main:6: note: Revealed type is "builtins.str" + main:7: note: Revealed type is "builtins.RuntimeError" + main:8: note: Revealed type is "Any" - class Dependency(): - def do_thing(self, input: str) -> int: - return 42 +- case: captor_mimics_types + main: | + from decoy import matchers - decoy = Decoy() - fake = decoy.mock(cls=Dependency) - captor1 = matchers.argument_captor() - captor2 = matchers.argument_captor(str) - captor3 = matchers.argument_captor(int) - - decoy.when(fake.do_thing(matchers.Anything())).then_return(42) - decoy.when(fake.do_thing(matchers.AnythingOrNone())).then_return(42) - decoy.when(fake.do_thing(matchers.HasAttributes({"foo": "bar"}))).then_return(42) - decoy.when(fake.do_thing(matchers.Captor())).then_return(42) - decoy.when(fake.do_thing(captor1.capture())).then_return(42) - decoy.when(fake.do_thing(captor2.capture())).then_return(42) - decoy.when(fake.do_thing(captor3.capture())).then_return(42) + reveal_type(matchers.Anything()) + reveal_type(matchers.IsA(str)) + reveal_type(matchers.IsNot(str)) + reveal_type(matchers.StringMatching("foobar")) + reveal_type(matchers.ErrorMatching(RuntimeError)) + reveal_type(matchers.Captor()) out: | - main:19: error: Argument 1 to "do_thing" of "Dependency" has incompatible type "int"; expected "str" [arg-type] + main:3: note: Revealed type is "Any" + main:4: note: Revealed type is "Any" + main:5: note: Revealed type is "Any" + main:6: note: Revealed type is "builtins.str" + main:7: note: Revealed type is "builtins.RuntimeError" + main:8: note: Revealed type is "Any" From d009732a13757f186b18bbb830929895a19d947d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Fern=C3=A1ndez?= <61871580+Alejandro-FA@users.noreply.github.com> Date: Thu, 21 Aug 2025 12:21:33 +0200 Subject: [PATCH 3/4] Remove unnecessary `argument_captor` function. Also, change ABC with a Protocol, and adapt tests to the changes. --- decoy/matchers.py | 58 ++++++++++-------------------------- docs/usage/matchers.md | 14 ++++----- tests/test_matchers.py | 47 +++++------------------------ tests/typing/test_typing.yml | 36 +++++++++++----------- 4 files changed, 44 insertions(+), 111 deletions(-) diff --git a/decoy/matchers.py b/decoy/matchers.py index 530c161..1840cad 100644 --- a/decoy/matchers.py +++ b/decoy/matchers.py @@ -27,9 +27,9 @@ def test_logger_called(decoy: Decoy): equality comparisons (`==`) for stubbing and verification. """ -from abc import ABC, abstractmethod +from abc import abstractmethod from re import compile as compile_re -from typing import cast, overload, Any, Generic, List, Mapping, Optional, Pattern, Type, TypeVar +from typing import cast, overload, Any, List, Mapping, Optional, Pattern, Protocol, Type, TypeVar __all__ = [ @@ -41,7 +41,6 @@ def test_logger_called(decoy: Decoy): "IsA", "IsNot", "StringMatching", - "argument_captor", ] @@ -366,33 +365,32 @@ def ErrorMatching(error: Type[ErrorT], match: Optional[str] = None) -> ErrorT: return cast(ErrorT, _ErrorMatching(error, match)) -CapturedT = TypeVar("CapturedT", covariant=True) +CapturedT = TypeVar("CapturedT") -class ArgumentCaptor(ABC, Generic[CapturedT]): +class ArgumentCaptor(Protocol[CapturedT]): """Captures method arguments for later assertions. - Use the `capture()` method to pass the captor as an argument when stubbing a method. + Use the `capture()` method to pass the captor as an argument when verifying a method. The last captured argument is available via `captor.value`, and all captured arguments are stored in `captor.values`. !!! example ```python - captor: ArgumentCaptor[str] = argument_captor(match_type=str) + captor: ArgumentCaptor[str] = Captor(match_type=str) assert "foobar" == captor.capture() assert 2 != captor.capture() print(captor.value) # "foobar" print(captor.values) # ["foobar"] ``` """ - - @abstractmethod def capture(self) -> CapturedT: """Match anything, capturing its value. !!! note This method exists solely to match the target argument type and suppress type checker warnings. """ + return cast(CapturedT, self) @property @abstractmethod @@ -405,15 +403,15 @@ def value(self) -> CapturedT: @property @abstractmethod - def values(self) -> list[CapturedT]: + def values(self) -> List[CapturedT]: """Get all captured values.""" class _Captor(ArgumentCaptor[CapturedT]): - _values: list[CapturedT] - _match_type: type[CapturedT] + _values: List[CapturedT] + _match_type: Type[CapturedT] - def __init__(self, match_type: type[CapturedT]) -> None: + def __init__(self, match_type: Type[CapturedT]) -> None: self._values = [] self._match_type = match_type @@ -427,9 +425,6 @@ def __repr__(self) -> str: """Return a string representation of the matcher.""" return "" - def capture(self) -> CapturedT: - return cast(CapturedT, self) - @property def value(self) -> CapturedT: if len(self._values) == 0: @@ -437,7 +432,7 @@ def value(self) -> CapturedT: return self._values[-1] @property - def values(self) -> list[CapturedT]: + def values(self) -> List[CapturedT]: return self._values @@ -447,8 +442,8 @@ def values(self) -> list[CapturedT]: @overload def Captor() -> Any: ... @overload -def Captor(match_type: type[MatchT]) -> MatchT: ... -def Captor(match_type: type[object] = object) -> object: +def Captor(match_type: Type[MatchT]) -> ArgumentCaptor[MatchT]: ... +def Captor(match_type: Type[object] = object) -> Any: """Match anything, capturing its value for further assertions. The last captured value will be set to `captor.value`. All captured @@ -461,32 +456,9 @@ def Captor(match_type: type[object] = object) -> object: !!! example ```python captor = Captor() - assert "foobar" == captor + assert "foobar" == captor.capture() print(captor.value) # "foobar" print(captor.values) # ["foobar"] ``` """ return _Captor(match_type) - - -@overload -def argument_captor() -> ArgumentCaptor[Any]: ... -@overload -def argument_captor(match_type: type[MatchT]) -> ArgumentCaptor[MatchT]: ... -def argument_captor(match_type: type[object] = object) -> ArgumentCaptor[object]: - """Create an [decoy.matchers.ArgumentCaptor][] to capture arguments of the given type. - - Arguments: - match_type: Optional type to match. - - !!! example - ```python - fake = decoy.mock(cls=Dependency) - captor = matchers.argument_captor() - - decoy.when(fake.do_thing(captor.capture())).then_return(42) - - assert captor.value == "Expected value" - ``` - """ - return _Captor(match_type) diff --git a/docs/usage/matchers.md b/docs/usage/matchers.md index cef0928..e3256f2 100644 --- a/docs/usage/matchers.md +++ b/docs/usage/matchers.md @@ -16,7 +16,7 @@ Decoy includes the [decoy.matchers][] module, which is a set of Python classes w | [decoy.matchers.IsA][] | Matches using `isinstance` | | [decoy.matchers.IsNot][] | Matches anything that isn't a given value | | [decoy.matchers.StringMatching][] | Matches a string against a regular expression | -| [decoy.matchers.ArgumentCaptor][] | Captures the comparison value (see below) | +| [decoy.matchers.Captor][] | Captures the comparison value (see below) | ## Basic usage @@ -46,11 +46,7 @@ def test_log_warning(decoy: Decoy): ## Capturing values -When testing certain APIs, especially callback APIs, it can be helpful to capture the values of arguments passed to a given dependency. For this, Decoy provides the [decoy.matchers.ArgumentCaptor][] matcher, which can be created with the [decoy.matchers.argument_captor][] function. - -!!! note - - Prefer using [decoy.matchers.argument_captor][] to create a [decoy.matchers.ArgumentCaptor][] instance over the deprecated [decoy.matchers.Captor][]. The new function has better support for type checkers. +When testing certain APIs, especially callback APIs, it can be helpful to capture the values of arguments passed to a given dependency. For this, Decoy provides [decoy.matchers.Captor][]. For example, our test subject may register an event listener handler, and we want to test our subject's behavior when the event listener is triggered. @@ -66,7 +62,7 @@ from .event_consumer import EventConsumer def test_event_listener(decoy: Decoy): event_source = decoy.mock(cls=EventSource) subject = EventConsumer(event_source=event_source) - captor = matchers.argument_captor() + captor = matchers.Captor() # subject registers its listener when started subject.start_consuming() @@ -82,11 +78,11 @@ def test_event_listener(decoy: Decoy): assert subject.has_heard_event is True ``` -This is a pretty verbose way of writing a test, so in general, you may want to approach using `matchers.argument_captor` as a form of potential code smell / test pain. There are often better ways to structure your code for these sorts of interactions that don't involve private functions. +This is a pretty verbose way of writing a test, so in general, you may want to approach using `matchers.Captor` as a form of potential code smell / test pain. There are often better ways to structure your code for these sorts of interactions that don't involve private functions. For further reading on when (or rather, when not) to use argument captors, check out [testdouble's documentation on its argument captor matcher](https://github.com/testdouble/testdouble.js/blob/main/docs/6-verifying-invocations.md#tdmatcherscaptor). -If you would like to have stricter type checking in your tests, consider passing a type to [decoy.matchers.argument_captor][] (e.g. `argument_captor(match_type=str)`). By doing so, captured values will be cast to the specified type. +If you want to only capture values of a specific type, or you would like to have stricter type checking in your tests, consider passing a type to [decoy.matchers.Captor][] (e.g. `Captor(match_type=str)`). ## Writing custom matchers diff --git a/tests/test_matchers.py b/tests/test_matchers.py index 3ad4e86..6ea9be5 100644 --- a/tests/test_matchers.py +++ b/tests/test_matchers.py @@ -154,44 +154,15 @@ def test_captor_matcher() -> None: assert captor.values == comparisons[0 : i + 1] -def test_argument_captor_matcher() -> None: - """It should have a captor matcher that captures the compared value.""" - captor = matchers.argument_captor() - comparisons: list[object] = [ - 1, - False, - None, - {}, - [], - ("hello", "world"), - SomeClass(), - ] - - for i, compare in enumerate(comparisons): - assert compare == captor.capture() - assert captor.value is compare - assert captor.values == comparisons[0 : i + 1] - - -def test_argument_captor_matcher_with_match_type() -> None: - """It should have a captor matcher that captures the compared value.""" - captor = matchers.argument_captor(int) - comparisons: list[object] = [ - 1, - False, - None, - {}, - [], - ("hello", "world"), - SomeClass(), - ] +def test_captor_matcher_with_match_type() -> None: + """It should have a captor matcher that captures the compared value if it matches the type.""" + captor = matchers.Captor(int) + comparisons: List[Any] = [1, False, None, {}, [], ("hello", "world"), SomeClass()] for compare in comparisons: - if isinstance(compare, int): - assert compare == captor.capture() - assert captor.value is compare - else: - assert compare != captor.capture() + is_equal = compare == captor.capture() + assert is_equal == isinstance(compare, int) + assert captor.values == [1, False] @@ -201,7 +172,3 @@ def test_captor_matcher_raises_if_no_value() -> None: with pytest.raises(AssertionError, match="No value captured"): captor.value # noqa: B018 - - captor = matchers.argument_captor() - with pytest.raises(AssertionError, match="No value captured"): - captor.value # noqa: B018 diff --git a/tests/typing/test_typing.yml b/tests/typing/test_typing.yml index 234a2af..b6e1d6d 100644 --- a/tests/typing/test_typing.yml +++ b/tests/typing/test_typing.yml @@ -113,44 +113,42 @@ main:10: note: Possible overload variants: main:10: note: def then_enter_with\(self, value.+\) -> None -- case: matchers_mimic_types_when_not_passed_to_a_function +- case: matchers_mimic_types main: | from decoy import matchers - captor = matchers.argument_captor() - reveal_type(matchers.Anything()) reveal_type(matchers.AnythingOrNone()) reveal_type(matchers.IsA(str)) - reveal_type(matchers.IsNot(False)) + reveal_type(matchers.IsNot(str)) reveal_type(matchers.HasAttributes({"foo": "bar"})) reveal_type(matchers.DictMatching({"foo": 1})) reveal_type(matchers.ListMatching([1])) reveal_type(matchers.StringMatching("foobar")) reveal_type(matchers.ErrorMatching(RuntimeError)) reveal_type(matchers.Captor()) - reveal_type(captor.capture()) - reveal_type(captor.values) out: | + main:3: note: Revealed type is "Any" + main:4: note: Revealed type is "Any" main:5: note: Revealed type is "Any" - main:6: note: Revealed type is "builtins.str" - main:7: note: Revealed type is "builtins.RuntimeError" + main:6: note: Revealed type is "Any" + main:7: note: Revealed type is "Any" main:8: note: Revealed type is "Any" + main:9: note: Revealed type is "Any" + main:10: note: Revealed type is "builtins.str" + main:11: note: Revealed type is "builtins.RuntimeError" + main:12: note: Revealed type is "Any" - case: captor_mimics_types main: | from decoy import matchers - reveal_type(matchers.Anything()) - reveal_type(matchers.IsA(str)) - reveal_type(matchers.IsNot(str)) - reveal_type(matchers.StringMatching("foobar")) - reveal_type(matchers.ErrorMatching(RuntimeError)) - reveal_type(matchers.Captor()) + captor = matchers.Captor(str) + + reveal_type(captor) + reveal_type(captor.capture()) + reveal_type(captor.values) out: | - main:3: note: Revealed type is "Any" - main:4: note: Revealed type is "Any" - main:5: note: Revealed type is "Any" + main:5: note: Revealed type is "decoy.matchers.ArgumentCaptor[builtins.str]" main:6: note: Revealed type is "builtins.str" - main:7: note: Revealed type is "builtins.RuntimeError" - main:8: note: Revealed type is "Any" + main:7: note: Revealed type is "builtins.list[builtins.str]" From 9a16ea5f6879c8e05e7962a8b211d4c35f0db883 Mon Sep 17 00:00:00 2001 From: Michael Cousins Date: Tue, 6 Jan 2026 09:00:53 -0500 Subject: [PATCH 4/4] fixup: simplify behaviors, update docs --- CONTRIBUTING.md | 6 -- decoy/matchers.py | 113 +++++++++++------------------------ docs/usage/matchers.md | 13 ++-- tests/test_matchers.py | 39 ++++++++---- tests/typing/test_typing.yml | 12 ++-- 5 files changed, 73 insertions(+), 110 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9c890bc..2881696 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -63,13 +63,7 @@ poe format Decoy's documentation is built with [mkdocs][], which you can use to preview the documentation site locally. ```bash -<<<<<<< HEAD -poetry run poe docs -||||||| 1cebd3f -poetry run docs -======= poe docs ->>>>>>> main ``` ## Deploying diff --git a/decoy/matchers.py b/decoy/matchers.py index 4231a9f..9174ccc 100644 --- a/decoy/matchers.py +++ b/decoy/matchers.py @@ -27,32 +27,19 @@ def test_logger_called(decoy: Decoy): equality comparisons (`==`) for stubbing and verification. """ -from abc import abstractmethod from re import compile as compile_re from typing import ( Any, + Generic, List, Mapping, Optional, Pattern, - Protocol, Type, TypeVar, cast, - overload, ) -__all__ = [ - "Anything", - "AnythingOrNone", - "ArgumentCaptor", - "Captor", - "ErrorMatching", - "IsA", - "IsNot", - "StringMatching", -] - class _AnythingOrNone: def __eq__(self, target: object) -> bool: @@ -378,98 +365,66 @@ def ErrorMatching(error: Type[ErrorT], match: Optional[str] = None) -> ErrorT: CapturedT = TypeVar("CapturedT") -class ArgumentCaptor(Protocol[CapturedT]): - """Captures method arguments for later assertions. +class ValueCaptor(Generic[CapturedT]): + """Match anything, capturing its value for further assertions. - Use the `capture()` method to pass the captor as an argument when verifying a method. - The last captured argument is available via `captor.value`, and all captured arguments - are stored in `captor.values`. + Compare against the `matcher` property to capture a value. + The last captured value is available via `captor.value`, + while all captured values are stored in `captor.values`. !!! example ```python - captor: ArgumentCaptor[str] = Captor(match_type=str) - assert "foobar" == captor.capture() - assert 2 != captor.capture() + captor = ValueCaptor[str]() + assert "foobar" == captor.matcher print(captor.value) # "foobar" print(captor.values) # ["foobar"] ``` """ - def capture(self) -> CapturedT: - """Match anything, capturing its value. - - !!! note - This method exists solely to match the target argument type and suppress type checker warnings. - """ - return cast(CapturedT, self) - - @property - @abstractmethod - def value(self) -> CapturedT: - """Get the captured value. + _values: List[object] - Raises: - AssertionError: if no value was captured. - """ - - @property - @abstractmethod - def values(self) -> List[CapturedT]: - """Get all captured values.""" - - -class _Captor(ArgumentCaptor[CapturedT]): - _values: List[CapturedT] - _match_type: Type[CapturedT] - - def __init__(self, match_type: Type[CapturedT]) -> None: + def __init__(self) -> None: self._values = [] - self._match_type = match_type def __eq__(self, target: object) -> bool: - if isinstance(target, self._match_type): - self._values.append(target) - return True - return False + """Captors are always "equal" to a given target.""" + self._values.append(target) + return True def __repr__(self) -> str: """Return a string representation of the matcher.""" return "" @property - def value(self) -> CapturedT: + def matcher(self) -> CapturedT: + """Match anything, capturing its value. + + This method exists as a type-checking convenience. + """ + return cast(CapturedT, self) + + @property + def value(self) -> object: + """The latest captured value. + + Raises: + AssertionError: no value has been captured. + """ if len(self._values) == 0: raise AssertionError("No value captured by captor.") + return self._values[-1] @property - def values(self) -> List[CapturedT]: + def values(self) -> List[object]: + """All captured values.""" return self._values -MatchT = TypeVar("MatchT") - - -@overload -def Captor() -> Any: ... -@overload -def Captor(match_type: Type[MatchT]) -> ArgumentCaptor[MatchT]: ... -def Captor(match_type: Type[object] = object) -> Any: +def Captor() -> Any: """Match anything, capturing its value for further assertions. - The last captured value will be set to `captor.value`. All captured - values will be placed in the `captor.values` list, which can be - helpful if a captor needs to be triggered multiple times. - - Arguments: - match_type: Optional type to match. - - !!! example - ```python - captor = Captor() - assert "foobar" == captor.capture() - print(captor.value) # "foobar" - print(captor.values) # ["foobar"] - ``` + !!! tip + Prefer [decoy.matchers.ValueCaptor][], which has better type annotations. """ - return _Captor(match_type) + return ValueCaptor() diff --git a/docs/usage/matchers.md b/docs/usage/matchers.md index e3256f2..5910a16 100644 --- a/docs/usage/matchers.md +++ b/docs/usage/matchers.md @@ -11,12 +11,13 @@ Decoy includes the [decoy.matchers][] module, which is a set of Python classes w | [decoy.matchers.Anything][] | Matches any value that isn't `None` | | [decoy.matchers.AnythingOrNone][] | Matches any value including `None` | | [decoy.matchers.DictMatching][] | Matches a `dict` based on some of its values | +| [decoy.matchers.ListMatching][] | Matches a `list` based on some of its values | | [decoy.matchers.ErrorMatching][] | Matches an `Exception` based on its type and message | | [decoy.matchers.HasAttributes][] | Matches an object based on its attributes | | [decoy.matchers.IsA][] | Matches using `isinstance` | | [decoy.matchers.IsNot][] | Matches anything that isn't a given value | | [decoy.matchers.StringMatching][] | Matches a string against a regular expression | -| [decoy.matchers.Captor][] | Captures the comparison value (see below) | +| [decoy.matchers.ValueCaptor][] | Captures the comparison value (see below) | ## Basic usage @@ -46,7 +47,7 @@ def test_log_warning(decoy: Decoy): ## Capturing values -When testing certain APIs, especially callback APIs, it can be helpful to capture the values of arguments passed to a given dependency. For this, Decoy provides [decoy.matchers.Captor][]. +When testing certain APIs, especially callback APIs, it can be helpful to capture the values of arguments passed to a given dependency. For this, Decoy provides [decoy.matchers.ValueCaptor][]. For example, our test subject may register an event listener handler, and we want to test our subject's behavior when the event listener is triggered. @@ -62,13 +63,13 @@ from .event_consumer import EventConsumer def test_event_listener(decoy: Decoy): event_source = decoy.mock(cls=EventSource) subject = EventConsumer(event_source=event_source) - captor = matchers.Captor() + captor = matchers.ValueCaptor() # subject registers its listener when started subject.start_consuming() # verify listener attached and capture the listener - decoy.verify(event_source.register(event_listener=captor.capture())) + decoy.verify(event_source.register(event_listener=captor.matcher)) # trigger the listener event_handler = captor.value # or, equivalently, captor.values[0] @@ -78,12 +79,10 @@ def test_event_listener(decoy: Decoy): assert subject.has_heard_event is True ``` -This is a pretty verbose way of writing a test, so in general, you may want to approach using `matchers.Captor` as a form of potential code smell / test pain. There are often better ways to structure your code for these sorts of interactions that don't involve private functions. +This is a pretty verbose way of writing a test, so in general, approach using `matchers.ValueCaptor` as a form of potential code smell / test pain. There are often better ways to structure your code for these sorts of interactions that don't involve private functions. For further reading on when (or rather, when not) to use argument captors, check out [testdouble's documentation on its argument captor matcher](https://github.com/testdouble/testdouble.js/blob/main/docs/6-verifying-invocations.md#tdmatcherscaptor). -If you want to only capture values of a specific type, or you would like to have stricter type checking in your tests, consider passing a type to [decoy.matchers.Captor][] (e.g. `Captor(match_type=str)`). - ## Writing custom matchers You can write your own matcher class and use it wherever you would use a built-in matcher. All you need to do is define a class with an `__eq__` method: diff --git a/tests/test_matchers.py b/tests/test_matchers.py index 8e1a511..a0b8942 100644 --- a/tests/test_matchers.py +++ b/tests/test_matchers.py @@ -1,7 +1,7 @@ """Matcher tests.""" from collections import namedtuple -from typing import Any, List, NamedTuple +from typing import List, NamedTuple import pytest @@ -151,10 +151,18 @@ def test_error_matching_matcher() -> None: assert RuntimeError("ah!") != matchers.ErrorMatching(RuntimeError, "ah$") -def test_captor_matcher() -> None: +def test_captor_matcher_legacy() -> None: """It should have a captor matcher that captures the compared value.""" captor = matchers.Captor() - comparisons: List[Any] = [1, False, None, {}, [], ("hello", "world"), SomeClass()] + comparisons: List[object] = [ + 1, + False, + None, + {}, + [], + ("hello", "world"), + SomeClass(), + ] for i, compare in enumerate(comparisons): assert compare == captor @@ -162,16 +170,23 @@ def test_captor_matcher() -> None: assert captor.values == comparisons[0 : i + 1] -def test_captor_matcher_with_match_type() -> None: - """It should have a captor matcher that captures the compared value if it matches the type.""" - captor = matchers.Captor(int) - comparisons: List[Any] = [1, False, None, {}, [], ("hello", "world"), SomeClass()] +def test_argument_captor_matcher() -> None: + """It should have a strictly-typed value captor matcher.""" + captor = matchers.ValueCaptor[object]() + comparisons: List[object] = [ + 1, + False, + None, + {}, + [], + ("hello", "world"), + SomeClass(), + ] - for compare in comparisons: - is_equal = compare == captor.capture() - assert is_equal == isinstance(compare, int) - - assert captor.values == [1, False] + for i, compare in enumerate(comparisons): + assert compare == captor.matcher + assert captor.value is compare + assert captor.values == comparisons[0 : i + 1] def test_captor_matcher_raises_if_no_value() -> None: diff --git a/tests/typing/test_typing.yml b/tests/typing/test_typing.yml index b6e1d6d..628720d 100644 --- a/tests/typing/test_typing.yml +++ b/tests/typing/test_typing.yml @@ -143,12 +143,12 @@ main: | from decoy import matchers - captor = matchers.Captor(str) + captor = matchers.ValueCaptor[str]() - reveal_type(captor) - reveal_type(captor.capture()) + reveal_type(captor.matcher) + reveal_type(captor.value) reveal_type(captor.values) out: | - main:5: note: Revealed type is "decoy.matchers.ArgumentCaptor[builtins.str]" - main:6: note: Revealed type is "builtins.str" - main:7: note: Revealed type is "builtins.list[builtins.str]" + main:5: note: Revealed type is "builtins.str" + main:6: note: Revealed type is "builtins.object" + main:7: note: Revealed type is "builtins.list[builtins.object]"