diff --git a/changelog.d/1591.fixed.rst b/changelog.d/1591.fixed.rst new file mode 100644 index 00000000..c3a9b107 --- /dev/null +++ b/changelog.d/1591.fixed.rst @@ -0,0 +1,4 @@ +Fixed cancelled async fixture setup and teardown being propagated as +``asyncio.CancelledError``, which could stop other fixtures from being torn down or break later +tests that use the fixture. Also ensured ContextVars changed by async generator fixtures are +restored when their teardown raises. diff --git a/pytest_asyncio/plugin.py b/pytest_asyncio/plugin.py index 38b75e41..66cc53ca 100644 --- a/pytest_asyncio/plugin.py +++ b/pytest_asyncio/plugin.py @@ -382,6 +382,23 @@ def _sync_fixture_wrapper( return _sync_fixture_wrapper +def _cancelled_fixture_error( + phase: Literal["setup", "teardown"], + request: FixtureRequest, +) -> PytestAsyncioError: + """ + Return the error to raise instead of a fixture's CancelledError. + + CancelledError is not an Exception subclass, so pytest does not + handle it like other fixture errors: it does not cache it as a setup + error, and it does not run a node's remaining finalizers after one + is raised during teardown. + """ + return PytestAsyncioError( + f"The {phase} of async fixture {request.fixturename!r} was cancelled." + ) + + AsyncGenFixtureParams = ParamSpec("AsyncGenFixtureParams") AsyncGenFixtureYieldType = TypeVar("AsyncGenFixtureYieldType") @@ -405,7 +422,10 @@ async def setup(): return res context = contextvars.copy_context() - result = runner.run(setup(), context=context) + try: + result = runner.run(setup(), context=context) + except asyncio.CancelledError as e: + raise _cancelled_fixture_error("setup", request) from e reset_contextvars = _apply_contextvar_changes(context) @@ -422,9 +442,13 @@ async def async_finalizer() -> None: msg += "Yield only once." raise ValueError(msg) - runner.run(async_finalizer(), context=context) - if reset_contextvars is not None: - reset_contextvars() + try: + runner.run(async_finalizer(), context=context) + except asyncio.CancelledError as e: + raise _cancelled_fixture_error("teardown", request) from e + finally: + if reset_contextvars is not None: + reset_contextvars() request.addfinalizer(finalizer) return result @@ -453,7 +477,10 @@ async def setup(): return res context = contextvars.copy_context() - result = runner.run(setup(), context=context) + try: + result = runner.run(setup(), context=context) + except asyncio.CancelledError as e: + raise _cancelled_fixture_error("setup", request) from e # Copy the context vars modified by the setup task into the current # context, and (if needed) add a finalizer to reset them. diff --git a/tests/async_fixtures/test_async_fixtures_contextvars.py b/tests/async_fixtures/test_async_fixtures_contextvars.py index 0b9046eb..da6fe857 100644 --- a/tests/async_fixtures/test_async_fixtures_contextvars.py +++ b/tests/async_fixtures/test_async_fixtures_contextvars.py @@ -159,6 +159,27 @@ async def test(var_fixture_2): result.assert_outcomes(passed=1) +def test_var_from_async_generator_reset_when_teardown_fails(pytester: Pytester): + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") + pytester.makepyfile(_prelude + dedent(""" + @pytest_asyncio.fixture + async def var_fixture(): + with context_var_manager("value"): + yield + raise RuntimeError("teardown failed") + + @pytest.mark.asyncio + async def test(var_fixture): + assert _context_var.get() == "value" + + def test_after_failed_teardown(): + with pytest.raises(LookupError): + _context_var.get() + """)) + result = pytester.runpytest("--asyncio-mode=strict") + result.assert_outcomes(passed=2, errors=1) + + def test_var_set_to_existing_value_ok(pytester: Pytester): pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") pytester.makepyfile(_prelude + dedent(""" diff --git a/tests/async_fixtures/test_cancelled_fixtures.py b/tests/async_fixtures/test_cancelled_fixtures.py new file mode 100644 index 00000000..25bd38ff --- /dev/null +++ b/tests/async_fixtures/test_cancelled_fixtures.py @@ -0,0 +1,92 @@ +"""Async fixtures whose setup or teardown is cancelled.""" + +from __future__ import annotations + +from textwrap import dedent + +import pytest +from pytest import Pytester + + +def test_cancelled_teardown_does_not_prevent_teardown_of_other_fixtures( + pytester: Pytester, +): + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") + pytester.makepyfile(dedent("""\ + import asyncio + + import pytest + import pytest_asyncio + + torn_down = [] + + @pytest.fixture + def sync_fixture(): + yield + torn_down.append("sync_fixture") + + @pytest_asyncio.fixture + async def cancelled_teardown(): + task = asyncio.create_task(asyncio.sleep(3600)) + yield + task.cancel() + await task + + @pytest.mark.asyncio + async def test_requests_fixtures(sync_fixture, cancelled_teardown): + pass + + def test_other_fixture_was_torn_down(): + assert torn_down == ["sync_fixture"] + """)) + result = pytester.runpytest("--asyncio-mode=strict") + result.assert_outcomes(passed=2, errors=1) + result.stdout.fnmatch_lines( + [ + "*ERROR at teardown of test_requests_fixtures*", + "E*CancelledError", + "The above exception was the direct cause of the following exception:", + "*PytestAsyncioError: The teardown of async fixture " + "'cancelled_teardown' was cancelled.", + ] + ) + + +@pytest.mark.parametrize("fixture_end", ("yield", "return")) +def test_cancelled_setup_of_module_scoped_fixture_is_setup_error_of_each_test( + pytester: Pytester, fixture_end: str +): + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = module") + pytester.makepyfile(dedent(f"""\ + import asyncio + + import pytest + import pytest_asyncio + + @pytest_asyncio.fixture(scope="module") + async def cancelled_setup(): + task = asyncio.create_task(asyncio.sleep(3600)) + task.cancel() + await task + {fixture_end} + + @pytest.mark.asyncio(loop_scope="module") + async def test_first(cancelled_setup): + pass + + @pytest.mark.asyncio(loop_scope="module") + async def test_second(cancelled_setup): + pass + """)) + result = pytester.runpytest("--asyncio-mode=strict") + result.assert_outcomes(errors=2) + result.stdout.fnmatch_lines( + [ + "*ERROR at setup of test_first*", + "*PytestAsyncioError: The setup of async fixture " + "'cancelled_setup' was cancelled.", + "*ERROR at setup of test_second*", + "*PytestAsyncioError: The setup of async fixture " + "'cancelled_setup' was cancelled.", + ] + )