Skip to content

Commit 68280f6

Browse files
committed
feat(pytest): use v3 API in decoy fixture if annotated
1 parent 74b7e4f commit 68280f6

File tree

9 files changed

+105
-55
lines changed

9 files changed

+105
-55
lines changed

decoy/pytest_plugin.py

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,21 +5,32 @@
55
but highly recommended.
66
"""
77

8-
from typing import Iterable
8+
from typing import TYPE_CHECKING, Iterable, Union, get_type_hints
99

1010
import pytest
1111

1212
from decoy import Decoy
1313

14+
if TYPE_CHECKING:
15+
from decoy.next import Decoy as DecoyNext
16+
1417

1518
@pytest.fixture()
16-
def decoy() -> Iterable[Decoy]:
19+
def decoy(
20+
request: pytest.FixtureRequest,
21+
) -> "Iterable[Union[Decoy, DecoyNext]]":
1722
"""Get a [decoy.Decoy][] container and [reset it][decoy.Decoy.reset] after the test.
1823
1924
This function is function-scoped [pytest fixture][] that will be
2025
automatically inserted by the plugin.
2126
27+
!!! tip
28+
29+
This fixture will automatically opt-into the [v3 preview API][v3-preview]
30+
if annotated with `decoy.next.Decoy`.
31+
2232
[pytest fixture]: https://docs.pytest.org/en/latest/how-to/fixtures.html
33+
[v3-preview]: ./v3/about.md
2334
2435
!!! example
2536
```python
@@ -28,6 +39,20 @@ def test_my_thing(decoy: Decoy) -> None:
2839
# ...
2940
```
3041
"""
31-
decoy = Decoy()
32-
yield decoy
33-
decoy.reset()
42+
try:
43+
decoy_hint = get_type_hints(request.function).get("decoy")
44+
is_next = decoy_hint.__module__.startswith("decoy.next")
45+
46+
# purely defensive, probably won't ever raise
47+
except Exception: # pragma: no cover
48+
is_next = False
49+
50+
if is_next:
51+
from decoy.next import Decoy as DecoyNext
52+
53+
with DecoyNext.create() as decoy_next:
54+
yield decoy_next
55+
else:
56+
decoy = Decoy()
57+
yield decoy
58+
decoy.reset()

docs/v3/about.md

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -28,14 +28,6 @@ Then, start trying out the new API!
2828
- from decoy import Decoy
2929
+ from decoy.next import Decoy
3030

31-
32-
+ @pytest.fixture()
33-
+ def decoy() -> collections.abc.Iterator[Decoy]:
34-
+ """Create a Decoy v3 preview instance for testing."""
35-
+ with Decoy.create() as decoy:
36-
+ yield decoy
37-
38-
3931
def test_when(decoy: Decoy) -> None:
4032
mock = decoy.mock(cls=SomeClass)
4133
- decoy.when(mock.foo("hello")).then_return("world")

docs/v3/migration.md

Lines changed: 9 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -12,19 +12,16 @@ Recommended migration from v2:
1212

1313
## Setup
1414

15-
For an incremental migration, you can set up a replacement `decoy` fixture wherever tests are being migrated:
15+
For an incremental migration, annotate a test's `decoy` fixture as `decoy.next.Decoy` to automatically opt-in that test to the preview API.
1616

17-
```python
18-
import collections.abc
19-
import pytest
20-
21-
from decoy.next import Decoy
17+
```diff
18+
- from decoy import Decoy
19+
+ from decoy.next import Decoy
2220

23-
@pytest.fixture()
24-
def decoy() -> collections.abc.Iterator[Decoy]:
25-
"""Create a Decoy instance for testing."""
26-
with Decoy.create() as decoy:
27-
yield decoy
21+
def test_when(decoy: Decoy) -> None:
22+
mock = decoy.mock(cls=SomeClass)
23+
- decoy.when(mock.foo("hello")).then_return("world")
24+
+ decoy.when(mock.foo).called_with("hello").then_return("world")
2825
```
2926

3027
## When
@@ -189,6 +186,7 @@ In v3, `__enter__` and `__exit__` can still be stubbed to test advanced context
189186

190187
## Other breaking changes
191188

189+
- The `mypy` plugin is no longer needed and will be removed
192190
- [`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.
193191
- [`IncorrectCallWarning`][decoy.warnings.IncorrectCallWarning] has been upgraded to an error: [`SignatureMismatchError`][decoy.errors.SignatureMismatchError].
194192
- Some "public" attributes have been removed from error classes.

pyproject.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,11 +77,13 @@ test = "pytest -f"
7777
test-once = "coverage run --branch --source=decoy -m pytest --mypy-same-process"
7878
coverage = "coverage report"
7979
coverage-xml = "coverage xml"
80+
coverage-html = "coverage html"
8081
docs = "mkdocs serve --livereload"
8182
build-docs = "mkdocs build"
8283
build-package = "uv build"
8384
check-ci = ["check", "lint", "format-check"]
8485
test-ci = ["test-once", "coverage-xml"]
86+
test-coverage = ["test-once", "coverage-html", "coverage"]
8587

8688
[tool.pytest.ini_options]
8789
addopts = "--color=yes --mypy-ini-file=tests/legacy/typing/mypy.ini --mypy-only-local-stub"

tests/test_matcher.py

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22

33
from __future__ import annotations
44

5-
import collections.abc
65
import dataclasses
76
import sys
87
from typing import Any, Callable, NamedTuple
@@ -33,13 +32,6 @@
3332
)
3433

3534

36-
@pytest.fixture()
37-
def decoy() -> collections.abc.Iterator[Decoy]:
38-
"""Create a Decoy instance for testing."""
39-
with Decoy.create() as decoy:
40-
yield decoy
41-
42-
4335
def test_matcher() -> None:
4436
"""It matches based on a TypeIs function."""
4537

tests/test_mock.py

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22

33
from __future__ import annotations
44

5-
import collections.abc
65
import inspect
76
import sys
87
from typing import Any
@@ -22,13 +21,6 @@
2221
)
2322

2423

25-
@pytest.fixture()
26-
def decoy() -> collections.abc.Iterator[Decoy]:
27-
"""Create a Decoy instance for testing."""
28-
with Decoy.create() as decoy:
29-
yield decoy
30-
31-
3224
def test_create_mock(decoy: Decoy) -> None:
3325
"""It creates a callable mock that no-ops."""
3426
subject = decoy.mock(name="alice")

tests/test_pytest_plugin.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
"""Tests for Decoy's pytest plugin."""
22

3+
import sys
4+
35
import pytest
46

57

@@ -19,3 +21,65 @@ def test_decoy(decoy):
1921

2022
# check that all 4 tests passed
2123
result.assert_outcomes(passed=1)
24+
25+
26+
@pytest.mark.skipif(sys.version_info < (3, 10), reason="v3 API required Python >= 3.10")
27+
def test_pytest_decoy_next_fixture(testdir: pytest.Testdir) -> None:
28+
"""It opts into the decoy.next API via annotation."""
29+
# create a temporary pytest test file
30+
testdir.makepyfile(
31+
"""
32+
from decoy.next import Decoy
33+
34+
def test_decoy(decoy: Decoy):
35+
assert isinstance(decoy, Decoy)
36+
"""
37+
)
38+
39+
result = testdir.runpytest_subprocess()
40+
41+
# check that all 4 tests passed
42+
result.assert_outcomes(passed=1)
43+
44+
45+
@pytest.mark.skipif(sys.version_info < (3, 10), reason="v3 API required Python >= 3.10")
46+
def test_pytest_decoy_next_fixture_fallback(testdir: pytest.Testdir) -> None:
47+
"""It falls back to v2 if annotations are messed up."""
48+
# create a temporary pytest test file
49+
testdir.makepyfile(
50+
"""
51+
from decoy import Decoy
52+
53+
def test_decoy(decoy: 'Oops'):
54+
assert isinstance(decoy, Decoy)
55+
"""
56+
)
57+
58+
result = testdir.runpytest_subprocess()
59+
60+
# check that all 4 tests passed
61+
result.assert_outcomes(passed=1)
62+
63+
64+
@pytest.mark.skipif(sys.version_info < (3, 10), reason="v3 API required Python >= 3.10")
65+
def test_pytest_decoy_next_fixture_nesting(testdir: pytest.Testdir) -> None:
66+
"""It opts into the decoy.next API via annotation for derived fixtures."""
67+
# create a temporary pytest test file
68+
testdir.makepyfile(
69+
"""
70+
import pytest
71+
from decoy.next import Decoy, Mock
72+
73+
@pytest.fixture()
74+
def mock(decoy: Decoy):
75+
return decoy.mock(name="mock")
76+
77+
def test_decoy(decoy: Decoy, mock: Mock):
78+
assert isinstance(mock, Mock)
79+
"""
80+
)
81+
82+
result = testdir.runpytest_subprocess()
83+
84+
# check that all 4 tests passed
85+
result.assert_outcomes(passed=1)

tests/test_verify.py

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22

33
from __future__ import annotations
44

5-
import collections.abc
65
import os
76
import sys
87

@@ -22,13 +21,6 @@
2221
)
2322

2423

25-
@pytest.fixture()
26-
def decoy() -> collections.abc.Iterator[Decoy]:
27-
"""Create a Decoy instance for testing."""
28-
with Decoy.create() as decoy:
29-
yield decoy
30-
31-
3224
def test_verify(decoy: Decoy) -> None:
3325
"""It no-ops if a call is verified."""
3426
subject = decoy.mock(name="subject")

tests/test_when.py

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -23,13 +23,6 @@
2323
)
2424

2525

26-
@pytest.fixture()
27-
def decoy() -> collections.abc.Iterator[Decoy]:
28-
"""Create a Decoy instance for testing."""
29-
with Decoy.create() as decoy:
30-
yield decoy
31-
32-
3326
def test_when_then_return(decoy: Decoy) -> None:
3427
"""It returns a value."""
3528
subject = decoy.mock(name="subject")

0 commit comments

Comments
 (0)