Skip to content

Fixtures

The root conftest.py provides app-level fixtures that every test directory inherits. Everything you need for a typical integration test — app, DB session, HTTP client, authenticated client — is already wired up.

Available fixtures

FixtureScopeReturnsUse when
settingsfunctionSettingsYou need to read the in-memory test settings (SQLite, multi_tenant=True).
db_statefunctionDatabaseStateLow-level access to engines / sessions outside a request.
enginefunctionAsyncEngineYou need the raw async engine (rare).
db_sessionfunctionAsyncSessionUnit-testing a service. Tables created, Alembic head stamped.
appfunctionFastAPIEndpoint tests that don't need an HTTP client.
clientfunctionhttpx.AsyncClientAnonymous HTTP calls to the app.
authenticated_clientfunctionhttpx.AsyncClientAdmin-scoped HTTP calls — carries a signed session cookie.

All fixtures are function-scoped — each test gets its own. The SQLite database is in-memory; tearing down and re-creating is cheap.

settings

A pre-built Settings object using the in-memory SQLite URL and multi_tenant=True. Most tests don't use it directly — the app fixture consumes it.

python
def test_settings_defaults(settings):
    assert settings.multi_tenant is True
    assert settings.database_url.startswith("sqlite")

db_session

The workhorse. Creates a fresh in-memory DB, runs CREATE TABLE for every module's tables (via build_module_metadata()), stamps alembic_version at head, and yields an AsyncSession.

python
from decimal import Decimal
from orders.models import Order

@pytest.mark.asyncio
async def test_create_order(db_session):
    order = Order(customer_email="a@b.c", total=Decimal("1"))
    db_session.add(order)
    await db_session.flush()
    assert order.id is not None

Notes:

  • Tables are created directly from model metadata (not alembic upgrade). This keeps tests fast. Alembic migrations are exercised separately — see Migrations § Testing.
  • Stamping alembic_version at head means the boot-time migration check in the app fixture passes. If you change this, expect SM010 to fail every test.
  • Each test gets a fresh DB — no transaction-rollback trickery.

app

Calls create_app(settings) and starts the lifespan. All modules are discovered, middleware is installed, register_* hooks run. Returns the FastAPI instance with a started lifespan; the fixture teardown closes it.

Useful for tests that exercise framework registrations:

python
@pytest.mark.asyncio
async def test_menu_registered(app):
    menus = app.state.sm.menu_registry
    assert any(m.key == "orders" for m in menus.items())

client

An httpx.AsyncClient pointed at the app via ASGITransport. No auth cookie.

python
@pytest.mark.asyncio
async def test_landing_page(client):
    r = await client.get("/")
    assert r.status_code == 200
    assert "Welcome" in r.text

For endpoints that return JSON:

python
r = await client.get("/api/orders")
assert r.json() == []

401 / 403 are expected for authenticated endpoints — use authenticated_client instead.

authenticated_client

Same as client, but the fixture also:

  1. Seeds an admin user via users.bootstrap.create_admin(...).
  2. Forges a signed session cookie for that user.
  3. Attaches the cookie to the client.
python
@pytest.mark.asyncio
async def test_create_order_as_admin(authenticated_client):
    r = await authenticated_client.post(
        "/api/orders",
        json={"customer_email": "a@b.c", "total": "1.00"},
    )
    assert r.status_code == 201

The admin has * permission (via DEFAULT_ROLE_PERMISSIONS["admin"]), so it bypasses every RequiresPermission check. For tests that require a less-privileged principal, create a separate user and sign in via the login endpoint:

python
@pytest.mark.asyncio
async def test_non_admin_denied(client, db_session):
    from users.service import UserService
    svc = UserService(db_session)
    await svc.create(email="u@e.com", password="x", roles=["viewer"])
    await db_session.commit()

    login = await client.post(
        "/users/login", data={"email": "u@e.com", "password": "x"}
    )
    assert login.status_code in (200, 303)

    r = await client.post("/api/orders", json={...})
    assert r.status_code == 403

Fixture composition

Fixtures compose the expected way — client depends on app, which depends on db_session, which depends on db_state, which depends on settings. Request only the fixture you need; the DAG pulls in the rest.

Adding your own fixtures at module level:

python
# modules/orders/tests/conftest.py
import pytest
from orders.service import OrderService

@pytest.fixture
def order_service(db_session):
    return OrderService(db_session)

Now every test in modules/orders/tests/ can take order_service as a parameter.

Overriding dependencies

FastAPI dependency overrides work as usual:

python
@pytest.mark.asyncio
async def test_with_mock_mailer(app, authenticated_client):
    from users.deps import _mailer
    captured = []
    def fake_mailer():
        return type("Mailer", (), {"send": lambda *a: captured.append(a)})()
    app.dependency_overrides[_mailer] = fake_mailer

    await authenticated_client.post(
        "/users/admin/invite", data={"email": "x@y.z"}
    )
    assert len(captured) == 1

Overrides applied to app persist for the lifetime of the fixture — no need to clean up; the next test gets a fresh app.

Time-sensitive tests

Use freezegun:

python
from freezegun import freeze_time

@pytest.mark.asyncio
async def test_order_timestamps(db_session):
    with freeze_time("2026-01-01T12:00:00Z"):
        order = Order(customer_email="a@b.c", total=Decimal("1"))
        db_session.add(order)
        await db_session.flush()
        assert order.created_at.isoformat().startswith("2026-01-01T12:00:00")

Don't rely on datetime.now() assertions with tolerance windows — they're flaky and slower to read.

Mocking external services

For HTTP, SMTP, storage backends — mock at the boundary with httpx_mock, aiosmtpd, or a service-registered fake. Don't monkeypatch the module you're testing; its behavior is what's under test.

Example with httpx_mock:

python
@pytest.mark.asyncio
async def test_webhook_delivery(httpx_mock, db_session):
    httpx_mock.add_response(url="https://example.com/hook", status_code=200)
    await deliver_webhook(db_session, "https://example.com/hook", {"id": 1})
    assert httpx_mock.get_request().content == b'{"id": 1}'

Parametrization

Standard pytest:

python
@pytest.mark.parametrize("status,expected", [
    ("pending", 200),
    ("shipped", 200),
    ("invalid", 422),
])
@pytest.mark.asyncio
async def test_status_transitions(authenticated_client, status, expected):
    r = await authenticated_client.patch(
        "/api/orders/1", json={"status": status}
    )
    assert r.status_code == expected

Released under the MIT License.