From e07485ddd9bc24f986604a310d391a7bfa5d287e Mon Sep 17 00:00:00 2001 From: Michael Cousins Date: Tue, 3 Feb 2026 21:00:07 -0500 Subject: [PATCH] feat(v3): add improved matcher class to v3 preview --- .github/actions/setup/action.yml | 2 +- decoy/errors.py | 4 + decoy/next/__init__.py | 2 + decoy/next/_internal/errors.py | 6 + decoy/next/_internal/inspect.py | 8 + decoy/next/_internal/matcher.py | 328 ++++++++++++++++++++++++++++ docs/v3/matchers.md | 208 ++++++++++++++++++ docs/v3/migration.md | 25 ++- docs/v3/verify.md | 8 +- docs/v3/when.md | 4 +- mkdocs.yml | 1 + pyproject.toml | 3 + tests/{ => legacy}/test_matchers.py | 2 +- tests/test_matcher.py | 265 ++++++++++++++++++++++ uv.lock | 4 + 15 files changed, 861 insertions(+), 9 deletions(-) create mode 100644 decoy/next/_internal/matcher.py create mode 100644 docs/v3/matchers.md rename tests/{ => legacy}/test_matchers.py (99%) create mode 100644 tests/test_matcher.py diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml index e8022ea..cbae322 100644 --- a/.github/actions/setup/action.yml +++ b/.github/actions/setup/action.yml @@ -4,7 +4,7 @@ description: "Install development dependencies" inputs: python-version: description: "Python version to install" - default: "3.12" + default: "3.14" runs: using: "composite" diff --git a/decoy/errors.py b/decoy/errors.py index b9b2035..427480c 100644 --- a/decoy/errors.py +++ b/decoy/errors.py @@ -98,3 +98,7 @@ def create( class VerifyOrderError(VerifyError): """A [`Decoy.verify_order`][decoy.next.Decoy.verify_order] assertion failed.""" + + +class NoMatcherValueCapturedError(ValueError): + """An error raised if a [decoy.next.Matcher][] has not captured any matching values.""" diff --git a/decoy/next/__init__.py b/decoy/next/__init__.py index 7c44ddd..d2a5066 100644 --- a/decoy/next/__init__.py +++ b/decoy/next/__init__.py @@ -5,6 +5,7 @@ """ from ._internal.decoy import Decoy +from ._internal.matcher import Matcher from ._internal.mock import AsyncMock, Mock from ._internal.verify import Verify from ._internal.when import Stub, When @@ -12,6 +13,7 @@ __all__ = [ "AsyncMock", "Decoy", + "Matcher", "Mock", "Stub", "Verify", diff --git a/decoy/next/_internal/errors.py b/decoy/next/_internal/errors.py index 5893884..fdf90ae 100644 --- a/decoy/next/_internal/errors.py +++ b/decoy/next/_internal/errors.py @@ -95,3 +95,9 @@ def createVerifyOrderError( ) return errors.VerifyOrderError(message) + + +def createNoMatcherValueCapturedError( + message: str, +) -> errors.NoMatcherValueCapturedError: + return errors.NoMatcherValueCapturedError(message) diff --git a/decoy/next/_internal/inspect.py b/decoy/next/_internal/inspect.py index ced35e3..3bda2ba 100644 --- a/decoy/next/_internal/inspect.py +++ b/decoy/next/_internal/inspect.py @@ -163,6 +163,14 @@ def bind_args( return BoundArguments(bound_args.args, bound_args.kwargs) +def get_func_name(func: Callable[..., object]) -> str: + """Get the name of a function.""" + if isinstance(func, functools.partial): + return func.func.__name__ + + return func.__name__ + + def _unwrap_callable(value: object) -> Callable[..., object] | None: """Return an object's callable, checking if a class has a `__call__` method.""" if not callable(value): diff --git a/decoy/next/_internal/matcher.py b/decoy/next/_internal/matcher.py new file mode 100644 index 0000000..b2cc4a9 --- /dev/null +++ b/decoy/next/_internal/matcher.py @@ -0,0 +1,328 @@ +import collections.abc +import functools +import re +import sys +from typing import Any, Callable, Generic, TypeVar, cast, overload + +if sys.version_info >= (3, 13): + from typing import TypeIs +else: + from typing_extensions import TypeIs + +from .errors import createNoMatcherValueCapturedError +from .inspect import get_func_name + +ValueT = TypeVar("ValueT") +MatchT = TypeVar("MatchT") +MappingT = TypeVar("MappingT", bound=collections.abc.Mapping[Any, Any]) +SequenceT = TypeVar("SequenceT", bound=collections.abc.Sequence[Any]) +ErrorT = TypeVar("ErrorT", bound=BaseException) + +TypedMatch = Callable[[object], TypeIs[MatchT]] +UntypedMatch = Callable[[object], bool] + + +class Matcher(Generic[ValueT]): + """Create an [argument matcher](./matchers.md). + + Arguments: + match: A comparison function that returns a bool or `TypeIs` guard. + name: Optional name for the matcher; defaults to `match.__name__` + description: Optional extra description for the matcher's repr. + + Example: + Use a function to create a custom matcher. + + ```python + def is_even(target: object) -> TypeIs[int]: + return isinstance(target, int) and target % 2 == 0 + + is_even_matcher = Matcher(is_even) + ``` + + Matchers can also be constructed from built-in inspection functions, like `callable`. + + ```python + callable_matcher = Matcher(callable) + ``` + """ + + @overload + def __init__( + self: "Matcher[MatchT]", + match: TypedMatch[MatchT], + name: str | None = None, + description: str | None = None, + ) -> None: ... + + @overload + def __init__( + self: "Matcher[Any]", + match: UntypedMatch, + name: str | None = None, + description: str | None = None, + ) -> None: ... + + def __init__( + self, + match: TypedMatch[ValueT] | UntypedMatch, + name: str | None = None, + description: str | None = None, + ) -> None: + self._match = match + self._name = name or get_func_name(match) + self._description = description + self._values: list[ValueT] = [] + + def __eq__(self, target: object) -> bool: + if self._match(target): + self._values.append(cast(ValueT, target)) # type: ignore[redundant-cast] + return True + + return False + + def __repr__(self) -> str: + matcher_name = f"Matcher.{self._name}" + if self._description: + return f"<{matcher_name} {self._description.strip()}>" + + return f"<{matcher_name}>" + + @property + def arg(self) -> ValueT: + """Type-cast the matcher as the expected value. + + Example: + If the mock expects a `str` argument, using `arg` prevents the type-checker from raising an error. + + ```python + decoy + .when(mock) + .called_with(Matcher.matches("^(hello|hi)$").arg) + .then_return("world") + ``` + """ + return cast(ValueT, self) + + @property + def value(self) -> ValueT: + """The latest matching compared value. + + Raises: + NoMatcherValueCapturedError: the matcher has not been compared with any matching value. + + Example: + You can use `value` to trigger a callback passed to your mock. + + ```python + callback_matcher = Matcher(callable) + decoy.verify(mock).called_with(callback_matcher) + callback_matcher.value("value") + ``` + """ + if len(self._values) == 0: + raise createNoMatcherValueCapturedError( + f"{self} has not matched any values" + ) + + return self._values[-1] + + @property + def values(self) -> list[ValueT]: + """All matching compared values.""" + return self._values.copy() + + @overload + @staticmethod + def any( + type: type[MatchT], + attrs: collections.abc.Mapping[str, object] | None = None, + ) -> "Matcher[MatchT]": ... + + @overload + @staticmethod + def any( + type: None = None, + attrs: collections.abc.Mapping[str, object] | None = None, + ) -> "Matcher[Any]": ... + + @staticmethod + def any( + type: type[MatchT] | None = None, + attrs: collections.abc.Mapping[str, object] | None = None, + ) -> "Matcher[MatchT] | Matcher[Any]": + """Match an argument, optionally by type and/or attributes. + + If type and attributes are omitted, will match everything, + including `None`. + + Arguments: + type: Type to match, if any. + attrs: Set of attributes to match, if any. + """ + description = "" + + if type: + description = type.__name__ + + if attrs: + description = f"{description} attrs={attrs!r}" + + return Matcher( + match=functools.partial(any, type, attrs), + description=description, + ) + + @staticmethod + def is_not(value: object) -> "Matcher[Any]": + """Match any value that does not `==` the given value. + + Arguments: + value: The value that the matcher rejects. + """ + return Matcher( + lambda t: t != value, + name="is_not", + description=repr(value), + ) + + @overload + @staticmethod + def contains(values: MappingT) -> "Matcher[MappingT]": ... + + @overload + @staticmethod + def contains(values: SequenceT, in_order: bool = False) -> "Matcher[SequenceT]": ... + + @staticmethod + def contains( + values: MappingT | SequenceT, + in_order: bool = False, + ) -> "Matcher[MappingT] | Matcher[SequenceT]": + """Match a dict, list, or string with a partial value. + + Arguments: + values: Partial value to match. + in_order: Match list values in order. + """ + description = repr(values) + + if in_order: + description = f"{description} in_order={in_order}" + + return Matcher( + match=functools.partial(contains, values, in_order), + description=description, + ) + + @staticmethod + def matches(pattern: str) -> "Matcher[str]": + """Match a string by a pattern. + + Arguments: + pattern: Regular expression pattern. + """ + pattern_re = re.compile(pattern) + + return Matcher( + match=functools.partial(matches, pattern_re), + description=repr(pattern), + ) + + @staticmethod + def error(type: type[ErrorT], message: str | None = None) -> "Matcher[ErrorT]": + """Match an exception object. + + Arguments: + type: The type of exception to match. + message: An optional regular expression pattern to match. + """ + message_re = re.compile(message or "") + description = type.__name__ + + if message: + description = f"{description} message={message!r}" + + return Matcher( + match=functools.partial(error, type, message_re), + name="error", + description=description, + ) + + +def any( + match_type: type[Any] | None, + attrs: collections.abc.Mapping[str, object] | None, + target: object, +) -> bool: + return (match_type is None or isinstance(target, match_type)) and ( + attrs is None or _has_attrs(attrs, target) + ) + + +def _has_attrs( + attributes: collections.abc.Mapping[str, object], + target: object, +) -> bool: + return all( + hasattr(target, attr_name) and getattr(target, attr_name) == attr_value + for attr_name, attr_value in attributes.items() + ) + + +def contains( + values: collections.abc.Mapping[object, object] | collections.abc.Sequence[object], + in_order: bool, + target: object, +) -> bool: + if isinstance(values, collections.abc.Mapping): + return _dict_containing(values, target) + if isinstance(values, str): + return isinstance(target, str) and values in target + + return _list_containing(values, in_order, target) + + +def _dict_containing( + values: collections.abc.Mapping[object, object], + target: object, +) -> bool: + try: + return all( + attr_name in target and target[attr_name] == attr_value # type: ignore[index,operator] + for attr_name, attr_value in values.items() + ) + except TypeError: + return False + + +def _list_containing( + values: collections.abc.Sequence[object], + in_order: bool, + target: object, +) -> bool: + target_index = 0 + + try: + for value in values: + if in_order: + target = target[target_index:] # type: ignore[index] + + target_index = target.index(value) # type: ignore[attr-defined] + + except (AttributeError, TypeError, ValueError): + return False + + return True + + +def error( + type: type[ErrorT], + message_pattern: re.Pattern[str], + target: object, +) -> bool: + return isinstance(target, type) and message_pattern.search(str(target)) is not None + + +def matches(pattern: re.Pattern[str], target: object) -> bool: + return isinstance(target, str) and pattern.search(target) is not None diff --git a/docs/v3/matchers.md b/docs/v3/matchers.md new file mode 100644 index 0000000..6194177 --- /dev/null +++ b/docs/v3/matchers.md @@ -0,0 +1,208 @@ +# Argument matchers + +Sometimes, you may not care _exactly_ how a mock is called. For example, you may want to assert that a dependency is called with a string, but you don't care about the full contents of that string. + +In Decoy, you can use a [`Matcher`][decoy.next.Matcher] in place of an actual argument value in [`when`][when-matcher-guide] and [`verify`][verify-matcher-guide] to "loosen" the match. + +[when-matcher-guide]: ./when.md#loosen-constraints-with-matchers +[verify-matcher-guide]: ./verify.md#loosen-constraints-with-matchers + +## Available matchers + +| Matcher | Description | +| ------------------------------------------------- | ------------------------------------------------------ | +| [`Matcher`][decoy.next.Matcher] | Match based on a comparison function. | +| [`Matcher.any`][decoy.next.Matcher.any] | Match any value, optionally by type and/or attributes. | +| [`Matcher.contains`][decoy.next.Matcher.contains] | Match a `list`, `dict`, or `str` based its contents. | +| [`Matcher.error`][decoy.next.Matcher.error] | Match an `Exception` based on its type and message. | +| [`Matcher.is_not`][decoy.next.Matcher.is_not] | Match anything that isn't a given value. | +| [`Matcher.matches`][decoy.next.Matcher.matches] | Match a string against a regular expression. | + +## Basic usage + +Use the matcher instance wherever you would normally use a value. If you use static type checking, use [`Matcher.arg`][decoy.next.Matcher.arg], which type-casts the matcher as the expected type. + +```python +from decoy.next import Decoy, Matcher + +from .logger import Logger +from .my_thing import MyThing + +def test_log_warning(decoy: Decoy): + logger = decoy.mock(cls=Logger) + subject = MyThing(logger=logger) + + subject.log_warning("Oh no, something went wrong with request abc123") + + decoy + .verify(logger.warn) + .called_with(Matcher.contains("abc123").arg) +``` + +A `Matcher` can also be used standalone, in an `assert`. + +```python +assert "hello world" == Matcher.contains("hello") +``` + +### `Matcher.any` + +Match any value, including `None`. + +```python +any_matcher = Matcher.any() # type: Matcher[Any] + +assert "hello world" == any_matcher +assert 42 == any_matcher +assert None == any_matcher +``` + +You can scope down the matcher with an `isinstance` type. This will narrow `arg` and `value` to the passed type. + +```python +str_matcher = Matcher.any(str) # type: Matcher[str] + +assert "hello world" == any_matcher +assert 42 != any_matcher +assert None != any_matcher +``` + +You can also scope the matcher to "anything with the given attributes." + +```python +attrs_matcher = Matcher.any(attrs={"hello": "world"}) # type: Matcher[Any] +``` + +### `Matcher.contains` + +Match mappings and sequences (including strings) that contain some values. + +```python +assert {"hello": "world", "hola": "mundo"} == Matcher.contains({"hello": "world"}) +assert ["hello", "world", "hola", "mundo"] == Matcher.contains(["hello", "world"]) +assert "hello, world - hola, mundo" == Matcher.contains("hello, world") +``` + +When checking non-string sequences, you can specify that the values should appear in order. + +```python +in_order_matcher = Matcher.contains(["hello", "world"], in_order=True) + +assert ["hello", "world"] == in_order_matcher +assert ["world", "hello"] != in_order_matcher +``` + +### `Matcher.error` + +### `Matcher.is_not` + +Negate a match. For example, you may want to check that a value is simply anything except `None`. + +```python +something_matcher = Matcher.is_not(None) # type: Matcher[Any] + +assert "hello world" == something_matcher +assert 42 == something_matcher +assert None != something_matcher +``` + +### `Matcher.matches` + +Match a string against a regex pattern. + +```python +hello_matcher = Matcher.matches("^hello") # type: Matcher[str] + +assert "hello world" == hello_matcher +assert "goodnight moon" != hello_matcher +``` + +## Capturing values + +Sometimes, you need access to the actual values of arguments passed to a dependency. For this purpose, all matchers will capture any values that they are successfully compared with, available via [decoy.next.Matcher.value][] and [decoy.next.Matcher.values][]. + +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. + +```python +from decoy.next import Decoy, Matcher + +from .event_source import EventSource +from .event_consumer import EventConsumer + + +def test_event_listener(decoy: Decoy): + event_source = decoy.mock(cls=EventSource) + subject = EventConsumer(event_source=event_source) + event_listener_matcher = Matcher(callable) + + # subject registers its listener when started + subject.start_consuming() + + # verify listener attached and capture the listener + decoy.verify(event_source.add_listener).called_with(event_listener_matcher.arg) + + # trigger the listener + assert subject.has_heard_event is False + event_listener_matcher.value() + assert subject.has_heard_event is True +``` + +These "two stage" tests can become pretty verbose, so in general, approach using matcher-captured values 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). + +## Custom matchers + +Use the base [`Matcher`][decoy.next.Matcher] class to create custom matchers. Pass `Matcher` a comparison function, and it will match any value that passes that function. + +```python +def is_odd_int(target: object) -> bool: + return isinstance(target, int) and target % 2 == 1 + +is_odd_matcher = Matcher(is_odd_int) # type: Matcher[Any] + +assert 1 == is_odd_matcher +assert 2 != is_odd_matcher +``` + +If you define your comparison function with [`TypeIs`](https://typing.python.org/en/latest/spec/narrowing.html#typeis), the `Matcher` will be narrowed to the appropriate type. + +```python +def is_odd_int(target: object) -> TypeIs[int]: + return isinstance(target, int) and target % 2 == 1 + +is_odd_matcher = Matcher(is_odd_int) # type: Matcher[int] + +assert_type(Matcher(is_odd_int), Matcher[int]) +assert_type(is_odd_matcher.arg, int) +assert_type(is_odd_matcher.value, int) +``` + +This is especially useful with built-in inspection functions, like `callable`, which also return `TypeIs` guards. + +```python +func_matcher = Matcher(callable) # type: Matcher[Callable[..., object]] +``` + +### Custom matcher example + +Custom matchers can be helpful when the value objects you are using as arguments are difficult to compare and out of your control. For example, Pandas [DataFrame][] objects do not return a `bool` from `__eq__`, which makes it difficult to compare calls. We can define a `data_frame_matcher` to work around this. + +```python +import functools +import TypeIs from typing +import pandas as pd +from decoy import Matcher + +def matchDataFrame(expected_data: object, target: object) -> TypeIs[pd.DataFrame]: + return pd.DataFrame(expected_data).equals(target) + +check_data = decoy.mock(name="check_data") +data_frame_matcher = Matcher(match=partial(matchDataFrame, {"x1": range(1, 42)})) + +check_data(pd.DataFrame({"x1": range(1, 42)})) + +decoy + .verify(check_answer) + .called_with(data_frame_matcher.arg) +``` + +[DataFrame]: https://pandas.pydata.org/pandas-docs/stable/reference/api/pandas.DataFrame.html diff --git a/docs/v3/migration.md b/docs/v3/migration.md index c1dcdd5..4d53b5a 100644 --- a/docs/v3/migration.md +++ b/docs/v3/migration.md @@ -1,6 +1,6 @@ # Migrate to v3 -Recommended migration: +Recommended migration from v2: 1. Upgrade to `decoy>=2.4.0 <3`. 2. Incrementally migrate to the new API using `decoy.next`. @@ -91,6 +91,29 @@ Using `called_with` in Decoy v3, it is no longer necessary to add `await` to `wh + decoy.verify(mock).called_with("hello") ``` +## Matchers + +Matchers have been reworked to be more type-safe and easier to extend. See the [`Matcher` guide][matcher-guide] for more details. + +```diff +- from decoy import Decoy, matchers ++ from decoy.next import Decoy, Matcher +``` + +| v2 | v3 | +| ------------------------- | ---------------------------------------------------- | +| `matchers.AnythingOrNone` | [`Matcher.any`][decoy.next.Matcher.any] | +| `matchers.HasAttributes` | [`Matcher.any(attrs=attrs)`][decoy.next.Matcher.any] | +| `matchers.IsA` | [`Matcher.any(type=type)`][decoy.next.Matcher.any] | +| `matchers.DictMatching` | [`Matcher.contains`][decoy.next.Matcher.contains] | +| `matchers.ListMatching` | [`Matcher.contains`][decoy.next.Matcher.contains] | +| `matchers.ErrorMatching` | [`Matcher.error`][decoy.next.Matcher.error] | +| `matchers.IsNot` | [`Matcher.is_not`][decoy.next.Matcher.is_not] | +| `matchers.Anything` | [`Matcher.is_not(None)`][decoy.next.Matcher.is_not] | +| `matchers.StringMatching` | [`Matcher.matches`][decoy.next.Matcher.matches] | +| `matchers.ValueCaptor` | Any other matcher; all matchers are now captors | +| Custom matchers | [`Matcher`][decoy.next.Matcher] + match function | + ## Attributes The `decoy.prop` API has been replaced. See the [attributes guide][attributes-guide] for more details. diff --git a/docs/v3/verify.md b/docs/v3/verify.md index d0fdf5a..6302212 100644 --- a/docs/v3/verify.md +++ b/docs/v3/verify.md @@ -62,22 +62,22 @@ decoy ## Loosen constraints with matchers -You may loosen rehearsal constraints using [`matchers`][decoy.matchers]. See the [matchers usage guide](../usage/matchers.md) for more information. +You may loosen rehearsal constraints using [`Matchers`][decoy.next.Matcher]. See the [argument matchers guide](./matchers.md) for more information. ```python say_hello = decoy.mock(name="say_hello") say_hello("foobar") -decoy.verify(say_hello).called_with(matchers.StringMatching("^foo")) +decoy.verify(say_hello).called_with(Matcher.matches("^foo").arg) with pytest.raises(): - decoy.verify(say_hello).called_with(matchers.StringMatching("^bar")) + decoy.verify(say_hello).called_with(Matcher.matches("^bar").arg) ``` ## Verify order of multiple calls -If your code under test must call several dependencies in order, use [`Decoy.verify_order`][decoy.next.Decoy.verify_order]. Decoy will search through the list of all calls made to the given spies and look for a matching ordered call sequence. +If your code under test must call several dependencies in order, use [`Decoy.verify_order`][decoy.next.Decoy.verify_order]. Decoy will search through the list of all calls made to the given mocks and look for a matching ordered call sequence. ```python with decoy.verify_order(): diff --git a/docs/v3/when.md b/docs/v3/when.md index 99e4d2e..96ae731 100644 --- a/docs/v3/when.md +++ b/docs/v3/when.md @@ -96,14 +96,14 @@ assert database.get("foo") == {id: "foo"} # also prints "hello foo" ## Loosen constraints with matchers -You may loosen `called_with` constraints using [`matchers`][decoy.matchers]. See the [matchers usage guide](../usage/matchers.md) for more information. +You may loosen `called_with` constraints using [`Matcher`][decoy.next.Matcher]. See the [argument matchers guide](./matchers.md) for more information. ```python say_hello = decoy.mock(name="say_hello") decoy .when(say_hello) - .called_with(matchers.StringMatching("^foo")) + .called_with(Matcher.matches("^foo").arg) .then_return("hello") assert say_hello("foo") == "hello" diff --git a/mkdocs.yml b/mkdocs.yml index ee5f584..c3e5d51 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -14,6 +14,7 @@ nav: - v3/create.md - v3/when.md - v3/verify.md + - v3/matchers.md - v3/attributes.md - v3/context-managers.md - v3/api.md diff --git a/pyproject.toml b/pyproject.toml index f57ea76..5ec4ee3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,6 +16,9 @@ classifiers = [ "Topic :: Software Development :: Testing :: Mocking", "Typing :: Typed", ] +dependencies = [ + "typing-extensions>=4.10.0 ; python_version >= '3.10' and python_version < '3.13'", +] [project.urls] Homepage = "https://michael.cousins.io/decoy/" diff --git a/tests/test_matchers.py b/tests/legacy/test_matchers.py similarity index 99% rename from tests/test_matchers.py rename to tests/legacy/test_matchers.py index 1944175..c7f267c 100644 --- a/tests/test_matchers.py +++ b/tests/legacy/test_matchers.py @@ -7,7 +7,7 @@ from decoy import Decoy, matchers -from .fixtures import SomeClass +from ..fixtures import SomeClass class _HelloClass(NamedTuple): diff --git a/tests/test_matcher.py b/tests/test_matcher.py new file mode 100644 index 0000000..38166ac --- /dev/null +++ b/tests/test_matcher.py @@ -0,0 +1,265 @@ +"""Matcher tests.""" + +from __future__ import annotations + +import collections.abc +import dataclasses +import sys +from typing import Any, Callable, NamedTuple + +import pytest + +from decoy import errors + +from . import fixtures + +if sys.version_info >= (3, 10): + from decoy.next import Decoy, Matcher + + if sys.version_info >= (3, 11): + from typing import assert_type + else: + from typing_extensions import assert_type + + if sys.version_info >= (3, 13): + from typing import TypeIs + else: + from typing_extensions import TypeIs + + +pytestmark = pytest.mark.skipif( + sys.version_info < (3, 10), + reason="v3 preview only supports Python >= 3.10", +) + + +@pytest.fixture() +def decoy() -> collections.abc.Iterator[Decoy]: + """Create a Decoy instance for testing.""" + with Decoy.create() as decoy: + yield decoy + + +def test_matcher() -> None: + """It matches based on a TypeIs function.""" + + def _is_str(target: object) -> TypeIs[str]: + return isinstance(target, str) + + subject = Matcher(_is_str) + + assert "hello" == subject + assert 42 != subject + assert str(subject) == "" + assert_type(subject, Matcher[str]) + + +def test_matcher_inspect() -> None: + """It matches based on a built-in inspection functions.""" + subject = Matcher(callable) + + assert fixtures.some_func == subject + assert 42 != subject + assert str(subject) == "" + assert_type(subject, Matcher[Callable[..., object]]) + + +def test_matcher_lambda() -> None: + """It matches based on a lambda function.""" + subject = Matcher(lambda x: isinstance(x, int) and x % 2 == 0, name="is_even") + + assert 2 == subject + assert 1 != subject + assert str(subject) == "" + assert_type(subject, Matcher[Any]) + + +@pytest.mark.filterwarnings("ignore::decoy.warnings.MiscalledStubWarning") +def test_matcher_arg(decoy: Decoy) -> None: + """It matches when used in called_with.""" + subject = decoy.mock(cls=fixtures.SomeClass) + + decoy.when(subject.foo).called_with(Matcher.any(str).arg).then_return("yay") + + assert subject.foo("hello") == "yay" + assert subject.foo(42) is None # type: ignore[arg-type] + + +def test_matcher_capture() -> None: + """It captures matching values.""" + subject = Matcher.any(str) + + assert "hello" == subject + assert subject.value == "hello" + assert subject.values == ["hello"] + + assert "world" == subject + assert subject.value == "world" + assert subject.values == ["hello", "world"] + + +def test_matcher_no_capture() -> None: + """It throws an error if no captured value.""" + subject = Matcher.any(str) + + with pytest.raises(errors.NoMatcherValueCapturedError, match="has not matched"): + _ = subject.value + + assert 42 != subject + + with pytest.raises(errors.NoMatcherValueCapturedError): + _ = subject.value + + +def test_any() -> None: + """It matches everything including None.""" + subject = Matcher.any() + + assert "hello" == subject + assert None == subject # noqa: E711 + assert str(subject) == "" + assert_type(subject, Matcher[Any]) + + +def test_any_isinstance() -> None: + """It matches any instance.""" + subject = Matcher.any(str) + + assert "hello" == subject + assert None != subject # noqa: E711 + assert str(subject) == "" + assert_type(subject, Matcher[str]) + + +def test_any_attributes() -> None: + """It matches a partial attributes.""" + + @dataclasses.dataclass + class _TargetClass: + hello: str + + class _TargetTuple(NamedTuple): + hello: str + + subject = Matcher.any(attrs={"hello": "world"}) + + assert _TargetClass(hello="world") == subject + assert _TargetTuple(hello="world") == subject + assert _TargetClass(hello="goodbye") != subject + assert _TargetTuple(hello="goodbye") != subject + assert str(subject) == "" + assert_type(subject, Matcher[Any]) + + +def test_any_isinstance_attributes() -> None: + """It matches an instance and partial attributes.""" + + @dataclasses.dataclass + class _TargetClass: + hello: str + + class _TargetTuple(NamedTuple): + hello: str + + subject = Matcher.any(_TargetClass, {"hello": "world"}) + + assert _TargetClass(hello="world") == subject + assert _TargetTuple(hello="world") != subject + assert _TargetClass(hello="goodbye") != subject + assert _TargetTuple(hello="goodbye") != subject + assert str(subject) == "" + assert_type(subject, Matcher[_TargetClass]) + + +def test_is_not() -> None: + """It negates a match.""" + subject = Matcher.is_not("hello") + + assert "hello" != subject + assert "goodbye" == subject + assert str(subject) == "" + + +def test_contains_dict() -> None: + """It matches a dict containing the given values.""" + subject = Matcher.contains({"hello": "world", 42: True}) + + assert {"hello": "world", 42: True} == subject + assert {"hello": "world", "hola": "mundo", 42: True} == subject + assert {} != subject + assert {"hello": "world"} != subject + assert {42: True} != subject + assert "hello" != subject + assert str(subject) == "" + assert_type(subject, Matcher[dict[object, object]]) # pyright: ignore[reportAssertTypeFailure] + + +def test_contains_list() -> None: + """It matches a list containing the given values.""" + subject = Matcher.contains([1, 2, 3]) + + assert [1, 2, 3] == subject + assert [0, 1, 2, 3, 4] == subject + assert [3, 2, 1] == subject + assert [1, 2] != subject + assert 1 != subject + assert str(subject) == "" + assert_type(subject, Matcher[list[int]]) + + +def test_contains_list_in_order() -> None: + """It matches a list containing the given values in order.""" + subject = Matcher.contains([1, 2], in_order=True) + + assert [1, 2] == subject + assert [0, 1, 2, 3] == subject + assert [1, 3, 2] == subject + assert [2, 1] != subject + assert 1 != subject + + assert str(subject) == "" + + +def test_contains_substring() -> None: + """It matches a substring.""" + subject = Matcher.contains("ell") + + assert "ell" == subject + assert "hello" == subject + assert "holle" != subject + assert 1 != subject + + assert str(subject) == "" + assert_type(subject, Matcher[str]) + + +def test_matches() -> None: + """It matches strings by regex.""" + subject = Matcher.matches("ello$") + + assert "hello" == subject + assert "hello!" != subject + assert 42 != subject + assert str(subject) == "" + assert_type(subject, Matcher[str]) + + +def test_error_type() -> None: + """It matches an exception type.""" + subject = Matcher.error(RuntimeError) + + assert RuntimeError("oh no") == subject + assert TypeError("oh no") != subject + assert "oh no" != subject + assert str(subject) == "" + assert_type(subject, Matcher[RuntimeError]) + + +def test_error_type_and_message() -> None: + """It matches an exception type and message.""" + subject = Matcher.error(RuntimeError, message="^oh") + + assert RuntimeError("oh no") == subject + assert RuntimeError("oh canada") == subject + assert TypeError("oh no") != subject + assert str(subject) == "" diff --git a/uv.lock b/uv.lock index e40a42e..c08722f 100644 --- a/uv.lock +++ b/uv.lock @@ -481,6 +481,9 @@ wheels = [ name = "decoy" version = "2.3.0" source = { editable = "." } +dependencies = [ + { name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10' and python_full_version < '3.13'" }, +] [package.dev-dependencies] dev = [ @@ -509,6 +512,7 @@ dev = [ ] [package.metadata] +requires-dist = [{ name = "typing-extensions", marker = "python_full_version >= '3.10' and python_full_version < '3.13'", specifier = ">=4.10.0" }] [package.metadata.requires-dev] dev = [