diff --git a/changelog/10839.deprecation.rst b/changelog/10839.deprecation.rst new file mode 100644 index 0000000000..a3e2cbf51d --- /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 e55f0d71c2..88cf3eccbf 100644 --- a/doc/en/deprecations.rst +++ b/doc/en/deprecations.rst @@ -15,6 +15,65 @@ 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 where they also have synchronous tests, they will also get this warning. We strongly recommend against this practice, and they should restructure their testing infrastructure so the fixture is synchronous or to separate the fixture from their synchronous tests. Note that the `anyio pytest plugin `_ has some support for sync test + async fixtures currently. + + .. _import-or-skip-import-error: ``pytest.importorskip`` default behavior regarding :class:`ImportError` diff --git a/pyproject.toml b/pyproject.toml index ad0bca4374..3636ee455a 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 6b882fa351..b5c64856e1 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. If this is intentional, consider making the fixture " + "sync and return a coroutine/asyncgen. " + "This will turn into an error in pytest 9." + ), + # 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 7a8d871144..624a313ca8 100644 --- a/testing/acceptance_test.py +++ b/testing/acceptance_test.py @@ -1286,6 +1286,106 @@ 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. " + "If this is intentional, consider making the fixture sync and return " + "a coroutine/asyncgen. " + "This will turn into an error in pytest 9." + ), + ] + ) + 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. " + "If this is intentional, consider making the fixture sync and return " + "a coroutine/asyncgen. " + "This will turn into an error in pytest 9." + ), + ] + ) + 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. If this is intentional, consider making the " + "fixture sync and return a coroutine/asyncgen. " + "This will turn into an error in pytest 9." + ), + ] + ) + result.assert_outcomes(passed=1, warnings=1) + + def test_pdb_can_be_rewritten(pytester: Pytester) -> None: pytester.makepyfile( **{