diff --git a/changelog/10839.deprecation.rst b/changelog/10839.deprecation.rst new file mode 100644 index 00000000000..a3e2cbf51d0 --- /dev/null +++ b/changelog/10839.deprecation.rst @@ -0,0 +1 @@ +Requesting an asynchronous fixture without a `pytest_fixture_setup` hook that resolves it will now give a DeprecationWarning. This most commonly happens if a sync test requests an async fixture. This should have no effect on a majority of users with async tests or fixtures using async pytest plugins, but may affect non-standard hook setups or ``autouse=True``. For guidance on how to work around this warning see :ref:`sync-test-async-fixture`. diff --git a/doc/en/deprecations.rst b/doc/en/deprecations.rst index e55f0d71c2e..59f9d83451b 100644 --- a/doc/en/deprecations.rst +++ b/doc/en/deprecations.rst @@ -15,6 +15,76 @@ Below is a complete list of all pytest features which are considered deprecated. :class:`~pytest.PytestWarning` or subclasses, which can be filtered using :ref:`standard warning filters `. +.. _sync-test-async-fixture: + +sync test depending on async fixture +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. deprecated:: 8.4 + +Pytest has for a long time given an error when encountering an asynchronous test function, prompting the user to install +a plugin that can handle it. It has not given any errors if you have an asynchronous fixture that's depended on by a +synchronous test. If the fixture was an async function you did get an "unawaited coroutine" warning, but for async yield fixtures you didn't even get that. +This is a problem even if you do have a plugin installed for handling async tests, as they may require +special decorators for async fixtures to be handled, and some may not robustly handle if a user accidentally requests an +async fixture from their sync tests. Fixture values being cached can make this even more unintuitive, where everything will +"work" if the fixture is first requested by an async test, and then requested by a synchronous test. + +Unfortunately there is no 100% reliable method of identifying when a user has made a mistake, versus when they expect an +unawaited object from their fixture that they will handle on their own. To suppress this warning +when you in fact did intend to handle this you can wrap your async fixture in a synchronous fixture: + +.. code-block:: python + + import asyncio + import pytest + + + @pytest.fixture + async def unawaited_fixture(): + return 1 + + + def test_foo(unawaited_fixture): + assert 1 == asyncio.run(unawaited_fixture) + +should be changed to + + +.. code-block:: python + + import asyncio + import pytest + + + @pytest.fixture + def unawaited_fixture(): + async def inner_fixture(): + return 1 + + return inner_fixture() + + + def test_foo(unawaited_fixture): + assert 1 == asyncio.run(unawaited_fixture) + + +You can also make use of `pytest_fixture_setup` to handle the coroutine/asyncgen before pytest sees it - this is the way current async pytest plugins handle it. + +If a user has an async fixture with ``autouse=True`` in their ``conftest.py``, or in a file +containing both synchronous tests and the fixture, they will receive this warning. +Unless you're using a plugin that specifically handles async fixtures +with synchronous tests, we strongly recommend against this practice. +It can lead to unpredictable behavior (with larger scopes, it may appear to "work" if an async +test is the first to request the fixture, due to value caching) and will generate +unawaited-coroutine runtime warnings (but only for non-yield fixtures). +Additionally, it creates ambiguity for other developers about whether the fixture is intended to perform +setup for synchronous tests. + +The `anyio pytest plugin `_ supports +synchronous tests with async fixtures, though certain limitations apply. + + .. _import-or-skip-import-error: ``pytest.importorskip`` default behavior regarding :class:`ImportError` diff --git a/pyproject.toml b/pyproject.toml index ad0bca4374b..3636ee455ab 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -336,7 +336,7 @@ disable = [ ] [tool.codespell] -ignore-words-list = "afile,asser,assertio,feld,hove,ned,noes,notin,paramete,parth,socio-economic,tesults,varius,wil" +ignore-words-list = "afile,asend,asser,assertio,feld,hove,ned,noes,notin,paramete,parth,socio-economic,tesults,varius,wil" skip = "*/plugin_list.rst" write-changes = true diff --git a/src/_pytest/fixtures.py b/src/_pytest/fixtures.py index 6b882fa3515..8122d7fe2a8 100644 --- a/src/_pytest/fixtures.py +++ b/src/_pytest/fixtures.py @@ -73,6 +73,7 @@ from _pytest.scope import _ScopeName from _pytest.scope import HIGH_SCOPES from _pytest.scope import Scope +from _pytest.warning_types import PytestRemovedIn9Warning if sys.version_info < (3, 11): @@ -575,6 +576,7 @@ def _get_active_fixturedef( # The are no fixtures with this name applicable for the function. if not fixturedefs: raise FixtureLookupError(argname, self) + # A fixture may override another fixture with the same name, e.g. a # fixture in a module can override a fixture in a conftest, a fixture in # a class can override a fixture in the module, and so on. @@ -959,6 +961,8 @@ def __init__( ids: tuple[object | None, ...] | Callable[[Any], object | None] | None = None, *, _ispytest: bool = False, + # only used in a deprecationwarning msg, can be removed in pytest9 + _autouse: bool = False, ) -> None: check_ispytest(_ispytest) # The "base" node ID for the fixture. @@ -1005,6 +1009,9 @@ def __init__( self.cached_result: _FixtureCachedResult[FixtureValue] | None = None self._finalizers: Final[list[Callable[[], object]]] = [] + # only used to emit a deprecationwarning, can be removed in pytest9 + self._autouse = _autouse + @property def scope(self) -> _ScopeName: """Scope string, one of "function", "class", "module", "package", "session".""" @@ -1136,6 +1143,25 @@ def pytest_fixture_setup( fixturefunc = resolve_fixture_function(fixturedef, request) my_cache_key = fixturedef.cache_key(request) + + if inspect.isasyncgenfunction(fixturefunc) or inspect.iscoroutinefunction( + fixturefunc + ): + auto_str = " with autouse=True" if fixturedef._autouse else "" + + warnings.warn( + PytestRemovedIn9Warning( + f"{request.node.name!r} requested an async fixture " + f"{request.fixturename!r}{auto_str}, with no plugin or hook that " + "handled it. This is usually an error, as pytest does not natively " + "support it. " + "This will turn into an error in pytest 9.\n" + "See: https://docs.pytest.org/en/stable/deprecations.html#sync-test-depending-on-async-fixture" + ), + # no stacklevel will point at users code, so we just point here + stacklevel=1, + ) + try: result = call_fixture_func(fixturefunc, request, kwargs) except TEST_OUTCOME as e: @@ -1666,6 +1692,7 @@ def _register_fixture( params=params, ids=ids, _ispytest=True, + _autouse=autouse, ) faclist = self._arg2fixturedefs.setdefault(name, []) diff --git a/testing/acceptance_test.py b/testing/acceptance_test.py index 7a8d871144a..ba1f86f02d9 100644 --- a/testing/acceptance_test.py +++ b/testing/acceptance_test.py @@ -1286,6 +1286,104 @@ def test_3(): result.assert_outcomes(failed=3) +def test_warning_on_sync_test_async_fixture(pytester: Pytester) -> None: + pytester.makepyfile( + test_sync=""" + import pytest + + @pytest.fixture + async def async_fixture(): + ... + + def test_foo(async_fixture): + # suppress unawaited coroutine warning + try: + async_fixture.send(None) + except StopIteration: + pass + """ + ) + result = pytester.runpytest() + result.stdout.fnmatch_lines( + [ + "*== warnings summary ==*", + ( + "*PytestRemovedIn9Warning: 'test_foo' requested an async " + "fixture 'async_fixture', with no plugin or hook that handled it. " + "This is usually an error, as pytest does not natively support it. " + "This will turn into an error in pytest 9." + ), + " See: https://docs.pytest.org/en/stable/deprecations.html#sync-test-depending-on-async-fixture", + ] + ) + result.assert_outcomes(passed=1, warnings=1) + + +def test_warning_on_sync_test_async_fixture_gen(pytester: Pytester) -> None: + pytester.makepyfile( + test_sync=""" + import pytest + + @pytest.fixture + async def async_fixture(): + yield + + def test_foo(async_fixture): + # async gens don't emit unawaited-coroutine + ... + """ + ) + result = pytester.runpytest() + result.stdout.fnmatch_lines( + [ + "*== warnings summary ==*", + ( + "*PytestRemovedIn9Warning: 'test_foo' requested an async " + "fixture 'async_fixture', with no plugin or hook that handled it. " + "This is usually an error, as pytest does not natively support it. " + "This will turn into an error in pytest 9." + ), + " See: https://docs.pytest.org/en/stable/deprecations.html#sync-test-depending-on-async-fixture", + ] + ) + result.assert_outcomes(passed=1, warnings=1) + + +def test_warning_on_sync_test_async_autouse_fixture(pytester: Pytester) -> None: + pytester.makepyfile( + test_sync=""" + import pytest + + @pytest.fixture(autouse=True) + async def async_fixture(): + ... + + # We explicitly request the fixture to be able to + # suppress the RuntimeWarning for unawaited coroutine. + def test_foo(async_fixture): + try: + async_fixture.send(None) + except StopIteration: + pass + """ + ) + result = pytester.runpytest() + result.stdout.fnmatch_lines( + [ + "*== warnings summary ==*", + ( + "*PytestRemovedIn9Warning: 'test_foo' requested an async " + "fixture 'async_fixture' with autouse=True, with no plugin or hook " + "that handled it. " + "This is usually an error, as pytest does not natively support it. " + "This will turn into an error in pytest 9." + ), + " See: https://docs.pytest.org/en/stable/deprecations.html#sync-test-depending-on-async-fixture", + ] + ) + result.assert_outcomes(passed=1, warnings=1) + + def test_pdb_can_be_rewritten(pytester: Pytester) -> None: pytester.makepyfile( **{