DEV Community

Chandrashekhar Kachawa
Chandrashekhar Kachawa

Posted on • Originally published at ctrix.pro

Explicit is Better Than Implicit: Mastering Pytest Fixtures and Async Testing

Look, we need to talk about async testing in Python. If you've ever stared at a ModuleNotFoundError: No module named 'trio' when you're just trying to test your FastAPI endpoints, you know the pain. You didn't ask for trio. You don't even know what trio is. You just want your tests to pass.

Let's fix this properly.

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. That's it. 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.

Here's what makes them powerful:

Dependency Injection (The Good Kind)

You want a database connection? Just ask for it:

def test_something(database_connection):
    # pytest automatically gives you the connection
    result = database_connection.query("SELECT * FROM users")
    assert result is not None
Enter fullscreen mode Exit fullscreen mode

No manual setup. No global variables. Just declare what you need, and pytest handles it.

Setup and Teardown Without the Boilerplate

Remember the old setUp() and tearDown() methods? Fixtures do this better:

@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)
Enter fullscreen mode Exit fullscreen mode

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

Fixtures have scopes that control how often they run:

  • function (default) - Runs for every single test
  • class - Runs once per test class
  • module - Runs once per test file
  • session - Runs once for your entire test suite

This matters for performance. Creating a database connection once per session? Smart. Creating it 500 times for 500 tests? Not smart.

The anyio Backend Problem

Now here's where things get spicy. 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.

Sounds great, right? Wrong.

Here's what actually happens:

  1. You write tests using asyncio (because that's what FastAPI uses)
  2. You install pytest-anyio to test your async code
  3. pytest-anyio sees you have trio installed (maybe from another project)
  4. It tries to run your tests with trio
  5. Your aiosqlite database driver explodes because it only works with asyncio
  6. You get errors like ModuleNotFoundError: No module named 'trio' or RuntimeError: no running event loop
  7. You question 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

In your tests/conftest.py, add this:

import pytest

@pytest.fixture(scope="session")
def anyio_backend():
    """
    Use asyncio for all async tests. Period.
    """
    return "asyncio"
Enter fullscreen mode Exit fullscreen mode

That's it. 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
Enter fullscreen mode Exit fullscreen mode

Step 3: Mark Your Async Tests Properly

When you write async tests, mark them explicitly:

@pytest.mark.anyio(backend='asyncio')
async def test_create_and_get_example(client, create_test_tables):
    # Your test code here
    pass
Enter fullscreen mode Exit fullscreen mode

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

Let's put this all together with a real example. You've got a FastAPI app with a database, and you want to test it properly.

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

The Actual Test

@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"
Enter fullscreen mode Exit fullscreen mode

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-anyio was trying to use trio

  • Solution: Created the anyio_backend fixture returning "asyncio"

Problem 2: trio was installed but incompatible with aiosqlite

  • Solution: Uninstalled trio since we don't need it

Problem 3: Conflicting configurations in pyproject.toml and pytest.ini

  • Solution: Removed old anyio configurations, kept it simple

The lesson? Explicit is better than implicit. Don't let your test framework guess what you want.

Key Takeaways

  1. Fixtures are dependency injection for tests - Declare what you need, pytest provides it
  2. Use yield for setup/teardown - Code before yield is setup, code after is cleanup
  3. Choose the right scope - Don't recreate expensive resources for every test
  4. Be explicit about your async backend - Create an anyio_backend fixture that returns "asyncio"
  5. Don't install backends you don't use - If you're using asyncio, you probably don't need trio
  6. Mark async tests properly - Use @pytest.mark.anyio(backend='asyncio')

The Bottom Line

Async testing in Python doesn't have to be painful. The key is understanding what's actually happening:

  • Your async code needs an event loop
  • pytest-anyio manages that loop for you
  • You need to tell it which loop to use
  • Fixtures handle setup and teardown automatically

Get these pieces right, and your tests will be reliable, fast, and maintainable. Get them wrong, and you'll be debugging event loop errors at 2 AM.

Choose wisely.


Have you dealt with async testing nightmares? What tripped you up? Let me know in the comments or hit me up on Twitter. And if this saved you from a debugging session, you're welcome.

Top comments (1)

Collapse
 
jonasberg_dev profile image
Jonas Berg

Loved the “we need to talk about async” intervention energy here. The explicit anyio backend tip is gold, even if it exposes how magical pytest-anyio pretends to be. Only nit: now I feel morally obliged to uninstall trio from every project just to sleep at night.