Skip to content

Commit

Permalink
Merge pull request #12930 from jakkdl/sync_test_async_fixture
Browse files Browse the repository at this point in the history
DeprecationWarning if sync test requests async fixture
  • Loading branch information
Zac-HD authored Nov 17, 2024
2 parents 8691ff1 + 7084940 commit 5611bdd
Show file tree
Hide file tree
Showing 5 changed files with 197 additions and 1 deletion.
1 change: 1 addition & 0 deletions changelog/10839.deprecation.rst
Original file line number Diff line number Diff line change
@@ -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`.
70 changes: 70 additions & 0 deletions doc/en/deprecations.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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 <warnings>`.


.. _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 <https://anyio.readthedocs.io/en/stable/testing.html>`_ supports
synchronous tests with async fixtures, though certain limitations apply.


.. _import-or-skip-import-error:

``pytest.importorskip`` default behavior regarding :class:`ImportError`
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -335,7 +335,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

Expand Down
27 changes: 27 additions & 0 deletions src/_pytest/fixtures.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -968,6 +970,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.
Expand Down Expand Up @@ -1014,6 +1018,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"."""
Expand Down Expand Up @@ -1145,6 +1152,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:
Expand Down Expand Up @@ -1675,6 +1701,7 @@ def _register_fixture(
params=params,
ids=ids,
_ispytest=True,
_autouse=autouse,
)

faclist = self._arg2fixturedefs.setdefault(name, [])
Expand Down
98 changes: 98 additions & 0 deletions testing/acceptance_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
**{
Expand Down

0 comments on commit 5611bdd

Please sign in to comment.