Explicit is Better Than Implicit: Mastering Pytest Fixtures and Async Testing
Source: Dev.to
The Problem: Event Loops Are Confusing
Here’s the thing about async Python: your async/await code needs an event loop to run. That’s not optional. And there are multiple event loop implementations out there:
- asyncio – Python’s built‑in standard library solution
- trio – Third‑party library with structured concurrency
- curio – Another third‑party option (less common these days)
When you’re testing async code with pytest, you need something to manage these event loops. Enter pytest-anyio – a plugin that’s supposed to make your life easier. Spoiler alert: it doesn’t always.
What Actually Are Pytest Fixtures?
Before we dive into the async madness, let’s talk about fixtures. If you’ve used pytest, you’ve probably seen them. If you haven’t understood them, you’re not alone.
Fixtures are just functions that set up stuff for your tests. They run before your test, give your test what it needs, and clean up after. Think of them as the responsible adult in the room.
Dependency Injection (The Good Kind)
def test_something(database_connection):
# pytest automatically gives you the connection
result = database_connection.query("SELECT * FROM users")
assert result is not None
No manual setup. No global variables. Just declare what you need, and pytest handles it.
Setup and Teardown Without the Boilerplate
import pytest
@pytest.fixture
async def create_test_tables():
# Setup: runs before your test
async with engine.begin() as conn:
await conn.run_sync(SQLModel.metadata.create_all)
yield # Your test runs here
# Teardown: runs after your test
async with engine.begin() as conn:
await conn.run_sync(SQLModel.metadata.drop_all)
Everything before yield is setup. Everything after is cleanup. Your test gets a clean database every time.
Scopes: Because Not Everything Needs to Run Every Time
function(default) – Runs for every single testclass– Runs once per test classmodule– Runs once per test filesession– Runs once for your entire test suite
Choosing the right scope matters for performance.
The anyio Backend Problem
When you use pytest-anyio to test async code, it needs to know which event loop to use. By default, it tries to be helpful and test against all available backends.
What actually happens:
- You write tests using
asyncio(because that’s what FastAPI uses). - You install
pytest-anyioto test your async code. pytest-anyiosees you havetrioinstalled (maybe from another project).- It tries to run your tests with trio.
- Your
aiosqlitedriver explodes because it only works with asyncio.
You get errors like ModuleNotFoundError: No module named 'trio' or RuntimeError: no running event loop, and you start questioning your career choices.
The Solution: Be Explicit
Stop letting pytest-anyio guess. Tell it exactly what you want.
Step 1: Create an anyio_backend Fixture
# tests/conftest.py
import pytest
@pytest.fixture(scope="session")
def anyio_backend():
"""
Use asyncio for all async tests. Period.
"""
return "asyncio"
This fixture runs once per test session and tells pytest-anyio: “Use asyncio. Don’t try to be clever.”
Step 2: Make Sure trio Isn’t Interfering
If you don’t need trio, uninstall it:
pip uninstall trio
Step 3: Mark Your Async Tests Properly
import pytest
@pytest.mark.anyio(backend='asyncio')
async def test_create_and_get_example(client, create_test_tables):
# Your test code here
pass
The backend='asyncio' parameter is redundant if you have the fixture, but it makes your intent crystal clear.
Real‑World Example: Testing FastAPI with SQLModel
The Database Setup Fixture
# tests/conftest.py
import pytest
from sqlmodel import SQLModel
from tests.test_db import engine
@pytest.fixture(scope="session")
def anyio_backend():
return "asyncio"
@pytest.fixture
async def create_test_tables():
"""
Creates tables before each test, drops them after.
Each test gets a clean database.
"""
async with engine.begin() as conn:
await conn.run_sync(SQLModel.metadata.create_all)
yield
async with engine.begin() as conn:
await conn.run_sync(SQLModel.metadata.drop_all)
The Test Client Fixture
# tests/features/test_examples_api.py
import pytest
from fastapi.testclient import TestClient
from src.app import app
from src.features.examples.api import get_example_service
from src.features.examples.service import ExampleService
from tests.test_db import TestingSessionLocal
async def override_get_example_service():
"""Override the dependency to use test database"""
async with TestingSessionLocal() as session:
yield ExampleService(session=session)
app.dependency_overrides[get_example_service] = override_get_example_service
@pytest.fixture(scope="module")
def client():
with TestClient(app) as c:
yield c
The Actual Test
import pytest
from fastapi.testclient import TestClient
@pytest.mark.anyio(backend='asyncio')
async def test_create_and_get_example(client: TestClient, create_test_tables):
# Create an example
create_response = client.post(
"/api/v1/examples/",
json={"name": "Test Example", "description": "A test description"},
)
assert create_response.status_code == 200
created_example = create_response.json()
assert created_example["name"] == "Test Example"
assert "id" in created_example
# Fetch it back
example_id = created_example["id"]
get_response = client.get(f"/api/v1/examples/{example_id}")
assert get_response.status_code == 200
fetched_example = get_response.json()
assert fetched_example["id"] == example_id
assert fetched_example["name"] == "Test Example"
Notice how the test just declares what it needs (client, create_test_tables), and pytest handles the rest. Clean database, test client, all set up and torn down automatically.
What We Fixed (The Troubleshooting Story)
When I first set this up, tests were failing with trio errors. Here’s what was wrong and how we fixed it:
- Problem 1:
pytest-anyiowas trying to use trio. - Solution: Added the
anyio_backendfixture returning"asyncio"and removed the unnecessarytriodependency.
(Additional problems and fixes can be added here as needed.)