Modes, Markers, and Event Loops
Modes, Markers, and Event Loops
Plain pytest cannot await coroutines — without a plugin, async tests are skipped with "async def functions are not natively supported". pytest-asyncio wraps each async test in an event loop.
# pyproject.toml
[tool.pytest.ini_options]
asyncio_mode = "auto" # no markers needed; "strict" (default) requires them
In strict mode every coroutine test carries the marker:
import pytest
@pytest.mark.asyncio
async def test_fetch(client):
resp = await client.get("/health")
assert resp.status == 200
By default each test gets a function-scoped loop, created and closed around it — anything bound to one loop (clients, locks) must not leak into the next test. Unmarked async tests in strict mode are silently skipped, which is the most common "my tests never ran" bug.
Async Fixtures and Composition
Async Fixtures and Composition
Async fixtures need the plugin’s decorator in strict/standard mode so they are awaited on the right loop:
import pytest_asyncio
@pytest_asyncio.fixture
async def db_url(tmp_path):
server = await start_db(tmp_path) # awaited setup
yield f"sqlite+aiosqlite:///{server}"
await server.stop() # awaited teardown
@pytest_asyncio.fixture(loop_scope="session")
async def app_client():
async with AsyncClient(app=app) as c:
yield c
loop_scope (matching the test’s @pytest.mark.asyncio(loop_scope="session")) lets fixtures and tests share one loop across a module or session — necessary when a background task or connection pool must survive multiple tests. Keep the scope change deliberate: a session loop widens the blast radius of leaked tasks.
Pitfalls and Interview Angles
Pitfalls and Interview Angles
- Coroutine never awaited. Creating a task and forgetting
await/asyncio.gatheryields a RuntimeWarning and a test that asserts nothing; enablefilterwarnings = errorto convert warnings into failures. - Loop mismatch. Binding an object on loop A and awaiting it on loop B raises "attached to a different loop" — scopes must match across fixture and test.
- Order dependence.
asyncio.create_taskwork continues until the loop closes; cancel or await tasks in teardown so tests stay isolated. - Timeouts. Pair with
pytest-timeoutso a hung await fails fast instead of stalling CI.
Interview angle: explain the loop lifecycle pytest-asyncio manages (create → run test → run teardown → close), why asyncio.run per test is the default isolation strategy, and how you would test code that assumes a long-lived loop.
Quiz
1. In strict mode, what happens to an unmarked async def test?
2. Why does an async fixture use @pytest_asyncio.fixture instead of @pytest.fixture?
3. Error "object attached to a different loop" usually means…
Flashcards
Question
asyncio_mode
Click to reveal answer
Answer
strict (default): @pytest.mark.asyncio required; auto: plugin wraps all async tests automatically.
Question
@pytest_asyncio.fixture
Click to reveal answer
Answer
Declares an async fixture so setup/teardown coroutine steps run on the test’s loop.
Question
loop_scope
Click to reveal answer
Answer
Chooses which loop a test/fixture binds to (function default; session for shared pools).
Question
Skipped async test symptom
Click to reveal answer
Answer
Unmarked async def in strict mode never runs — check pytest’s skip warnings in CI.
Revision Notes
Key Takeaways
- 1. pytest-asyncio runs each coroutine test inside a managed event loop; modes decide whether markers are mandatory
- 2. Async fixtures need the plugin decorator, and loop scopes must match across fixture and test
- 3. Make warnings errors so un-awaited coroutines fail loudly instead of passing vacuously
Interview Tips
- • Explain the default function-scoped loop and why sharing loops across tests trades isolation for realism
- • Describe diagnosing "tests pass but never executed": skip warnings plus --co to inspect collection
Cheat Sheet
asyncio_mode strict → @pytest.mark.asyncio; async fixtures → @pytest_asyncio.fixture; default loop scope function; match loop_scope; unmarked async tests skip.