Pytest & CI

How to Scope Pytest Fixtures for Async Tests

A module- or session-scoped async fixture that closes a connection pool during teardown frequently fails with RuntimeError: Event loop is closed, and a wider fixture that requests a narrower one raises ScopeMismatch during collection. Both symptoms have the same root cause: in pytest-asyncio, a fixture's pytest scope and the event loop it runs on are configured separately, and when they diverge the fixture's post-yield cleanup executes on a loop that no longer exists. This guide shows how to bind the two together with loop_scope so async setup and teardown always run on the same live loop.

Prerequisites

  • pytest >= 8.0 and pytest-asyncio >= 0.23 (the loop_scope parameter on @pytest_asyncio.fixture and @pytest.mark.asyncio was added in 0.23; earlier releases have only the global event_loop fixture override).
  • Python 3.9+ (asyncio.get_running_loop(), asyncio.all_tasks()).
  • asyncio_mode = "auto" and a default loop scope set in pyproject.toml:
TOML
# pyproject.toml
[tool.pytest.ini_options]
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function"  # explicit default; silences the 0.23 deprecation warning

This guide builds on the scope rules covered in Mastering Pytest Fixtures; the loop lifecycle itself is dissected in Debugging Async Code and Event Loops.

Solution

Declare the same value for scope and loop_scope, and run teardown inside a try/finally block that acquires the live loop with asyncio.get_running_loop():

Python
import asyncio
import pytest_asyncio
from typing import AsyncGenerator
from myapp.db import AsyncConnectionPool

@pytest_asyncio.fixture(scope="module", loop_scope="module")
async def db_pool() -> AsyncGenerator[AsyncConnectionPool, None]:
    """Module-scoped pool whose loop survives for the whole module."""
    pool = AsyncConnectionPool(dsn="postgresql+asyncpg://test:test@localhost/testdb")
    await pool.connect()                       # setup runs on the module loop
    try:
        yield pool                             # tests share this single pool
    finally:
        # get_running_loop() is the loop pytest-asyncio is still driving;
        # get_event_loop() may hand back a closed/foreign loop here.
        loop = asyncio.get_running_loop()
        # Cancel any tasks the pool spawned before closing it, so close()
        # is not interrupted by a still-pending background coroutine.
        pending = [t for t in asyncio.all_tasks(loop) if not t.done()]
        for task in pending:
            task.cancel()
        # return_exceptions=True swallows the CancelledError each task raises.
        await asyncio.gather(*pending, return_exceptions=True)
        await pool.close()                     # teardown runs on the SAME loop

The mapping you almost always want:

Fixture scopeloop_scopeUse for
functionfunctionisolated unit tests, ephemeral transactions, HTTP mocks
modulemodulea local test server or pool shared by one file
sessionsessionDockerised databases, Kafka brokers, expensive pools

The loop scope ladder below shows why a mismatch breaks teardown.

Loop scope vs fixture scope When loop_scope matches the fixture scope the loop spans both tests; when it defaults to function the loop closes before module teardown. Matching loop_scope keeps teardown alive loop_scope="module" (correct) one loop: setup, tests, teardown test 1 test 2 teardown runs on a live loop loop_scope="function" (default) loop A loop B loop A closed before module teardown RuntimeError: Event loop is closed raised when scope and loop_scope diverge
A module fixture with the default function loop_scope tears down on a loop that was already closed after the first test, raising "Event loop is closed". Matching loop_scope to the fixture scope keeps one loop alive across the whole scope.

Why this works

pytest-asyncio creates one event loop per loop_scope boundary and runs every coroutine inside that scope on it, including the generator resume that executes your finally block. When loop_scope equals the fixture's scope, the loop is guaranteed to still be running when teardown fires, so await pool.close() succeeds. When loop_scope is left at its function default while the fixture is module or session, the loop is torn down after the first test and the deferred cleanup awaits on a dead loop — exactly the RuntimeError: Event loop is closed engineers see in CI but rarely locally, where a single test masks the boundary.

Edge cases and failure modes

  • Cross-scope dependency raises ScopeMismatch. A session-scoped fixture cannot request a function-scoped one: the narrow resource is destroyed first, and the two attach to different loops. Elevate the dependency's scope, pass the value via @pytest.mark.parametrize, or return a factory the wider fixture calls during the test rather than injecting directly.
  • asyncio.get_event_loop() in teardown. It may return a closed loop or one from another thread. Always use asyncio.get_running_loop() inside async fixtures.
  • Unmatched conftest.py inheritance. An async fixture inherited from a parent conftest.py keeps its declared loop_scope, but if a child file omits it the loop is recreated per test. Pin loop_scope explicitly on shared async fixtures — see Managing Conftest Hierarchies for inheritance rules.
  • Background tasks outliving the fixture. WebSocket servers or queue consumers must be cancelled before the primary resource closes, then awaited with asyncio.wait_for(task, timeout=...) and return_exceptions=True so a deadlocked task does not hang the suite.
  • uvloop / ProactorEventLoop policies. Custom loop policies change cancellation timing. Set the policy in pytest_configure and never rely on implicit GC to clean up tasks; cancel them explicitly.

Frequently Asked Questions

Can I use session-scoped fixtures with pytest-asyncio? Yes. In pytest-asyncio 0.23 or newer, declare both scope="session" and loop_scope="session" so the fixture and its event loop share one lifetime. Without a matching loop_scope the loop is recreated per test and the session fixture loses its loop context after the first test.

Why does my async fixture raise RuntimeError: Event loop is closed during teardown? The fixture's scope outlives the loop its post-yield cleanup runs in. The default loop_scope is function, so a module or session fixture tries to await teardown on a loop pytest-asyncio already closed. Set loop_scope to match the fixture scope so setup and teardown share one loop.

Why does an async fixture raise ScopeMismatch when it depends on another fixture? A wider-scoped fixture cannot request a narrower-scoped one, because the narrow resource is torn down first. A session fixture depending on a function fixture raises ScopeMismatch. Elevate the dependency's scope, pass the value via parametrize, or use a factory instead of direct injection.

← Back to Mastering Pytest Fixtures