Pytest & CI

How to Scope Pytest Fixtures for Async Tests

How to Scope Pytest Fixtures for Async Tests

Integrating pytest-asyncio into a mature test suite requires a fundamental shift in how fixture lifecycles are conceptualized. Unlike synchronous testing, where fixture teardown aligns predictably with Python's garbage collection and pytest's internal cleanup hooks, asynchronous testing introduces an independent execution model governed by the asyncio event loop. The core challenge lies in decoupling fixture scope from test execution while ensuring that teardown operations execute within an active, valid event loop. Modern pytest-asyncio (v0.23+) resolves this through explicit loop_scope configuration and strict dependency injection validation, but misalignment remains a leading cause of RuntimeError: Event loop is closed and ScopeMismatch failures in production CI/CD pipelines.

Understanding how to scope pytest fixtures for async tests requires mapping pytest's hierarchical fixture manager to asyncio's task scheduler. When scopes are misconfigured, fixtures may attempt to yield resources after the loop has terminated, or background tasks may leak across test boundaries, causing non-deterministic failures and memory exhaustion. For teams navigating the broader ecosystem of test configuration, aligning plugin behavior with architectural constraints is critical. See Advanced Pytest Architecture & Configuration for foundational strategies on plugin lifecycle management and configuration inheritance. This guide provides a production-grade methodology for scoping async fixtures, diagnosing loop lifecycle conflicts, and implementing robust teardown guarantees.

The Core Problem: Async Event Loops vs. Traditional Fixture Scopes

Pytest's traditional fixture resolution operates synchronously. When a test completes, pytest iterates through the fixture stack in reverse order, executing teardown logic before moving to the next test. This model assumes a single-threaded, blocking execution flow where resource cleanup occurs immediately after the test function returns. In asynchronous contexts, this assumption breaks down. The asyncio event loop manages its own task queue, coroutine scheduling, and I/O multiplexing. When pytest-asyncio wraps an async test, it creates a dedicated event loop, executes the test coroutine, and then closes the loop to prevent cross-test state pollution.

The conflict arises when a fixture's scope outlives the test's event loop. Consider a module-scoped async fixture that initializes a database connection pool. If the fixture yields the pool and attempts to close it during teardown, but the test's event loop has already been terminated by pytest-asyncio, the teardown coroutine will raise RuntimeError: Event loop is closed. This occurs because the fixture manager attempts to run the async generator's post-yield block outside the active loop context. Historically, developers worked around this by forcing synchronous teardown using asyncio.run() or loop.run_until_complete(), but these patterns introduce reentrancy issues, mask underlying scope mismatches, and violate asyncio's single-loop-per-thread policy.

Furthermore, pytest's scope inheritance rules do not natively account for event loop boundaries. A session-scoped fixture instantiated in conftest.py expects to persist across the entire test run. However, if pytest-asyncio is configured to recreate the event loop per test (the default in older versions or strict mode), the session-scoped fixture loses its loop context after the first test completes. Subsequent tests attempting to use the fixture will either fail with loop closure errors or trigger silent loop recreation, leading to resource duplication and unpredictable teardown ordering. The resolution requires explicit alignment between pytest's fixture scope, pytest-asyncio's loop_scope parameter, and the underlying event loop policy. Without this alignment, teardown race conditions and state leakage become inevitable in concurrent or parallelized test execution.

Mapping Fixture Scopes to Async Lifecycles

Pytest defines four primary fixture scopes: function, class, module, and session. In synchronous testing, these scopes dictate instantiation frequency and teardown timing. In asynchronous testing, they additionally dictate event loop attachment and lifecycle boundaries. pytest-asyncio v0.23+ introduces the loop_scope parameter to explicitly bind a fixture's event loop to its pytest scope, decoupling test execution loops from fixture management loops.

When asyncio_mode = "auto" is configured in pyproject.toml, pytest-asyncio automatically detects async tests and wraps them in an event loop. However, automatic detection does not resolve scope mismatches. You must explicitly declare loop_scope to match the fixture's pytest scope. For example:

  • @pytest.fixture(scope="function", loop_scope="function"): Creates a new event loop per test. Ideal for isolated unit tests, HTTP client mocks, or ephemeral database transactions. The loop closes immediately after teardown.
  • @pytest.fixture(scope="module", loop_scope="module"): Shares a single event loop across all tests in a file. Suitable for module-level resource initialization like local Redis instances or file-based test servers.
  • @pytest.fixture(scope="session", loop_scope="session"): Maintains a single event loop for the entire test run. Required for expensive resources like Dockerized database containers, Kafka brokers, or connection pools.

The loop_scope parameter ensures that the fixture's setup and teardown execute within the same loop context. Without it, pytest-asyncio defaults to function loop scope, causing higher-scoped fixtures to lose their loop attachment when tests complete. This misalignment is the primary cause of teardown failures in CI environments where tests run in parallel or across multiple workers.

Understanding how scope inheritance interacts with conftest.py hierarchy is equally critical. Fixtures defined in parent directories inherit their scope relative to the test file's location, but event loop policies do not automatically propagate. If a root conftest.py defines a session-scoped async fixture, child directories will share the fixture instance, but each test file may still trigger loop recreation if loop_scope is omitted. Explicitly declaring loop_scope eliminates this ambiguity and ensures deterministic lifecycle management. For teams seeking deeper insight into fixture lifecycle fundamentals and scope inheritance patterns, refer to Mastering Pytest Fixtures for comprehensive architectural guidance.

Implementing Async Generator Fixtures with Correct Scoping

Async generator fixtures using yield provide the cleanest pattern for setup/teardown separation in asynchronous contexts. The generator yields the resource to the test, suspends execution, and resumes after the test completes to perform cleanup. However, async generators require strict adherence to event loop context and exception handling to prevent resource leaks.

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

@pytest.fixture(scope="module", loop_scope="module")
async def db_pool() -> AsyncGenerator[AsyncConnectionPool, None]:
 """Module-scoped async database connection pool with guaranteed teardown."""
 pool = AsyncConnectionPool(dsn="postgresql+asyncpg://test:test@localhost:5432/testdb")
 await pool.connect()
 
 try:
 yield pool
 finally:
 # Teardown MUST run inside the active event loop
 loop = asyncio.get_running_loop()
 # Ensure all pending tasks are cancelled before closing connections
 pending = asyncio.all_tasks(loop)
 for task in pending:
 task.cancel()
 await asyncio.gather(*pending, return_exceptions=True)
 await pool.close()

Key implementation rules:

  1. Always use asyncio.get_running_loop() in teardown: The deprecated asyncio.get_event_loop() may return a closed loop or a loop from a different thread, causing RuntimeError or silent failures. get_running_loop() guarantees access to the currently executing loop context.
  2. Wrap teardown in try/finally: Async fixtures can raise exceptions during test execution. The finally block ensures cleanup executes regardless of test outcome, preventing connection pool exhaustion.
  3. Cancel background tasks explicitly: If the fixture spawns background workers or listeners, they must be cancelled before closing the primary resource. asyncio.all_tasks() retrieves pending tasks, and asyncio.gather(..., return_exceptions=True) prevents unhandled cancellation errors from propagating.
  4. Avoid synchronous cleanup in async fixtures: Calling pool.close() synchronously or using time.sleep() blocks the event loop and triggers pytest-asyncio's timeout mechanisms. All teardown must be awaited.

When using async with context managers, ensure the manager implements __aenter__ and __aexit__ correctly. pytest-asyncio does not automatically wrap context managers; you must explicitly manage the lifecycle via yield.

Edge Case: Cross-Scope Fixture Dependencies & Event Loop Isolation

Pytest enforces strict dependency injection rules: a fixture cannot request a fixture with a narrower scope. A session-scoped fixture cannot depend on a function-scoped fixture because the narrower-scoped resource would be destroyed before the wider-scoped fixture completes its lifecycle. In async contexts, this violation triggers ScopeMismatch errors and often masks underlying event loop isolation failures.

Python
# BROKEN: ScopeMismatch violation
@pytest.fixture(scope="session", loop_scope="session")
async def session_cache():
 return {}

@pytest.fixture(scope="function", loop_scope="function")
async def ephemeral_token():
 return "temp_token"

# This will raise ScopeMismatch: session_cache requests ephemeral_token
@pytest.fixture(scope="session", loop_scope="session")
async def broken_session_fixture(session_cache, ephemeral_token):
 session_cache["token"] = ephemeral_token
 return session_cache

The error occurs because pytest's dependency resolver detects that ephemeral_token will be torn down after the first test, but broken_session_fixture expects to persist across the session. In async testing, this is compounded by loop scope mismatches: session_cache attaches to the session loop, while ephemeral_token attaches to a per-test loop. Attempting to inject the function-scoped fixture into the session-scoped one forces cross-loop execution, which asyncio explicitly forbids.

Resolution Strategies:

  1. Elevate Dependent Fixture Scope: Change ephemeral_token to scope="session" if the token does not require per-test isolation.
  2. Parameterization Over Injection: Pass dynamic values via @pytest.mark.parametrize instead of fixture injection. This keeps scopes aligned while providing test-specific data.
  3. Factory Pattern: Replace the fixture with a factory function that returns a coroutine. The session-scoped fixture calls the factory during test execution, avoiding direct dependency injection.
  4. Scope Isolation via loop_scope: If cross-scope injection is unavoidable (e.g., testing middleware), explicitly configure loop_scope to match the narrowest scope, but be aware this forces loop recreation per test and degrades performance.

Refactoring to eliminate cross-scope dependencies is the only production-safe approach. Async event loops are not designed for shared mutable state across different loop contexts. Isolating resources by scope prevents race conditions and ensures deterministic teardown ordering.

Rapid Diagnosis: Minimal Reproducible Examples & Profiling

When async fixture scoping fails, rapid diagnosis requires isolating the loop lifecycle violation and tracing fixture instantiation order. The following workflow accelerates root cause identification in complex test suites.

1. Visualize Fixture Execution Order Run pytest --setup-show to display fixture setup and teardown sequence. This reveals whether teardown occurs before or after the event loop closes. Combine with pytest --fixtures to inspect scope declarations and dependency chains.

2. Trace Event Loop Boundaries Insert diagnostic logging at fixture setup and teardown boundaries:

Python
import logging
import asyncio

logger = logging.getLogger(__name__)

@pytest.fixture(scope="function", loop_scope="function")
async def debug_fixture():
 logger.info(f"Setup: Loop ID {id(asyncio.get_running_loop())}")
 yield "resource"
 logger.info(f"Teardown: Loop ID {id(asyncio.get_running_loop())}")

If teardown logs show a different loop ID or raise RuntimeError, the fixture is executing outside its expected loop context.

3. Profile Memory and Task Leaks Enable asyncio debug mode and tracemalloc to detect unclosed resources:

Python
import tracemalloc
import asyncio

tracemalloc.start()
asyncio.set_debug(True)

# Run tests with: pytest --asyncio-mode=auto
# Check output for: "Task was destroyed but it is pending!" or "Unclosed connection"

asyncio.set_debug(True) logs slow callbacks, unhandled exceptions, and pending task destruction. tracemalloc identifies memory leaks from unclosed async generators or connection pools.

4. Isolate Loop Policy Conflicts Custom event loop policies (e.g., uvloop, asyncio.ProactorEventLoop) can interfere with pytest-asyncio's loop management. If tests pass locally but fail in CI, verify that the CI environment uses the same Python version and loop policy. Force a consistent policy in conftest.py:

Python
import sys
import asyncio

@pytest.fixture(scope="session", autouse=True)
def enforce_loop_policy():
 if sys.platform == "win32":
 asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy())
 yield

5. Minimal Reproducible Example (MRE) Template When reporting issues, strip all business logic and isolate the fixture:

Python
import pytest
import asyncio

@pytest.fixture(scope="session", loop_scope="session")
async def session_fixture():
 await asyncio.sleep(0)
 yield "data"

@pytest.mark.asyncio
async def test_session(session_fixture):
 assert session_fixture == "data"

If the MRE fails, the issue is a pytest-asyncio version incompatibility or configuration error. If it passes, the failure originates from application-level async code or cross-scope dependencies.

Advanced Configuration: Customizing Loop Policies & Fixture Teardown Order

Production test suites often require fine-grained control over event loop behavior, particularly when integrating with third-party async libraries or optimizing CI/CD execution. pytest-asyncio allows explicit loop policy injection and teardown order customization through configuration overrides and plugin hooks.

Custom Loop Policy Configuration Override the default event loop policy to match your runtime environment. This is critical for Windows CI runners or high-throughput Linux environments:

Python
# conftest.py
import asyncio
import sys
import pytest

def pytest_configure(config):
 if sys.platform == "linux":
 try:
 import uvloop
 asyncio.set_event_loop_policy(uvloop.EventLoopPolicy())
 except ImportError:
 pass

Ensure loop_scope declarations remain consistent with the policy. uvloop and proactor loops handle task cancellation differently; verify that your fixture teardown explicitly cancels pending tasks rather than relying on implicit garbage collection.

Managing Background Tasks During Teardown Long-running async fixtures (e.g., WebSocket servers, message queue consumers) must gracefully shut down. Implement a teardown coordinator:

Python
@pytest.fixture(scope="module", loop_scope="module")
async def async_server():
 server = start_background_server()
 task = asyncio.create_task(server.run_forever())
 
 try:
 yield server
 finally:
 server.stop()
 task.cancel()
 try:
 await asyncio.wait_for(task, timeout=5.0)
 except asyncio.TimeoutError:
 task.cancel()
 await asyncio.gather(task, return_exceptions=True)

Using asyncio.wait_for() prevents CI hangs caused by unresponsive background tasks. The timeout ensures the test suite proceeds even if a task deadlocks, while return_exceptions=True prevents cancellation errors from failing the entire test run.

CI/CD Optimization Strategies

  • Disable Debug Mode in CI: asyncio.set_debug(True) adds significant overhead. Enable it only in local debugging sessions.
  • Parallelize by Module Scope: Use pytest-xdist with --dist=loadgroup to isolate module-scoped fixtures per worker. This prevents cross-worker loop conflicts.
  • Explicit Teardown Ordering: If multiple session-scoped fixtures depend on each other, declare them in reverse teardown order in conftest.py to ensure deterministic cleanup.

FAQ: Async Fixture Scoping

Can I use session-scoped fixtures with pytest-asyncio? Yes, but you must configure asyncio_mode = auto in pyproject.toml and ensure the event loop persists for the session. Use @pytest.fixture(scope='session', loop_scope='session') in pytest-asyncio >= 0.23. This binds the fixture to a single loop that survives across all tests, preventing premature closure and enabling efficient resource sharing.

Why does my async fixture raise 'ScopeMismatch' when injected into a sync test?pytest-asyncio enforces strict scope alignment. Async fixtures must be consumed by async tests or explicitly wrapped. Converting the test to async def resolves the mismatch. Alternatively, downgrade the fixture scope to match the test runner's lifecycle, or use a synchronous wrapper that calls asyncio.run() internally. Mixing sync and async scopes without explicit bridging violates pytest's dependency graph.

How do I debug fixture teardown order in async tests? Run pytest --setup-show to visualize execution order. Use logging inside fixture setup/teardown blocks to trace loop IDs and execution timestamps. For profiling, integrate pytest-asyncio with async-profiler or enable asyncio.set_debug(True) to trace event loop blocking during teardown. Verify that teardown executes before the loop closes by checking asyncio.get_running_loop() in the finally block.

Does conftest.py hierarchy affect async fixture scoping? Yes. Async fixtures defined in parent conftest.py files inherit their scope relative to the test file's directory. Cross-directory scope resolution can cause unexpected loop recreation if loop_scope is not explicitly declared. Pin scopes explicitly and avoid implicit conftest inheritance for async resources. Isolate session-scoped async fixtures in a dedicated tests/conftest.py to prevent accidental scope dilution across subdirectories.