Python FastAPI testing — mønstre til IoT og SaaS
FastAPI testing mønstre: ASGITransport AsyncClient, pytest fixtures, JWT dependency override, database isolation med rollback, parametrize og respx mock til httpx-kald i tests.
Af M-Bus Gateway
FastAPI-tests kræver async HTTP-klient, JWT-mocking og databaseisolering. Her er mønstrene der virker i produktion.
ASGITransport — in-process testning
# server/tests/conftest.py
import pytest
import pytest_asyncio
from httpx import AsyncClient, ASGITransport
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
from server.src.main import create_app
from server.src.db.engine import get_db
# Brug SQLite i tests (ingen Docker krævet)
TEST_DATABASE_URL = "sqlite+aiosqlite:///:memory:"
@pytest_asyncio.fixture(scope="session")
async def test_engine():
engine = create_async_engine(TEST_DATABASE_URL, echo=False)
async with engine.begin() as conn:
from server.src.db.models import SQLModel
await conn.run_sync(SQLModel.metadata.create_all)
yield engine
await engine.dispose()
@pytest_asyncio.fixture
async def db_session(test_engine):
"""
Isoleret DB-session pr. test.
Alle ændringer rulles tilbage — ingen data lækkager mellem tests.
"""
async_session = async_sessionmaker(test_engine, expire_on_commit=False)
async with test_engine.connect() as conn:
await conn.begin()
async with AsyncSession(bind=conn) as session:
yield session
await conn.rollback()
@pytest_asyncio.fixture
async def client(db_session):
"""
FastAPI TestClient via ASGITransport.
Kører in-process — ingen netværksforbindelse.
"""
app = create_app()
# Override DB dependency til isoleret test-session
async def override_get_db():
yield db_session
app.dependency_overrides[get_db] = override_get_db
async with AsyncClient(
transport=ASGITransport(app=app),
base_url="http://testserver",
) as ac:
yield ac
app.dependency_overrides.clear()
JWT dependency override
# server/tests/conftest.py
from server.src.auth.deps import get_current_user
from server.src.db.models import User
def make_jwt_override(
user_id: str = "test-user-id",
tenant_id: str = "test-tenant-id",
role: str = "landlord",
):
"""
Returner en dependency override der simulerer en authentikeret bruger.
Bruges til at teste RBAC-logik uden rigtig JWT.
"""
async def override():
return User(
id=user_id,
tenant_id=tenant_id,
role=role,
email="test@example.com",
name="Test User",
)
return override
@pytest.fixture
def landlord_client(client, app):
"""Client med landlord-JWT."""
app.dependency_overrides[get_current_user] = make_jwt_override(role="landlord")
yield client
app.dependency_overrides[get_current_user] = make_jwt_override(role="landlord")
@pytest.fixture
def super_admin_client(client, app):
"""Client med super_admin-JWT."""
app.dependency_overrides[get_current_user] = make_jwt_override(role="super_admin")
yield client
Test-eksempler med parametrize
# server/tests/test_portfolio_kpi.py
import pytest
from decimal import Decimal
from unittest.mock import AsyncMock, patch
@pytest.mark.asyncio
@pytest.mark.parametrize("role,expected_status", [
("landlord", 200),
("technician", 200),
("installer", 403),
("tenant", 403),
])
async def test_portfolio_kpi_rbac(
client,
app,
role: str,
expected_status: int,
):
"""RBAC: Kun landlord og technician må se portfolio KPI."""
from server.src.auth.deps import get_current_user
app.dependency_overrides[get_current_user] = make_jwt_override(role=role)
response = await client.get("/api/v1/portfolio/kpi")
assert response.status_code == expected_status
@pytest.mark.asyncio
@pytest.mark.parametrize("property_count,unit_count,expected_tier", [
(1, 10, "small"),
(5, 100, "medium"),
(20, 500, "large"),
])
async def test_portfolio_kpi_tiers(
client,
db_session,
property_count: int,
unit_count: int,
expected_tier: str,
):
"""KPI tier-beregning baseret på porteføljestørrelse."""
# Seed testdata
await _seed_properties(db_session, property_count, unit_count)
response = await client.get("/api/v1/portfolio/kpi")
assert response.status_code == 200
data = response.json()
assert data["tier"] == expected_tier
respx mock til httpx-kald
# server/tests/test_dmi_sync.py
import pytest
import respx
import httpx
from server.src.workers.tasks.dmi import sync_degree_days_for_zip
@pytest.mark.asyncio
async def test_dmi_sync_success():
"""Mock DMI API — kræver ikke rigtig netværksadgang."""
mock_response = {
"timeSeriesData": [
{"time": "2026-05-01T00:00:00Z", "value": 12.5},
{"time": "2026-05-02T00:00:00Z", "value": 8.3},
]
}
with respx.mock:
respx.get("https://dmigw.govcloud.dk/v2/climateData/").mock(
return_value=httpx.Response(200, json=mock_response)
)
result = await sync_degree_days_for_zip("2100", "2026-05-01", "2026-05-31")
assert result["days_synced"] == 2
assert result["zip_code"] == "2100"
@pytest.mark.asyncio
async def test_dmi_sync_api_error():
"""DMI API nede → graceful fejl, ikke exception."""
with respx.mock:
respx.get("https://dmigw.govcloud.dk/v2/climateData/").mock(
return_value=httpx.Response(503, text="Service Unavailable")
)
result = await sync_degree_days_for_zip("2100", "2026-05-01", "2026-05-31")
assert result["status"] == "error"
assert result["days_synced"] == 0
@pytest.mark.asyncio
async def test_dmi_sync_timeout():
"""DMI timeout → retry og til sidst fejl."""
with respx.mock:
respx.get("https://dmigw.govcloud.dk/v2/climateData/").mock(
side_effect=httpx.TimeoutException("Connection timed out")
)
result = await sync_degree_days_for_zip("2100", "2026-05-01", "2026-05-31")
assert result["status"] == "timeout"
Database fixtures — seed og isolation
# server/tests/factories.py
from datetime import date, datetime, timedelta
from decimal import Decimal
import uuid
from server.src.db.models import Property, Unit, Meter, MeterInstallation, Reading
async def create_test_property(
db,
tenant_id: str = "test-tenant",
name: str = "Testejendom",
zip_code: str = "2100",
) -> Property:
prop = Property(
id=str(uuid.uuid4()),
tenant_id=tenant_id,
name=name,
address=f"Testvej 1, {zip_code} København",
zip_code=zip_code,
country="DK",
created_at=datetime.utcnow(),
updated_at=datetime.utcnow(),
)
db.add(prop)
await db.flush()
return prop
async def create_test_reading(
db,
meter_installation_id: str,
value_kwh: float = 100.0,
days_ago: int = 1,
) -> Reading:
reading = Reading(
id=str(uuid.uuid4()),
meter_installation_id=meter_installation_id,
timestamp=datetime.utcnow() - timedelta(days=days_ago),
value_kwh=Decimal(str(value_kwh)),
received_at=datetime.utcnow(),
)
db.add(reading)
await db.flush()
return reading
# I test:
# async def test_something(db_session):
# prop = await create_test_property(db_session, tenant_id="t1")
# reading = await create_test_reading(db_session, mi_id, value_kwh=234.5)
# await db_session.commit()
Pytest konfiguration
# pytest.ini
[pytest]
asyncio_mode = auto
testpaths = server/tests
python_files = test_*.py
python_classes = Test*
python_functions = test_*
# Markers
markers =
slow: Tests der tager >1 sekund (kræver --run-slow)
integration: Integration tests (kræver ekstern service)
# Ignorer warnings fra tredjepartsbiblioteker
filterwarnings =
ignore::DeprecationWarning:sqlalchemy
ignore::DeprecationWarning:pydantic
# Kør unit tests hurtigt (CI baseline):
pytest server/tests/ -x --timeout=30 -q
# Kør med coverage:
pytest server/tests/ --cov=server/src --cov-report=term-missing -q
# Kør kun hurtige tests (ekskludér slow):
pytest server/tests/ -m "not slow" -q
Konklusion
ASGITransport + AsyncClient giver in-process FastAPI-tests uden netværk — korrekt isolering, hurtig eksekvering. db_session fixture med rollback sikrer at tests aldrig påvirker hinanden. JWT dependency_overrides giver fuld RBAC-kontrol i tests uden rigtig token-generering. parametrize er det rette valg til RBAC-matrixtests (rolle × endpoint × forventet status). respx mock til httpx-kald (DMI, Stripe, Brevo) eliminerer ekstern afhængighed i tests.