Repository: pytest-dev/pytest-asyncio (language: python)

Issue:
`event_loop` not found for multiple test modules
I just want to use module scope fixture but I get an error:

**[environment]**
python==3.10.14
pytest==8.2.2
pytest-asyncio==0.23.7

**[Directory]**

`tests/conftest.py`:
```python
import pytest_asyncio
@pytest_asyncio.fixture(scope="module")
async def foo():
    yield "a value"
```
`tests/test_1.py`:
```python
import pytest
@pytest.mark.asyncio
async def test_func(foo):
        print("test_1 test_func", foo)
```
`tests/test_2.py`:
```python
import pytest
@pytest.mark.asyncio
async def test_func(foo):
    print("test_2 test_func", foo)
```
**[error]**

```text
# pytest -s tests/
============================= test session starts ==============================
platform linux -- Python 3.10.14, pytest-8.2.2, pluggy-1.5.0
rootdir: /home/sunyw
plugins: asyncio-0.23.7
asyncio: mode=strict
collected 2 items                                                              

tests/test_1.py .                                                        [ 50%]
tests/test_2.py E                                                        [100%]

==================================== ERRORS ====================================
_________________________ ERROR at setup of test_func __________________________
file /home/xxx/tests/test_2.py, line 3
  @pytest.mark.asyncio
  async def test_func(foo):
      print("test_2 test_func", foo)
file /home/xxx/tests/conftest.py, line 13
  @pytest_asyncio.fixture(scope="module")
  async def foo():
      yield "a value"
E       fixture 'tests/test_1.py::<event_loop>' not found
>       available fixtures: _session_event_loop, cache, capfd, capfdbinary, caplog, capsys, capsysbinary, doctest_namespace, event_loop, event_loop_policy, foo, monkeypatch, pytestconfig, record_property, record_testsuite_property, record_xml_attribute, recwarn, tests/test_2.py::<event_loop>, tmp_path, tmp_path_factory, tmpdir, tmpdir_factory, unused_tcp_port, unused_tcp_port_factory, unused_udp_port, unused_udp_port_factory
>       use 'pytest --fixtures [testpath]' for help on them.

/home/xxx/tests/conftest.py:13
=========================== short test summary info ============================
ERROR tests/test_2.py::test_func
========================== 1 passed, 1 error in 0.01s ==========================
```

according to the document [Decorators](https://pytest-asyncio.readthedocs.io/en/latest/reference/decorators/index.html):
> All scopes are supported, but if you use a non-function scope you will need to redefine the event_loop fixture to have the same or broader scope. Async fixtures need the event loop, and so must have the same or narrower scope than the event_loop fixture.

I add `event_loop` fixture in `conftest.py`:

```python
import pytest_asyncio
import asyncio
import pytest

@pytest.fixture(scope="module")
def event_loop(request):
    loop = asyncio.get_event_loop_policy().get_event_loop()
    yield loop
    loop.close()

@pytest_asyncio.fixture(scope="module")
async def foo():
    yield "a value"
```
But this error is still there.





Discussion / hints:
Same issue for me, I have the same `event_loop` as @TE-YongweiSun in `conftest.py` and with pytest-asyncio `v.0.21.1` no problem but with the latest version `v0.23.6` I have the notorious `attached to a different loop` issue.

Logs:
```
E   RuntimeError: Task <Task pending name='Task-6' coro=<_wrap_asyncgen_fixture.<locals>._asyncgen_fixture_wrapper.<locals>.setup() running at /opt/acc-py/venvs/acc-py-venv/lib/python3.11/site-packages/pytest_asyncio/plugin.py:326> cb=[_run_until_complete_cb() at /opt/acc-py/base/2023.06/lib/python3.11/asyncio/base_events.py:180]> got Future <Future pending cb=[Protocol._on_waiter_completed()]> attached to a different loop
```

For somehow the fixture `event_loop` is not used anymore.


Starting from pytest-asyncio v0.23, every level of the pytest test collection hierarchy has its own asyncio event loop. By default, tests marked with `@pytest.mark.asyncio` are run in a function-scope event loop, i.e. the event loop is refreshed before every test and closed afterwards. A test marked with `@pytest.mark.asyncio(scope="module")` is run in an asyncio event loop that is valid for the scope of an entire module, for example.

Unfortunately, pytest-asyncio v0.23 falsely assumes that the caching scope and event loop scope for async fixtures are the same. In your example, this means that `foo` is run in a module-scoped loop, whereas `tests/test_1.py::test_func` and `tests/test_2.py::test_func` are each run in a different, function-scope event loop.

You'll want to adjust your tests to use
```python
@pytest.mark.asyncio(scope="module")
async def test_func(foo):
    ...
```
for everything to run in the same event loop.

Is this something that you can do in your codebase (not the example)?
Closing this issue, because I don't have enough information to take any action.

Feel free to continue adding comments, though.
I have the same issue and would like the issue re-opened. Adding `scope` to my marks does no difference.

`conftest.py`
```python
import pytest_asyncio
@pytest_asyncio.fixture(scope="module")
async def foo():
    yield "a value"
```

`tests/test_1.py`
```python
import pytest
@pytest.mark.asyncio(scope="module")
async def test_func(foo):
    print("test_1 test_func", foo)
```

`tests/test_2.py`
```python
import pytest
@pytest.mark.asyncio(scope="module")
async def test_func(foo):
    print("test_2 test_func", foo)
```

Test run output:
```
================================================================================================ test session starts =================================================================================================
platform darwin -- Python 3.12.0, pytest-8.3.2, pluggy-1.5.0
rootdir: /private/var/folders/cq/77qr3h853b72gvhxlj7vzv7h0000gn/T/tmp.sso6hseXlG/foo
configfile: pyproject.toml
plugins: asyncio-0.23.8
asyncio: mode=Mode.STRICT
collected 2 items

tests/test_1.py .                                                                                                                                                                                              [ 50%]
tests/test_2.py E                                                                                                                                                                                              [100%]

======================================================================================================= ERRORS =======================================================================================================
____________________________________________________________________________________________ ERROR at setup of test_func _____________________________________________________________________________________________
file /private/var/folders/cq/77qr3h853b72gvhxlj7vzv7h0000gn/T/tmp.sso6hseXlG/foo/tests/test_2.py, line 4
  @pytest.mark.asyncio(scope="module")
  async def test_func(foo):
      print("test_2 test_func", foo)
file /private/var/folders/cq/77qr3h853b72gvhxlj7vzv7h0000gn/T/tmp.sso6hseXlG/foo/conftest.py, line 4
  @pytest_asyncio.fixture(scope="module")
  async def foo():
      yield "a value"
E       fixture 'tests/test_1.py::<event_loop>' not found
>       available fixtures: _session_event_loop, cache, capfd, capfdbinary, caplog, capsys, capsysbinary, doctest_namespace, event_loop, event_loop_policy, foo, foo::<event_loop>, monkeypatch, pytestconfig, record_property, record_testsuite_property, record_xml_attribute, recwarn, tests/test_2.py::<event_loop>, tmp_path, tmp_path_factory, tmpdir, tmpdir_factory, unused_tcp_port, unused_tcp_port_factory, unused_udp_port, unused_udp_port_factory
>       use 'pytest --fixtures [testpath]' for help on them.

/private/var/folders/cq/77qr3h853b72gvhxlj7vzv7h0000gn/T/tmp.sso6hseXlG/foo/conftest.py:4
============================================================================================== short test summary info ===============================================================================================
ERROR tests/test_2.py::test_func
============================================================================================= 1 passed, 1 error in 0.01s =============================================================================================
```
Thanks for the reproducer @cstruct !

This seems to be related to the fixture processing code in pytest-asyncio. Async fixtures are processed at collection time. It looks like _foo_ references the event loop of _test_1.py_, even though it's being re-evaluated in _test_2.py_. At that point, the event loop of _test_1.py_ is no longer available.

See also #668 
Thank you for the quick response! 🤗 
I'll spend some time this weekend and see if I can help with a fix. ☺️ 

Fix patch:
diff --git a/dependencies/default/requirements.txt b/dependencies/default/requirements.txt
index 3ac25aba..42cfc8d3 100644
--- a/dependencies/default/requirements.txt
+++ b/dependencies/default/requirements.txt
@@ -1,3 +1,3 @@
 # Always adjust install_requires in setup.cfg and pytest-min-requirements.txt
 # when changing runtime dependencies
-pytest >= 7.0.0,<9
+pytest >= 8.2,<9
diff --git a/docs/source/reference/changelog.rst b/docs/source/reference/changelog.rst
index c976b8da..abf27a0a 100644
--- a/docs/source/reference/changelog.rst
+++ b/docs/source/reference/changelog.rst
@@ -4,9 +4,11 @@ Changelog
 
 0.24.0 (UNRELEASED)
 ===================
+- BREAKING: Updated minimum supported pytest version to v8.2.0
 - Adds an optional `loop_scope` keyword argument to `pytest.mark.asyncio`. This argument controls which event loop is used to run the marked async test. `#706`_, `#871 <https://github.com/pytest-dev/pytest-asyncio/pull/871>`_
 - Deprecates the optional `scope` keyword argument to `pytest.mark.asyncio` for API consistency with ``pytest_asyncio.fixture``. Users are encouraged to use the `loop_scope` keyword argument, which does exactly the same.
 - Raises an error when passing `scope` or `loop_scope` as a positional argument to ``@pytest.mark.asyncio``. `#812 <https://github.com/pytest-dev/pytest-asyncio/issues/812>`_
+- Fixes a bug that caused module-scoped async fixtures to fail when reused in other modules `#862 <https://github.com/pytest-dev/pytest-asyncio/issues/862>`_ `#668 <https://github.com/pytest-dev/pytest-asyncio/issues/668>`_
 
 
 0.23.8 (2024-07-17)
diff --git a/setup.cfg b/setup.cfg
index 9947cbe3..c04d3884 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -41,7 +41,7 @@ include_package_data = True
 
 # Always adjust requirements.txt and pytest-min-requirements.txt when changing runtime dependencies
 install_requires =
-  pytest >= 7.0.0,<9
+  pytest >= 8.2,<9
 
 [options.extras_require]
 testing =


Test patch:
diff --git a/dependencies/pytest-min/constraints.txt b/dependencies/pytest-min/constraints.txt
index 65e3addb..f01a0eb7 100644
--- a/dependencies/pytest-min/constraints.txt
+++ b/dependencies/pytest-min/constraints.txt
@@ -11,10 +11,10 @@ iniconfig==2.0.0
 mock==5.1.0
 nose==1.3.7
 packaging==23.2
-pluggy==1.3.0
+pluggy==1.5.0
 py==1.11.0
 Pygments==2.16.1
-pytest==7.0.0
+pytest==8.2.0
 requests==2.31.0
 sortedcontainers==2.4.0
 tomli==2.0.1
diff --git a/dependencies/pytest-min/requirements.txt b/dependencies/pytest-min/requirements.txt
index 9fb33e96..918abfd5 100644
--- a/dependencies/pytest-min/requirements.txt
+++ b/dependencies/pytest-min/requirements.txt
@@ -1,3 +1,3 @@
 # Always adjust install_requires in setup.cfg and requirements.txt
 # when changing minimum version dependencies
-pytest[testing] == 7.0.0
+pytest[testing] == 8.2.0
diff --git a/pytest_asyncio/plugin.py b/pytest_asyncio/plugin.py
index a90e125e..178fcaa6 100644
--- a/pytest_asyncio/plugin.py
+++ b/pytest_asyncio/plugin.py
@@ -30,11 +30,13 @@
     overload,
 )
 
+import pluggy
 import pytest
 from pytest import (
     Class,
     Collector,
     Config,
+    FixtureDef,
     FixtureRequest,
     Function,
     Item,
@@ -62,12 +64,6 @@
 FixtureFunction = Union[SimpleFixtureFunction, FactoryFixtureFunction]
 FixtureFunctionMarker = Callable[[FixtureFunction], FixtureFunction]
 
-# https://github.com/pytest-dev/pytest/commit/fb55615d5e999dd44306596f340036c195428ef1
-if pytest.version_tuple < (8, 0):
-    FixtureDef = Any
-else:
-    from pytest import FixtureDef
-
 
 class PytestAsyncioError(Exception):
     """Base class for exceptions raised by pytest-asyncio"""
@@ -251,15 +247,8 @@ def _preprocess_async_fixtures(
                 or default_loop_scope
                 or fixturedef.scope
             )
-            if scope == "function":
-                event_loop_fixture_id: Optional[str] = "event_loop"
-            else:
-                event_loop_node = _retrieve_scope_root(collector, scope)
-                event_loop_fixture_id = event_loop_node.stash.get(
-                    # Type ignored because of non-optimal mypy inference.
-                    _event_loop_fixture_id,  # type: ignore[arg-type]
-                    None,
-                )
+            if scope == "function" and "event_loop" not in fixturedef.argnames:
+                fixturedef.argnames += ("event_loop",)
             _make_asyncio_fixture_function(func, scope)
             function_signature = inspect.signature(func)
             if "event_loop" in function_signature.parameters:
@@ -271,49 +260,26 @@ def _preprocess_async_fixtures(
                         f"instead."
                     )
                 )
-            assert event_loop_fixture_id
-            _inject_fixture_argnames(
-                fixturedef,
-                event_loop_fixture_id,
-            )
-            _synchronize_async_fixture(
-                fixturedef,
-                event_loop_fixture_id,
-            )
+            if "request" not in fixturedef.argnames:
+                fixturedef.argnames += ("request",)
+            _synchronize_async_fixture(fixturedef)
             assert _is_asyncio_fixture_function(fixturedef.func)
             processed_fixturedefs.add(fixturedef)
 
 
-def _inject_fixture_argnames(
-    fixturedef: FixtureDef, event_loop_fixture_id: str
-) -> None:
-    """
-    Ensures that `request` and `event_loop` are arguments of the specified fixture.
-    """
-    to_add = []
-    for name in ("request", event_loop_fixture_id):
-        if name not in fixturedef.argnames:
-            to_add.append(name)
-    if to_add:
-        fixturedef.argnames += tuple(to_add)
-
-
-def _synchronize_async_fixture(
-    fixturedef: FixtureDef, event_loop_fixture_id: str
-) -> None:
+def _synchronize_async_fixture(fixturedef: FixtureDef) -> None:
     """
     Wraps the fixture function of an async fixture in a synchronous function.
     """
     if inspect.isasyncgenfunction(fixturedef.func):
-        _wrap_asyncgen_fixture(fixturedef, event_loop_fixture_id)
+        _wrap_asyncgen_fixture(fixturedef)
     elif inspect.iscoroutinefunction(fixturedef.func):
-        _wrap_async_fixture(fixturedef, event_loop_fixture_id)
+        _wrap_async_fixture(fixturedef)
 
 
 def _add_kwargs(
     func: Callable[..., Any],
     kwargs: Dict[str, Any],
-    event_loop_fixture_id: str,
     event_loop: asyncio.AbstractEventLoop,
     request: FixtureRequest,
 ) -> Dict[str, Any]:
@@ -321,14 +287,12 @@ def _add_kwargs(
     ret = kwargs.copy()
     if "request" in sig.parameters:
         ret["request"] = request
-    if event_loop_fixture_id in sig.parameters:
-        ret[event_loop_fixture_id] = event_loop
+    if "event_loop" in sig.parameters:
+        ret["event_loop"] = event_loop
     return ret
 
 
-def _perhaps_rebind_fixture_func(
-    func: _T, instance: Optional[Any], unittest: bool
-) -> _T:
+def _perhaps_rebind_fixture_func(func: _T, instance: Optional[Any]) -> _T:
     if instance is not None:
         # The fixture needs to be bound to the actual request.instance
         # so it is bound to the same object as the test method.
@@ -337,28 +301,28 @@ def _perhaps_rebind_fixture_func(
             unbound, cls = func.__func__, type(func.__self__)  # type: ignore
         except AttributeError:
             pass
-        # If unittest is true, the fixture is bound unconditionally.
-        # otherwise, only if the fixture was bound before to an instance of
+        # Only if the fixture was bound before to an instance of
         # the same type.
-        if unittest or (cls is not None and isinstance(instance, cls)):
+        if cls is not None and isinstance(instance, cls):
             func = unbound.__get__(instance)  # type: ignore
     return func
 
 
-def _wrap_asyncgen_fixture(fixturedef: FixtureDef, event_loop_fixture_id: str) -> None:
+def _wrap_asyncgen_fixture(fixturedef: FixtureDef) -> None:
     fixture = fixturedef.func
 
     @functools.wraps(fixture)
     def _asyncgen_fixture_wrapper(request: FixtureRequest, **kwargs: Any):
-        unittest = fixturedef.unittest if hasattr(fixturedef, "unittest") else False
-        func = _perhaps_rebind_fixture_func(fixture, request.instance, unittest)
-        event_loop = kwargs.pop(event_loop_fixture_id)
-        gen_obj = func(
-            **_add_kwargs(func, kwargs, event_loop_fixture_id, event_loop, request)
+        func = _perhaps_rebind_fixture_func(fixture, request.instance)
+        event_loop_fixture_id = _get_event_loop_fixture_id_for_async_fixture(
+            request, func
         )
+        event_loop = request.getfixturevalue(event_loop_fixture_id)
+        kwargs.pop(event_loop_fixture_id, None)
+        gen_obj = func(**_add_kwargs(func, kwargs, event_loop, request))
 
         async def setup():
-            res = await gen_obj.__anext__()
+            res = await gen_obj.__anext__()  # type: ignore[union-attr]
             return res
 
         def finalizer() -> None:
@@ -366,7 +330,7 @@ def finalizer() -> None:
 
             async def async_finalizer() -> None:
                 try:
-                    await gen_obj.__anext__()
+                    await gen_obj.__anext__()  # type: ignore[union-attr]
                 except StopAsyncIteration:
                     pass
                 else:
@@ -380,27 +344,48 @@ async def async_finalizer() -> None:
         request.addfinalizer(finalizer)
         return result
 
-    fixturedef.func = _asyncgen_fixture_wrapper
+    fixturedef.func = _asyncgen_fixture_wrapper  # type: ignore[misc]
 
 
-def _wrap_async_fixture(fixturedef: FixtureDef, event_loop_fixture_id: str) -> None:
+def _wrap_async_fixture(fixturedef: FixtureDef) -> None:
     fixture = fixturedef.func
 
     @functools.wraps(fixture)
     def _async_fixture_wrapper(request: FixtureRequest, **kwargs: Any):
-        unittest = False if pytest.version_tuple >= (8, 2) else fixturedef.unittest
-        func = _perhaps_rebind_fixture_func(fixture, request.instance, unittest)
-        event_loop = kwargs.pop(event_loop_fixture_id)
+        func = _perhaps_rebind_fixture_func(fixture, request.instance)
+        event_loop_fixture_id = _get_event_loop_fixture_id_for_async_fixture(
+            request, func
+        )
+        event_loop = request.getfixturevalue(event_loop_fixture_id)
+        kwargs.pop(event_loop_fixture_id, None)
 
         async def setup():
-            res = await func(
-                **_add_kwargs(func, kwargs, event_loop_fixture_id, event_loop, request)
-            )
+            res = await func(**_add_kwargs(func, kwargs, event_loop, request))
             return res
 
         return event_loop.run_until_complete(setup())
 
-    fixturedef.func = _async_fixture_wrapper
+    fixturedef.func = _async_fixture_wrapper  # type: ignore[misc]
+
+
+def _get_event_loop_fixture_id_for_async_fixture(
+    request: FixtureRequest, func: Any
+) -> str:
+    default_loop_scope = request.config.getini("asyncio_default_fixture_loop_scope")
+    loop_scope = (
+        getattr(func, "_loop_scope", None) or default_loop_scope or request.scope
+    )
+    if loop_scope == "function":
+        event_loop_fixture_id = "event_loop"
+    else:
+        event_loop_node = _retrieve_scope_root(request._pyfuncitem, loop_scope)
+        event_loop_fixture_id = event_loop_node.stash.get(
+            # Type ignored because of non-optimal mypy inference.
+            _event_loop_fixture_id,  # type: ignore[arg-type]
+            "",
+        )
+    assert event_loop_fixture_id
+    return event_loop_fixture_id
 
 
 class PytestAsyncioFunction(Function):
@@ -555,13 +540,12 @@ def pytest_pycollect_makeitem_preprocess_async_fixtures(
     return None
 
 
-# TODO: #778 Narrow down return type of function when dropping support for pytest 7
 # 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)
 def pytest_pycollect_makeitem_convert_async_functions_to_subclass(
     collector: Union[pytest.Module, pytest.Class], name: str, obj: object
-) -> Generator[None, Any, None]:
+) -> Generator[None, pluggy.Result, None]:
     """
     Converts coroutines and async generators collected as pytest.Functions
     to AsyncFunction items.
@@ -772,11 +756,10 @@ def pytest_generate_tests(metafunc: Metafunc) -> None:
         )
 
 
-# TODO: #778 Narrow down return type of function when dropping support for pytest 7
 @pytest.hookimpl(hookwrapper=True)
 def pytest_fixture_setup(
     fixturedef: FixtureDef,
-) -> Generator[None, Any, None]:
+) -> Generator[None, pluggy.Result, None]:
     """Adjust the event loop policy when an event loop is produced."""
     if fixturedef.argname == "event_loop":
         # The use of a fixture finalizer is preferred over the
diff --git a/tests/async_fixtures/test_shared_module_fixture.py b/tests/async_fixtures/test_shared_module_fixture.py
new file mode 100644
index 00000000..ff1cb62b
--- /dev/null
+++ b/tests/async_fixtures/test_shared_module_fixture.py
@@ -0,0 +1,35 @@
+from textwrap import dedent
+
+from pytest import Pytester
+
+
+def test_asyncio_mark_provides_package_scoped_loop_strict_mode(pytester: Pytester):
+    pytester.makepyfile(
+        __init__="",
+        conftest=dedent(
+            """\
+            import pytest_asyncio
+            @pytest_asyncio.fixture(scope="module")
+            async def async_shared_module_fixture():
+                return True
+            """
+        ),
+        test_module_one=dedent(
+            """\
+            import pytest
+            @pytest.mark.asyncio
+            async def test_shared_module_fixture_use_a(async_shared_module_fixture):
+                assert async_shared_module_fixture is True
+            """
+        ),
+        test_module_two=dedent(
+            """\
+            import pytest
+            @pytest.mark.asyncio
+            async def test_shared_module_fixture_use_b(async_shared_module_fixture):
+                assert async_shared_module_fixture is True
+            """
+        ),
+    )
+    result = pytester.runpytest("--asyncio-mode=strict")
+    result.assert_outcomes(passed=2)
diff --git a/tests/test_is_async_test.py b/tests/test_is_async_test.py
index 12e791c1..e0df54de 100644
--- a/tests/test_is_async_test.py
+++ b/tests/test_is_async_test.py
@@ -1,6 +1,5 @@
 from textwrap import dedent
 
-import pytest
 from pytest import Pytester
 
 
@@ -74,13 +73,7 @@ def pytest_collection_modifyitems(items):
         )
     )
     result = pytester.runpytest("--asyncio-mode=strict")
-    if pytest.version_tuple < (7, 2):
-        # Probably related to https://github.com/pytest-dev/pytest/pull/10012
-        result.assert_outcomes(failed=1)
-    elif pytest.version_tuple < (8,):
-        result.assert_outcomes(skipped=1)
-    else:
-        result.assert_outcomes(failed=1)
+    result.assert_outcomes(failed=1)
 
 
 def test_returns_true_for_unmarked_coroutine_item_in_auto_mode(pytester: Pytester):