FastAPI Test Fixtures — Deep Dive

The transactional test pattern

The gold standard for FastAPI database testing is transactional isolation: each test runs inside a database transaction that rolls back when the test ends. No data persists. No cleanup needed. Every test sees a pristine database.

# conftest.py
import pytest
from sqlalchemy import create_engine, event
from sqlalchemy.orm import sessionmaker, Session
from app.main import app
from app.deps import get_db
from app.models import Base

TEST_DATABASE_URL = "postgresql://test:test@localhost/test_db"

@pytest.fixture(scope="session")
def engine():
    eng = create_engine(TEST_DATABASE_URL)
    Base.metadata.create_all(bind=eng)
    yield eng
    Base.metadata.drop_all(bind=eng)

@pytest.fixture(scope="function")
def db_session(engine):
    connection = engine.connect()
    transaction = connection.begin()
    session = Session(bind=connection)

    # Handle nested transactions (SAVEPOINT)
    @event.listens_for(session, "after_transaction_end")
    def restart_savepoint(s, trans):
        if trans.nested and not trans._parent.nested:
            s.begin_nested()

    session.begin_nested()

    yield session

    session.close()
    transaction.rollback()
    connection.close()

@pytest.fixture(scope="function")
def client(db_session):
    def override_get_db():
        yield db_session

    app.dependency_overrides[get_db] = override_get_db
    from fastapi.testclient import TestClient
    with TestClient(app) as c:
        yield c
    app.dependency_overrides.pop(get_db, None)

The begin_nested() and restart_savepoint listener are critical. Without them, if your route code calls session.commit(), it commits the outer transaction and rollback no longer works. The nested transaction (SAVEPOINT) lets route code commit normally while the outer transaction still rolls back everything.

Async test client with httpx

TestClient runs synchronously. For testing async endpoints, lifespan events, and async dependencies, use httpx.AsyncClient:

import pytest
import pytest_asyncio
from httpx import AsyncClient, ASGITransport
from app.main import app

@pytest_asyncio.fixture
async def async_client(db_session):
    def override_get_db():
        yield db_session

    app.dependency_overrides[get_db] = override_get_db
    transport = ASGITransport(app=app)
    async with AsyncClient(transport=transport, base_url="http://test") as client:
        yield client
    app.dependency_overrides.pop(get_db, None)

@pytest.mark.asyncio
async def test_list_items(async_client):
    response = await async_client.get("/items")
    assert response.status_code == 200

The ASGITransport sends requests directly to the ASGI app without HTTP overhead. Lifespan events (startup, shutdown) run inside the async with context, which means your startup database connections and other initialization actually execute during tests.

Factory fixtures with factory-boy

For complex data models, hand-crafting test data in each fixture becomes tedious. factory-boy integrates with SQLAlchemy to generate test objects:

import factory
from app.models import User, Order, Product

class UserFactory(factory.alchemy.SQLAlchemyModelFactory):
    class Meta:
        model = User
        sqlalchemy_session_persistence = "flush"

    email = factory.Sequence(lambda n: f"user-{n}@example.com")
    name = factory.Faker("name")
    role = "user"
    is_active = True

class ProductFactory(factory.alchemy.SQLAlchemyModelFactory):
    class Meta:
        model = Product
        sqlalchemy_session_persistence = "flush"

    name = factory.Faker("product_name")
    price = factory.Faker("pydecimal", left_digits=3, right_digits=2, positive=True)
    stock = factory.Faker("random_int", min=0, max=1000)

class OrderFactory(factory.alchemy.SQLAlchemyModelFactory):
    class Meta:
        model = Order
        sqlalchemy_session_persistence = "flush"

    user = factory.SubFactory(UserFactory)
    product = factory.SubFactory(ProductFactory)
    quantity = factory.Faker("random_int", min=1, max=10)

# Fixture that injects the session into factories
@pytest.fixture(autouse=True)
def set_factory_session(db_session):
    UserFactory._meta.sqlalchemy_session = db_session
    ProductFactory._meta.sqlalchemy_session = db_session
    OrderFactory._meta.sqlalchemy_session = db_session

Usage in tests:

def test_user_orders(client, db_session):
    user = UserFactory(role="premium")
    orders = OrderFactory.create_batch(5, user=user)

    response = client.get(f"/users/{user.id}/orders")
    assert len(response.json()) == 5

Factory-boy generates realistic data, handles relationships, and lets you override specific fields per test while providing sensible defaults.

Multi-tenant test fixtures

SaaS applications need to test tenant isolation. A tenant-aware fixture setup:

@pytest.fixture
def tenant_a(db_session):
    tenant = Tenant(name="Acme Corp", plan="enterprise")
    db_session.add(tenant)
    db_session.flush()
    return tenant

@pytest.fixture
def tenant_b(db_session):
    tenant = Tenant(name="Widgets Inc", plan="starter")
    db_session.add(tenant)
    db_session.flush()
    return tenant

@pytest.fixture
def client_as_tenant_a(client, tenant_a):
    """Client that sends X-Tenant-ID header for tenant A"""
    client.headers["X-Tenant-ID"] = str(tenant_a.id)
    yield client
    client.headers.pop("X-Tenant-ID", None)

def test_tenant_isolation(client_as_tenant_a, tenant_a, tenant_b, db_session):
    # Create data for both tenants
    db_session.add(Item(name="Widget", tenant_id=tenant_a.id))
    db_session.add(Item(name="Secret", tenant_id=tenant_b.id))
    db_session.flush()

    response = client_as_tenant_a.get("/items")
    items = response.json()
    # Tenant A should only see their own items
    assert len(items) == 1
    assert items[0]["name"] == "Widget"

Performance optimization: fixture caching

For large test suites, fixture setup time dominates. Strategies:

Shared database schema (session scope):

@pytest.fixture(scope="session")
def engine():
    eng = create_engine(TEST_DATABASE_URL)
    Base.metadata.drop_all(bind=eng)
    Base.metadata.create_all(bind=eng)
    yield eng

Creating/dropping tables once per suite instead of per test saves significant time with large schemas.

Preloaded reference data:

@pytest.fixture(scope="session")
def reference_data(engine):
    """Load reference data that tests read but never modify."""
    session = Session(bind=engine)
    load_countries(session)
    load_currencies(session)
    load_categories(session)
    session.commit()
    session.close()

Reference data that tests only read (countries, currencies, categories) can be session-scoped. It’s created once and shared. Transactional isolation prevents tests from accidentally modifying it.

Parallel test execution:

# pytest-xdist: each worker gets its own database
@pytest.fixture(scope="session")
def engine(worker_id):
    db_url = f"postgresql://test:test@localhost/test_db_{worker_id}"
    eng = create_engine(db_url)
    Base.metadata.create_all(bind=eng)
    yield eng
    Base.metadata.drop_all(bind=eng)

With pytest-xdist, each worker process gets a unique database. This prevents cross-worker interference and enables full CPU utilization on multi-core machines.

Testing WebSocket endpoints

WebSocket fixtures need special handling:

@pytest.fixture
def ws_client():
    from fastapi.testclient import TestClient
    client = TestClient(app)
    yield client

def test_chat_message(ws_client, db_session):
    user = UserFactory()
    with ws_client.websocket_connect(f"/ws/chat?token={generate_test_token(user)}") as ws:
        ws.send_text('{"type": "chat", "content": "hello"}')
        response = ws.receive_text()
        data = json.loads(response)
        assert data["content"] == "hello"
        assert data["user_id"] == str(user.id)

For multi-client WebSocket testing (e.g., verifying broadcast), open multiple WebSocket connections within the same test.

File upload fixtures

@pytest.fixture
def sample_image():
    """Generate a minimal valid PNG."""
    import io
    from PIL import Image
    img = Image.new("RGB", (100, 100), color="red")
    buf = io.BytesIO()
    img.save(buf, format="PNG")
    buf.seek(0)
    return buf

def test_upload_avatar(client, sample_image):
    response = client.post(
        "/users/me/avatar",
        files={"file": ("avatar.png", sample_image, "image/png")},
    )
    assert response.status_code == 200
    assert response.json()["avatar_url"].endswith(".png")

Generating test files in fixtures avoids committing binary test data to the repository and ensures every test gets a fresh file handle.

Fixture debugging

When fixtures interact in unexpected ways, debugging tools:

# Show fixture resolution order
pytest --setup-show test_file.py::test_name

# Show all available fixtures
pytest --fixtures

# Show which conftest files are loaded
pytest --collect-only -q

--setup-show is invaluable for understanding the exact order fixtures are created and destroyed. When a test fails due to fixture interaction, this output reveals the sequence.

The conftest.py architecture

A well-organized test directory:

tests/
├── conftest.py              # Engine, base session, base client
├── factories.py             # All factory-boy factories
├── api/
│   ├── conftest.py          # API-specific: authenticated clients
│   ├── test_users.py
│   └── test_orders.py
├── services/
│   ├── conftest.py          # Service-layer: no HTTP client needed
│   └── test_order_service.py
└── integration/
    ├── conftest.py          # Real external services (Testcontainers)
    └── test_payment_flow.py

Each conftest.py adds fixtures specific to its test category. API tests get HTTP clients. Service tests get direct database sessions. Integration tests get real external dependencies via Testcontainers.

The one thing to remember: The transactional test pattern (nested SAVEPOINT + outer rollback) is the foundation of fast, isolated FastAPI tests — layer factory fixtures for data generation, compose fixtures from reusable pieces, and use session-scoped immutable fixtures to minimize setup overhead in large suites.

pythonwebapistesting

See Also

  • Python Aiohttp Client Understand Aiohttp Client through a practical analogy so your Python decisions become faster and clearer.
  • Python Api Client Design Why building your own API client in Python is like creating a TV remote that only has the buttons you actually need.
  • Python Api Documentation Swagger Swagger turns your Python API into an interactive playground where anyone can click buttons to try it out — no coding required.
  • Python Api Mocking Responses Why testing with fake API responses is like rehearsing a play with stand-ins before the real actors show up.
  • Python Api Pagination Clients Why APIs send data in pages, and how Python handles it — like reading a book one chapter at a time instead of swallowing the whole thing.