From 156f146257a74aa48e3b90e26e0d04fbd4d3a7ff Mon Sep 17 00:00:00 2001 From: Michael Cousins Date: Thu, 5 Feb 2026 11:08:46 -0500 Subject: [PATCH] feat(pytest): use v3 API in decoy fixture if annotated --- decoy/pytest_plugin.py | 35 ++++++++++++++++++--- docs/v3/about.md | 8 ----- docs/v3/migration.md | 20 ++++++------ pyproject.toml | 2 ++ tests/test_matcher.py | 8 ----- tests/test_mock.py | 8 ----- tests/test_pytest_plugin.py | 62 +++++++++++++++++++++++++++++++++++-- tests/test_verify.py | 8 ----- tests/test_when.py | 7 ----- 9 files changed, 100 insertions(+), 58 deletions(-) diff --git a/decoy/pytest_plugin.py b/decoy/pytest_plugin.py index 43a3cc1..81c9d60 100644 --- a/decoy/pytest_plugin.py +++ b/decoy/pytest_plugin.py @@ -5,21 +5,32 @@ but highly recommended. """ -from typing import Iterable +from typing import TYPE_CHECKING, Iterable, Union, get_type_hints import pytest from decoy import Decoy +if TYPE_CHECKING: + from decoy.next import Decoy as DecoyNext + @pytest.fixture() -def decoy() -> Iterable[Decoy]: +def decoy( + request: pytest.FixtureRequest, +) -> "Iterable[Union[Decoy, DecoyNext]]": """Get a [decoy.Decoy][] container and [reset it][decoy.Decoy.reset] after the test. This function is function-scoped [pytest fixture][] that will be automatically inserted by the plugin. + !!! tip + + This fixture will automatically opt-into the [v3 preview API][v3-preview] + if annotated with `decoy.next.Decoy`. + [pytest fixture]: https://docs.pytest.org/en/latest/how-to/fixtures.html + [v3-preview]: ./v3/about.md !!! example ```python @@ -28,6 +39,20 @@ def test_my_thing(decoy: Decoy) -> None: # ... ``` """ - decoy = Decoy() - yield decoy - decoy.reset() + try: + decoy_hint = get_type_hints(request.function).get("decoy") + is_next = decoy_hint.__module__.startswith("decoy.next") + + # purely defensive, probably won't ever raise + except Exception: # pragma: no cover + is_next = False + + if is_next: + from decoy.next import Decoy as DecoyNext + + with DecoyNext.create() as decoy_next: + yield decoy_next + else: + decoy = Decoy() + yield decoy + decoy.reset() diff --git a/docs/v3/about.md b/docs/v3/about.md index 1e009a0..d1ac85a 100644 --- a/docs/v3/about.md +++ b/docs/v3/about.md @@ -28,14 +28,6 @@ Then, start trying out the new API! - from decoy import Decoy + from decoy.next import Decoy - -+ @pytest.fixture() -+ def decoy() -> collections.abc.Iterator[Decoy]: -+ """Create a Decoy v3 preview instance for testing.""" -+ with Decoy.create() as decoy: -+ yield decoy - - def test_when(decoy: Decoy) -> None: mock = decoy.mock(cls=SomeClass) - decoy.when(mock.foo("hello")).then_return("world") diff --git a/docs/v3/migration.md b/docs/v3/migration.md index 4d53b5a..cf8ebee 100644 --- a/docs/v3/migration.md +++ b/docs/v3/migration.md @@ -12,19 +12,16 @@ Recommended migration from v2: ## Setup -For an incremental migration, you can set up a replacement `decoy` fixture wherever tests are being migrated: +For an incremental migration, annotate a test's `decoy` fixture as `decoy.next.Decoy` to automatically opt-in that test to the preview API. -```python -import collections.abc -import pytest - -from decoy.next import Decoy +```diff +- from decoy import Decoy ++ from decoy.next import Decoy -@pytest.fixture() -def decoy() -> collections.abc.Iterator[Decoy]: - """Create a Decoy instance for testing.""" - with Decoy.create() as decoy: - yield decoy + def test_when(decoy: Decoy) -> None: + mock = decoy.mock(cls=SomeClass) +- decoy.when(mock.foo("hello")).then_return("world") ++ decoy.when(mock.foo).called_with("hello").then_return("world") ``` ## When @@ -189,6 +186,7 @@ In v3, `__enter__` and `__exit__` can still be stubbed to test advanced context ## Other breaking changes +- The `mypy` plugin is no longer needed and will be removed - [`Decoy.mock`][decoy.next.Decoy.mock] is more strict about its arguments, and will raise [`MockSpecInvalidError`][decoy.errors.MockSpecInvalidError] if passed an invalid `spec` value. - [`IncorrectCallWarning`][decoy.warnings.IncorrectCallWarning] has been upgraded to an error: [`SignatureMismatchError`][decoy.errors.SignatureMismatchError]. - Some "public" attributes have been removed from error classes. diff --git a/pyproject.toml b/pyproject.toml index b4b3bab..2ddf681 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -77,11 +77,13 @@ test = "pytest -f" test-once = "coverage run --branch --source=decoy -m pytest --mypy-same-process" coverage = "coverage report" coverage-xml = "coverage xml" +coverage-html = "coverage html" docs = "mkdocs serve --livereload" build-docs = "mkdocs build" build-package = "uv build" check-ci = ["check", "lint", "format-check"] test-ci = ["test-once", "coverage-xml"] +test-coverage = ["test-once", "coverage-html", "coverage"] [tool.pytest.ini_options] addopts = "--color=yes --mypy-ini-file=tests/legacy/typing/mypy.ini --mypy-only-local-stub" diff --git a/tests/test_matcher.py b/tests/test_matcher.py index 38166ac..e518dc3 100644 --- a/tests/test_matcher.py +++ b/tests/test_matcher.py @@ -2,7 +2,6 @@ from __future__ import annotations -import collections.abc import dataclasses import sys from typing import Any, Callable, NamedTuple @@ -33,13 +32,6 @@ ) -@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.""" diff --git a/tests/test_mock.py b/tests/test_mock.py index b931ff3..fe5de5e 100644 --- a/tests/test_mock.py +++ b/tests/test_mock.py @@ -2,7 +2,6 @@ from __future__ import annotations -import collections.abc import inspect import sys from typing import Any @@ -22,13 +21,6 @@ ) -@pytest.fixture() -def decoy() -> collections.abc.Iterator[Decoy]: - """Create a Decoy instance for testing.""" - with Decoy.create() as decoy: - yield decoy - - def test_create_mock(decoy: Decoy) -> None: """It creates a callable mock that no-ops.""" subject = decoy.mock(name="alice") diff --git a/tests/test_pytest_plugin.py b/tests/test_pytest_plugin.py index bd2a388..3f6e4bc 100644 --- a/tests/test_pytest_plugin.py +++ b/tests/test_pytest_plugin.py @@ -1,11 +1,12 @@ """Tests for Decoy's pytest plugin.""" +import sys + import pytest def test_pytest_decoy_fixture(testdir: pytest.Testdir) -> None: - """It should add a decoy test fixture.""" - # create a temporary pytest test file + """It adds a decoy test fixture.""" testdir.makepyfile( """ from decoy import Decoy @@ -17,5 +18,60 @@ def test_decoy(decoy): result = testdir.runpytest_subprocess() - # check that all 4 tests passed + result.assert_outcomes(passed=1) + + +@pytest.mark.skipif(sys.version_info < (3, 10), reason="v3 API required Python >= 3.10") +def test_pytest_decoy_next_fixture(testdir: pytest.Testdir) -> None: + """It opts into the decoy.next API via annotation.""" + testdir.makepyfile( + """ + from decoy.next import Decoy + + def test_decoy(decoy: Decoy): + assert isinstance(decoy, Decoy) + """ + ) + + result = testdir.runpytest_subprocess() + + result.assert_outcomes(passed=1) + + +@pytest.mark.skipif(sys.version_info < (3, 10), reason="v3 API required Python >= 3.10") +def test_pytest_decoy_next_fixture_fallback(testdir: pytest.Testdir) -> None: + """It falls back to v2 if annotations are messed up.""" + testdir.makepyfile( + """ + from decoy import Decoy + + def test_decoy(decoy: 'Oops'): + assert isinstance(decoy, Decoy) + """ + ) + + result = testdir.runpytest_subprocess() + + result.assert_outcomes(passed=1) + + +@pytest.mark.skipif(sys.version_info < (3, 10), reason="v3 API required Python >= 3.10") +def test_pytest_decoy_next_fixture_nesting(testdir: pytest.Testdir) -> None: + """It opts into the decoy.next API via annotation for derived fixtures.""" + testdir.makepyfile( + """ + import pytest + from decoy.next import Decoy, Mock + + @pytest.fixture() + def mock(decoy: Decoy): + return decoy.mock(name="mock") + + def test_decoy(decoy: Decoy, mock: Mock): + assert isinstance(mock, Mock) + """ + ) + + result = testdir.runpytest_subprocess() + result.assert_outcomes(passed=1) diff --git a/tests/test_verify.py b/tests/test_verify.py index 6b4e7a0..b164218 100644 --- a/tests/test_verify.py +++ b/tests/test_verify.py @@ -2,7 +2,6 @@ from __future__ import annotations -import collections.abc import os import sys @@ -22,13 +21,6 @@ ) -@pytest.fixture() -def decoy() -> collections.abc.Iterator[Decoy]: - """Create a Decoy instance for testing.""" - with Decoy.create() as decoy: - yield decoy - - def test_verify(decoy: Decoy) -> None: """It no-ops if a call is verified.""" subject = decoy.mock(name="subject") diff --git a/tests/test_when.py b/tests/test_when.py index b940d06..f5dc9e0 100644 --- a/tests/test_when.py +++ b/tests/test_when.py @@ -23,13 +23,6 @@ ) -@pytest.fixture() -def decoy() -> collections.abc.Iterator[Decoy]: - """Create a Decoy instance for testing.""" - with Decoy.create() as decoy: - yield decoy - - def test_when_then_return(decoy: Decoy) -> None: """It returns a value.""" subject = decoy.mock(name="subject")