diff --git a/changelog.d/1501.fixed.rst b/changelog.d/1501.fixed.rst new file mode 100644 index 00000000..347ec571 --- /dev/null +++ b/changelog.d/1501.fixed.rst @@ -0,0 +1,5 @@ +Synchronous tests whose pytest-computed static fixture closure contains a +pytest-asyncio-managed fixture are now parametrized over configured event loop +factories, preventing shared fixtures from being torn down at +synchronous/asynchronous test boundaries. Fixtures requested only dynamically +through ``request.getfixturevalue()`` do not trigger this parametrization. diff --git a/docs/how-to-guides/custom_loop_factory.rst b/docs/how-to-guides/custom_loop_factory.rst index fb4d9f9c..f782ebcb 100644 --- a/docs/how-to-guides/custom_loop_factory.rst +++ b/docs/how-to-guides/custom_loop_factory.rst @@ -23,4 +23,25 @@ pytest-asyncio can run asynchronous tests with custom event loop factories by im The hook receives the current pytest ``item``, so it can return different factory mappings for different tests. See :doc:`configure_loop_factories_per_test` for item-based factory configuration. +Synchronous tests are not parametrized by the hook unless their statically +resolved fixture closure contains a pytest-asyncio-managed fixture. This +includes fixtures requested through test arguments, ``usefixtures``, autouse +fixtures, and transitive fixture dependencies. Such a test runs once for each +configured loop factory, ensuring the managed fixture is created and torn down +on the corresponding event loop. + +Direct test parameters shadow fixtures with the same name and therefore do not +trigger parametrization through those fixtures. Indirect parameters continue to +use their fixture definitions normally. + +A managed fixture requested only dynamically through +``request.getfixturevalue()`` is not known during collection and does not +trigger loop factory parametrization for a synchronous test. + +pytest-asyncio relies on the static fixture closure supplied by pytest and does +not perform additional fixture traversal. Dependencies introduced only by a +dynamic ``pytest_generate_tests`` rewrite, or hidden behind an overridden +same-name fixture definition that pytest does not include in that closure, do +not trigger parametrization. + To run a test with only some configured factories, see :doc:`run_test_with_specific_loop_factories`. diff --git a/docs/reference/hooks.rst b/docs/reference/hooks.rst index ed025b6f..bd942cfb 100644 --- a/docs/reference/hooks.rst +++ b/docs/reference/hooks.rst @@ -7,7 +7,7 @@ Hooks This hook returns a mapping from factory name strings to event loop factory callables for the current test item. -By default, each pytest-asyncio test is run once per configured factory. Tests managed by other async plugins are unaffected. Synchronous tests are not parametrized. The configured loop scope still determines how long each event loop instance is kept alive. +By default, each pytest-asyncio test is run once per configured factory. Tests managed by other async plugins are unaffected. A synchronous test is also parametrized when pytest's statically resolved fixture closure contains a pytest-asyncio-managed fixture; other synchronous tests are not parametrized. The configured loop scope still determines how long each event loop instance is kept alive. Factories should be callables without required parameters and should return an ``asyncio.AbstractEventLoop`` instance. The effective hook result must be a non-empty mapping of non-empty string names to callables. diff --git a/pytest_asyncio/plugin.py b/pytest_asyncio/plugin.py index 38b75e41..33cc42a6 100644 --- a/pytest_asyncio/plugin.py +++ b/pytest_asyncio/plugin.py @@ -685,6 +685,38 @@ def _resolve_asyncio_marker(item: Function) -> Mark | None: return None +def _get_managed_fixture_loop_scope( + metafunc: pytest.Metafunc, +) -> _ScopeName | None: + """Return the widest loop scope of managed fixtures used by a sync test.""" + asyncio_mode = _get_asyncio_mode(metafunc.config) + default_loop_scope = metafunc.config.getini("asyncio_default_fixture_loop_scope") + loop_scopes: list[Scope] = [] + # Let pytest own fixture traversal and shadowing. There is no public API for + # retrieving the active FixtureDef from Metafunc, so this is intentionally a + # shallow, read-only use of pytest's already-computed fixture closure. + for fixture_name in metafunc.fixturenames: + fixturedefs = metafunc._arg2fixturedefs.get(fixture_name) + if not fixturedefs: + continue + fixturedef = fixturedefs[-1] + func = fixturedef.func + is_managed = _is_asyncio_fixture_function(func) or ( + asyncio_mode == Mode.AUTO and _is_coroutine_or_asyncgen(func) + ) + if is_managed: + loop_scope = ( + getattr(func, "_loop_scope", None) + or default_loop_scope + or fixturedef.scope + ) + loop_scopes.append(Scope(loop_scope)) + if not loop_scopes: + return None + # Scope is ordered from function (narrowest) to session (widest). + return max(loop_scopes).value + + # The function name needs to start with "pytest_" # see https://github.com/pytest-dev/pytest/issues/11307 @pytest.hookimpl(specname="pytest_pycollect_makeitem", hookwrapper=True) @@ -720,6 +752,14 @@ def pytest_pycollect_makeitem_convert_async_functions_to_subclass( and _resolve_asyncio_marker(node) is not None ): updated_item = specialized_item_class._from_function(node) + elif ( + hasattr(node, "callspec") + and _asyncio_loop_factory.__name__ in node.callspec.params + ): + # pytest prunes the dynamically parametrized fixture name from + # the static closure before creating the Function item. Put it + # first so its cache key changes before managed fixtures are read. + node.fixturenames.insert(0, _asyncio_loop_factory.__name__) updated_node_collection.append(updated_item) hook_result.force_result(updated_node_collection) @@ -731,17 +771,43 @@ def pytest_generate_tests(metafunc: pytest.Metafunc) -> None: ) if specialized_item_class is None: return - asyncio_marker = _resolve_asyncio_marker(metafunc.definition) if asyncio_marker is None: return marker_loop_scope, marker_selected_factory_names = _parse_asyncio_marker( asyncio_marker ) + _parametrize_loop_factories( + metafunc, marker_loop_scope, marker_selected_factory_names + ) + + +@pytest.hookimpl(specname="pytest_generate_tests", wrapper=True, tryfirst=True) +def pytest_generate_tests_for_sync_functions( + metafunc: pytest.Metafunc, +) -> Generator[None, object, object]: + hook_result = yield + specialized_item_class = PytestAsyncioFunction.item_subclass_for( + metafunc.definition + ) + if specialized_item_class is not None: + return hook_result + managed_fixture_loop_scope = _get_managed_fixture_loop_scope(metafunc) + if managed_fixture_loop_scope is None: + return hook_result + _parametrize_loop_factories(metafunc, managed_fixture_loop_scope, None) + return hook_result + +def _parametrize_loop_factories( + metafunc: pytest.Metafunc, + loop_scope: _ScopeName | None, + selected_factory_names: Sequence[str] | None, +) -> None: + """Parametrize a test over the loop factories selected for its item.""" hook_factories = _collect_hook_loop_factories(metafunc.config, metafunc.definition) if hook_factories is None: - if marker_selected_factory_names is not None: + if selected_factory_names is not None: raise pytest.UsageError( "mark.asyncio 'loop_factories' requires at least one " "pytest_asyncio_loop_factories hook implementation." @@ -750,13 +816,13 @@ def pytest_generate_tests(metafunc: pytest.Metafunc) -> None: factory_params: Collection[object] factory_ids: Collection[str] - if marker_selected_factory_names is None: + if selected_factory_names is None: factory_params = hook_factories.values() factory_ids = hook_factories.keys() else: # Iterate in marker order to preserve explicit user selection # order. - factory_ids = marker_selected_factory_names + factory_ids = selected_factory_names factory_params = [ ( hook_factories[name] @@ -772,11 +838,11 @@ def pytest_generate_tests(metafunc: pytest.Metafunc) -> None: ), ) ) - for name in marker_selected_factory_names + for name in selected_factory_names ] metafunc.fixturenames.append(_asyncio_loop_factory.__name__) default_loop_scope = _get_default_test_loop_scope(metafunc.config) - loop_scope = marker_loop_scope or default_loop_scope + effective_loop_scope = loop_scope or default_loop_scope # pytest.HIDDEN_PARAM was added in pytest 8.4 hide_id = len(factory_ids) == 1 and hasattr(pytest, "HIDDEN_PARAM") metafunc.parametrize( @@ -784,7 +850,7 @@ def pytest_generate_tests(metafunc: pytest.Metafunc) -> None: factory_params, ids=(pytest.HIDDEN_PARAM,) if hide_id else factory_ids, indirect=True, - scope=loop_scope, + scope=effective_loop_scope, ) diff --git a/tests/test_loop_factory_parametrization.py b/tests/test_loop_factory_parametrization.py index 224c9141..d47e5fc7 100644 --- a/tests/test_loop_factory_parametrization.py +++ b/tests/test_loop_factory_parametrization.py @@ -22,16 +22,27 @@ def pytest_asyncio_loop_factories(config, item): """)) pytester.makepyfile(dedent("""\ import pytest + import pytest_asyncio pytest_plugins = "pytest_asyncio" + @pytest_asyncio.fixture + async def managed_fixture(): + return "managed" + @pytest.mark.asyncio async def test_example(): assert True + + def test_sync(managed_fixture): + assert managed_fixture == "managed" """)) result = pytester.runpytest("--asyncio-mode=strict", "--collect-only", "-q") result.stdout.fnmatch_lines( - ["test_single_factory_does_not_add_suffix_to_test_name.py::test_example"] + [ + "test_single_factory_does_not_add_suffix_to_test_name.py::test_example", + "test_single_factory_does_not_add_suffix_to_test_name.py::test_sync", + ] ) @@ -123,6 +134,50 @@ async def test_runs_once_per_factory(): ) +def test_factory_ids_preserve_async_order_and_follow_sync_parameters( + pytester: Pytester, +) -> None: + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") + pytester.makeconftest(dedent("""\ + import asyncio + import pytest_asyncio + + def pytest_asyncio_loop_factories(config, item): + return { + "factory_a": asyncio.new_event_loop, + "factory_b": asyncio.new_event_loop, + } + + @pytest_asyncio.fixture + async def managed_fixture(): + return "managed" + """)) + pytester.makepyfile(dedent("""\ + import pytest + + pytest_plugins = "pytest_asyncio" + + @pytest.mark.parametrize("value", [pytest.param(1, id="value")]) + @pytest.mark.asyncio + async def test_async(value): + assert value == 1 + + @pytest.mark.parametrize("value", [pytest.param(1, id="value")]) + def test_sync(value, managed_fixture): + assert value == 1 + assert managed_fixture == "managed" + """)) + result = pytester.runpytest("--asyncio-mode=strict", "--collect-only", "-q") + result.stdout.fnmatch_lines( + [ + "*test_async[[]factory_a-value[]]", + "*test_async[[]factory_b-value[]]", + "*test_sync[[]value-factory_a[]]", + "*test_sync[[]value-factory_b[]]", + ] + ) + + def test_named_hook_factories_apply_to_async_fixtures(pytester: Pytester) -> None: pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") pytester.makeconftest(dedent("""\ @@ -190,6 +245,386 @@ async def test_async(request): result.assert_outcomes(passed=3) +@pytest.mark.parametrize( + "async_test_first", (True, False), ids=("async-first", "sync-first") +) +def test_sync_test_using_shared_async_fixture_uses_loop_factory_parameter( + pytester: Pytester, + async_test_first: bool, +) -> None: + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = session") + pytester.makeconftest(dedent("""\ + import asyncio + import pytest_asyncio + + events = [] + + def pytest_asyncio_loop_factories(config, item): + return {"default": asyncio.new_event_loop} + + @pytest_asyncio.fixture(scope="session") + async def parent(): + events.append("parent setup") + yield "parent" + events.append("parent teardown") + + @pytest_asyncio.fixture(scope="session") + async def child(parent): + events.append("child setup") + yield "child" + events.append("child teardown") + + def pytest_sessionfinish(session): + assert events == [ + "parent setup", + "child setup", + "child teardown", + "parent teardown", + ] + """)) + async_test = dedent("""\ + @pytest.mark.asyncio(loop_scope="session") + async def test_async(parent): + assert parent == "parent" + """) + sync_test = dedent("""\ + def test_sync(child): + assert child == "child" + """) + test_bodies = async_test + sync_test if async_test_first else sync_test + async_test + pytester.makepyfile(dedent("""\ + import pytest + + pytest_plugins = "pytest_asyncio" + """) + test_bodies) + result = pytester.runpytest("--asyncio-mode=strict") + result.assert_outcomes(passed=2) + + +@pytest.mark.parametrize( + ("fixture_scope", "loop_scope"), + ( + ("function", "function"), + ("function", "module"), + ("module", "session"), + ("session", "session"), + ), +) +def test_sync_test_async_fixture_runs_and_tears_down_for_each_loop_factory( + pytester: Pytester, + fixture_scope: str, + loop_scope: str, +) -> None: + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") + pytester.makeconftest(dedent(f"""\ + import asyncio + import pytest_asyncio + + class CustomLoopA(asyncio.SelectorEventLoop): + pass + + class CustomLoopB(asyncio.SelectorEventLoop): + pass + + def pytest_asyncio_loop_factories(config, item): + return {{"loop_a": CustomLoopA, "loop_b": CustomLoopB}} + + @pytest_asyncio.fixture(scope="{fixture_scope}", loop_scope="{loop_scope}") + async def loop_name(): + name = type(asyncio.get_running_loop()).__name__ + yield name + print(f"TEARDOWN:{{name}}") + """)) + pytester.makepyfile(dedent("""\ + pytest_plugins = "pytest_asyncio" + + def test_sync(loop_name): + assert loop_name in ("CustomLoopA", "CustomLoopB") + """)) + result = pytester.runpytest("--asyncio-mode=strict", "-s") + result.assert_outcomes(passed=2) + output = result.stdout.str() + assert output.count("TEARDOWN:CustomLoopA") == 1 + assert output.count("TEARDOWN:CustomLoopB") == 1 + + +def test_direct_parameter_shadows_managed_fixture_for_sync_test( + pytester: Pytester, +) -> None: + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") + pytester.makeconftest(dedent("""\ + import asyncio + import pytest_asyncio + + class CustomLoopA(asyncio.SelectorEventLoop): + pass + + class CustomLoopB(asyncio.SelectorEventLoop): + pass + + def pytest_asyncio_loop_factories(config, item): + return {"loop_a": CustomLoopA, "loop_b": CustomLoopB} + + @pytest_asyncio.fixture + async def value(): + raise AssertionError("the directly parametrized value must win") + """)) + pytester.makepyfile(dedent("""\ + import pytest + + pytest_plugins = "pytest_asyncio" + + @pytest.mark.parametrize("value", (1, 2)) + def test_sync(value): + assert value in (1, 2) + """)) + result = pytester.runpytest("--asyncio-mode=strict") + result.assert_outcomes(passed=2) + + +def test_direct_parameter_shadows_transitive_managed_fixture_for_sync_test( + pytester: Pytester, +) -> None: + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") + pytester.makeconftest(dedent("""\ + import asyncio + import pytest_asyncio + + class CustomLoopA(asyncio.SelectorEventLoop): + pass + + class CustomLoopB(asyncio.SelectorEventLoop): + pass + + def pytest_asyncio_loop_factories(config, item): + return {"loop_a": CustomLoopA, "loop_b": CustomLoopB} + + @pytest_asyncio.fixture + async def value(): + raise AssertionError("the directly parametrized value must win") + """)) + pytester.makepyfile(dedent("""\ + import pytest + + pytest_plugins = "pytest_asyncio" + + @pytest.fixture + def resource(value): + return value + + @pytest.mark.parametrize("value", (1, 2)) + def test_sync(resource): + assert resource in (1, 2) + """)) + result = pytester.runpytest("--asyncio-mode=strict") + result.assert_outcomes(passed=2) + + +def test_mixed_indirect_direct_parameter_shadows_transitive_managed_fixture( + pytester: Pytester, +) -> None: + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") + pytester.makeconftest(dedent("""\ + import asyncio + import pytest_asyncio + + class CustomLoopA(asyncio.SelectorEventLoop): + pass + + class CustomLoopB(asyncio.SelectorEventLoop): + pass + + def pytest_asyncio_loop_factories(config, item): + return {"loop_a": CustomLoopA, "loop_b": CustomLoopB} + + @pytest_asyncio.fixture + async def value(): + raise AssertionError("the directly parametrized value must win") + """)) + pytester.makepyfile(dedent("""\ + import pytest + + pytest_plugins = "pytest_asyncio" + + @pytest.fixture + def resource(value): + return value + + @pytest.fixture + def other(request): + return request.param + + @pytest.mark.parametrize( + ("value", "other"), + ((1, "a"), (2, "b")), + indirect=["other"], + ) + def test_sync(resource, other): + assert resource in (1, 2) + assert other in ("a", "b") + """)) + result = pytester.runpytest("--asyncio-mode=strict") + result.assert_outcomes(passed=2) + + +def test_generate_tests_direct_parameter_shadows_transitive_managed_fixture( + pytester: Pytester, +) -> None: + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") + pytester.makeconftest(dedent("""\ + import asyncio + import pytest_asyncio + + class CustomLoopA(asyncio.SelectorEventLoop): + pass + + class CustomLoopB(asyncio.SelectorEventLoop): + pass + + def pytest_asyncio_loop_factories(config, item): + return {"loop_a": CustomLoopA, "loop_b": CustomLoopB} + + @pytest_asyncio.fixture + async def value(): + raise AssertionError("the directly parametrized value must win") + """)) + pytester.makepyfile(dedent("""\ + import pytest + + pytest_plugins = "pytest_asyncio" + + def pytest_generate_tests(metafunc): + if "value" in metafunc.fixturenames: + metafunc.parametrize("value", (1, 2)) + + @pytest.fixture + def resource(value): + return value + + def test_sync(resource): + assert resource in (1, 2) + """)) + result = pytester.runpytest("--asyncio-mode=strict") + result.assert_outcomes(passed=2) + + +def test_indirect_parameter_keeps_managed_fixture_for_sync_test( + pytester: Pytester, +) -> None: + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") + pytester.makeconftest(dedent("""\ + import asyncio + import pytest_asyncio + + class CustomLoopA(asyncio.SelectorEventLoop): + pass + + class CustomLoopB(asyncio.SelectorEventLoop): + pass + + def pytest_asyncio_loop_factories(config, item): + return {"loop_a": CustomLoopA, "loop_b": CustomLoopB} + + @pytest_asyncio.fixture + async def value(request): + return request.param, type(asyncio.get_running_loop()).__name__ + """)) + pytester.makepyfile(dedent("""\ + import pytest + + pytest_plugins = "pytest_asyncio" + + @pytest.mark.parametrize("value", (1, 2), indirect=True) + def test_sync(value): + parameter, loop_name = value + assert parameter in (1, 2) + assert loop_name in ("CustomLoopA", "CustomLoopB") + """)) + result = pytester.runpytest("--asyncio-mode=strict") + result.assert_outcomes(passed=4) + + +@pytest.mark.parametrize( + ("fixture_decorator", "test_decorator"), + ( + ("pytest_asyncio.fixture(autouse=True)", ""), + ( + "pytest_asyncio.fixture", + '@pytest.mark.usefixtures("custom_loop_is_active")', + ), + ), + ids=("autouse", "usefixtures"), +) +def test_sync_test_with_implicit_managed_sync_fixture_runs_for_each_loop_factory( + pytester: Pytester, + fixture_decorator: str, + test_decorator: str, +) -> None: + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") + pytester.makeconftest(dedent(f"""\ + import asyncio + import pytest_asyncio + + class CustomLoopA(asyncio.SelectorEventLoop): + pass + + class CustomLoopB(asyncio.SelectorEventLoop): + pass + + def pytest_asyncio_loop_factories(config, item): + return {{"loop_a": CustomLoopA, "loop_b": CustomLoopB}} + + @{fixture_decorator} + def custom_loop_is_active(): + loop_name = type(asyncio.get_event_loop()).__name__ + assert loop_name in ("CustomLoopA", "CustomLoopB") + """)) + pytester.makepyfile(dedent(f"""\ + import pytest + + pytest_plugins = "pytest_asyncio" + + {test_decorator} + def test_sync(): + pass + """)) + result = pytester.runpytest("--asyncio-mode=strict") + result.assert_outcomes(passed=2) + + +def test_sync_test_with_auto_mode_async_fixture_runs_for_each_loop_factory( + pytester: Pytester, +) -> None: + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") + pytester.makeconftest(dedent("""\ + import asyncio + + class CustomLoopA(asyncio.SelectorEventLoop): + pass + + class CustomLoopB(asyncio.SelectorEventLoop): + pass + + def pytest_asyncio_loop_factories(config, item): + return {"loop_a": CustomLoopA, "loop_b": CustomLoopB} + """)) + pytester.makepyfile(dedent("""\ + import asyncio + import pytest + + pytest_plugins = "pytest_asyncio" + + @pytest.fixture + async def loop_name(): + return type(asyncio.get_running_loop()).__name__ + + def test_sync(loop_name): + assert loop_name in ("CustomLoopA", "CustomLoopB") + """)) + result = pytester.runpytest("--asyncio-mode=auto") + result.assert_outcomes(passed=2) + + @pytest.mark.parametrize( "hook_body", (