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.

How loop_scope decides whether async teardown runs on a live loop Two timelines for a module-scoped async fixture. In the top lane loop_scope equals module, so a single event loop spans setup, test one, test two and teardown, and await pool.close succeeds. In the bottom lane loop_scope stays at the function default, so loop A is closed after test one and a second loop B runs test two; when module teardown finally fires it awaits the already-closed loop A and raises RuntimeError Event loop is closed. loop_scope decides which loop teardown runs on loop_scope="module" — matches the fixture scope setup test 1 test 2 teardown close() ✓ live loop One loop spans setup, both tests, and teardown — await pool.close() runs on the same live loop. loop_scope="function" (default) — narrower than the module fixture loop A setup · test 1 closed after test 1 loop B test 2 module teardown awaits closed loop A RuntimeError: Event loop is closed post-yield cleanup awaits a loop pytest-asyncio already tore down
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.

The mismatch is easiest to see as two lifetimes drawn against each other.

A session fixture and a function-scoped loop A sequence diagram with three lanes: the session fixture, the event loop, and the test function. The session fixture creates a connection on the first loop, the test runs and the loop closes at the end of the test, and the next test opens a new loop while the fixture still holds the connection bound to the closed one. A session fixture and a function-scoped loop session fixture event loop test function create connection run test 1 loop closes connection now orphaned Widening the fixture scope without widening the loop scope is the whole bug.
The connection outlives the loop it was created on, so the next await against it raises rather than reconnecting.

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.

Auditing scopes before they bite

Scope mismatches are cheap to prevent and expensive to debug, because the failure surfaces in the second test rather than in the one that is wrong. Two commands and one rule catch nearly all of them.

The first command is pytest --setup-show, which prints every fixture setup and teardown with its scope as the session runs. Read it once for a file that uses async fixtures and the ordering problem becomes obvious: a SETUP S db line followed by repeated SETUP F event_loop lines means the connection is older than the loop it will be used on.

Bash
$ pytest --setup-show tests/test_orders.py -q
SETUP    S db_pool
        SETUP    F event_loop
        tests/test_orders.py::test_create (fixtures used: db_pool, event_loop)
        TEARDOWN F event_loop        # loop dies here...
        SETUP    F event_loop        # ...a different loop for the next test
        tests/test_orders.py::test_cancel (fixtures used: db_pool, event_loop)
TEARDOWN S db_pool                   # torn down on a loop it never saw

The second is pytest --fixtures -v, which lists every visible fixture with its scope and defining file. Scanning it for async fixtures whose scope is wider than function gives you the audit list in one pass; anything on that list must have a matching loop scope declared next to it.

The rule is simpler than either command: an async fixture may not be wider in scope than the loop it awaits on. With pytest-asyncio 0.23+ that means declaring both explicitly, and keeping them in the same place so a later edit cannot separate them:

Python
import pytest
import pytest_asyncio

@pytest.fixture(scope="session")
def event_loop_policy():
    # Policy is safe to share; the loop itself is created per scope by the plugin.
    import asyncio
    return asyncio.DefaultEventLoopPolicy()

@pytest_asyncio.fixture(scope="session", loop_scope="session")
async def db_pool():
    pool = await create_pool(dsn="postgresql:///test")
    yield pool                       # lives exactly as long as the session loop
    await pool.close()               # awaited on the same loop that created it

@pytest.mark.asyncio(loop_scope="session")
async def test_create(db_pool):
    assert await db_pool.fetchval("SELECT 1") == 1

Two details make this stick in a real suite. Put the loop_scope argument on the fixture and on every test that consumes it — a test that omits it runs on a function-scoped loop and hits the same mismatch from the other direction. And set asyncio_default_fixture_loop_scope in the ini file so new fixtures inherit the intended default instead of the plugin's, which removes the most common way this regresses six months later.

When a suite mixes both, run the async tests in their own session with a distinct marker. Two loop scopes in one process is legal but hard to reason about, and the cost of a second pytest invocation is far lower than the cost of an intermittent teardown error nobody can reproduce. The audit is easier to keep up if the intended pairing is written down once, in the ini file, rather than repeated on every fixture:

TOML
[tool.pytest.ini_options]
asyncio_mode = "strict"                       # markers are required, never implicit
asyncio_default_fixture_loop_scope = "function"  # new fixtures inherit this

With strict mode, a coroutine test without a marker is reported as an error rather than silently skipped, which removes the other half of the async-fixture confusion: a test that never ran but reported as passed. Setting the default fixture loop scope explicitly means a future fixture added without a loop_scope argument matches the plugin's documented behaviour rather than whatever the installed minor version happens to default to. Both settings are one line and both fail loudly, which is the property you want from configuration that guards an intermittent bug.

The four settings that keep async scopes aligned A four-row checklist of the settings that keep async fixture scope and loop scope consistent: strict asyncio mode, an explicit default fixture loop scope, matching loop_scope on fixture and test, and a pinned plugin version. The four settings that keep async scopes aligned asyncio_mode = strict an unmarked coroutine test errors instead of skipping default fixture loop scope new fixtures inherit an intended value loop_scope on both sides fixture and consuming test must agree pinned plugin version the default changed across minor releases
Three lines of configuration and one lockfile entry remove the whole class of scope-mismatch teardown errors.

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