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
| Fixture | Scope | Returns | Use when |
|---|---|---|---|
settings | function | Settings | You need to read the in-memory test settings (SQLite, multi_tenant=True). |
db_state | function | DatabaseState | Low-level access to engines / sessions outside a request. |
engine | function | AsyncEngine | You need the raw async engine (rare). |
db_session | function | AsyncSession | Unit-testing a service. Tables created, Alembic head stamped. |
app | function | FastAPI | Endpoint tests that don't need an HTTP client. |
client | function | httpx.AsyncClient | Anonymous HTTP calls to the app. |
authenticated_client | function | httpx.AsyncClient | Admin-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.
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.
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 NoneNotes:
- Tables are created directly from model metadata (not
alembic upgrade). This keeps tests fast. Alembic migrations are exercised separately — see Migrations § Testing. - Stamping
alembic_versionat head means the boot-time migration check in theappfixture passes. If you change this, expectSM010to 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:
@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.
@pytest.mark.asyncio
async def test_landing_page(client):
r = await client.get("/")
assert r.status_code == 200
assert "Welcome" in r.textFor endpoints that return JSON:
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:
- Seeds an admin user via
users.bootstrap.create_admin(...). - Forges a signed session cookie for that user.
- Attaches the cookie to the client.
@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 == 201The 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:
@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 == 403Fixture 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:
# 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:
@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) == 1Overrides 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:
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:
@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:
@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