· pytest· asyncio· FastAPI· Python· testing· httpx· SQLModel· CI
pytest-asyncio til FastAPI — async tests og fixtures
Test async FastAPI endpoints med pytest-asyncio: async fixtures, httpx AsyncClient, database rollback pr. test, mock af Celery og Redis, CI integration.
Af M-Bus Gateway
Asynkrone FastAPI-applikationer kræver asynkrone tests. pytest-asyncio giver fuld async/await support i test-suiten.
Opsætning
# pyproject.toml
[tool.pytest.ini_options]
asyncio_mode = "auto" # auto: ingen @pytest.mark.asyncio nødvendig
testpaths = ["server/tests"]
[tool.uv]
dev-dependencies = [
"pytest>=8.0",
"pytest-asyncio>=0.23",
"httpx>=0.27",
"anyio>=4.3",
]
# server/tests/conftest.py
import pytest
from httpx import AsyncClient, ASGITransport
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.orm import sessionmaker
from sqlmodel import SQLModel
from src.main import app
from src.core.database import get_session
from src.core.redis import get_redis
TEST_DATABASE_URL = "postgresql+asyncpg://mbus:mbus@localhost:5433/mbus_test"
@pytest.fixture(scope="session")
async def engine():
"""Session-scoped engine — én forbindelse til hele test-kørslen."""
engine = create_async_engine(TEST_DATABASE_URL, echo=False)
async with engine.begin() as conn:
await conn.run_sync(SQLModel.metadata.create_all)
yield engine
async with engine.begin() as conn:
await conn.run_sync(SQLModel.metadata.drop_all)
await engine.dispose()
@pytest.fixture
async def session(engine):
"""Per-test session med automatisk rollback — ingen data lækker."""
async with engine.connect() as conn:
await conn.begin()
async_session = AsyncSession(bind=conn, expire_on_commit=False)
yield async_session
await conn.rollback()
@pytest.fixture
async def client(session):
"""AsyncClient med injiceret test-session."""
async def override_session():
yield session
app.dependency_overrides[get_session] = override_session
async with AsyncClient(
transport=ASGITransport(app=app),
base_url="http://test",
) as client:
yield client
app.dependency_overrides.clear()
JWT-autentificering i tests
# server/tests/conftest.py (fortsættelse)
from datetime import datetime, timezone, timedelta
from jose import jwt
from src.core.config import settings
def make_token(
tenant_id: str = "550e8400-e29b-41d4-a716-446655440000",
role: str = "landlord",
user_id: str = "660e8400-e29b-41d4-a716-446655440000",
) -> str:
payload = {
"sub": user_id,
"tenant_id": tenant_id,
"role": role,
"exp": datetime.now(timezone.utc) + timedelta(hours=1),
}
return jwt.encode(payload, settings.JWT_SECRET_KEY, algorithm="HS256")
@pytest.fixture
def landlord_headers() -> dict:
return {"Authorization": f"Bearer {make_token(role='landlord')}"}
@pytest.fixture
def admin_headers() -> dict:
return {"Authorization": f"Bearer {make_token(role='super_admin', tenant_id=None)}"}
Test af endpoints
# server/tests/test_properties.py
import pytest
from uuid import uuid4
pytestmark = pytest.mark.asyncio
async def test_create_property(client, session, landlord_headers):
payload = {
"name": "Testejendommen",
"address": "Testvej 1",
"zip_code": "2100",
"city": "København Ø",
"country": "DK",
"heat_distribution_method": "hca",
}
response = await client.post("/api/v1/properties", json=payload, headers=landlord_headers)
assert response.status_code == 201
data = response.json()
assert data["name"] == "Testejendommen"
assert "id" in data
async def test_get_property_not_found(client, landlord_headers):
response = await client.get(f"/api/v1/properties/{uuid4()}", headers=landlord_headers)
assert response.status_code == 404
async def test_list_properties_empty(client, session, landlord_headers):
response = await client.get("/api/v1/properties", headers=landlord_headers)
assert response.status_code == 200
assert response.json()["items"] == []
async def test_tenant_isolation(client, session):
"""Lejer A kan ikke se lejer B's ejendom."""
headers_a = {"Authorization": f"Bearer {make_token(tenant_id='aaaaaaaa-0000-0000-0000-000000000000')}"}
headers_b = {"Authorization": f"Bearer {make_token(tenant_id='bbbbbbbb-0000-0000-0000-000000000000')}"}
# Opret ejendom som tenant A
create_r = await client.post("/api/v1/properties", json={"name": "A's ejendom", "address": "A-vej 1", "zip_code": "1000", "city": "KBH", "country": "DK"}, headers=headers_a)
prop_id = create_r.json()["id"]
# Forsøg at hente som tenant B
get_r = await client.get(f"/api/v1/properties/{prop_id}", headers=headers_b)
assert get_r.status_code == 404
Mock af Celery tasks
# server/tests/test_settlements.py
from unittest.mock import AsyncMock, patch
async def test_generate_settlement_triggers_celery(client, session, landlord_headers):
"""Generer afregning → Celery task dispatches."""
with patch("src.settlements.router.generate_pdf_task.delay") as mock_delay:
response = await client.post(
"/api/v1/settlements/generate",
json={"property_id": "...", "period_start": "2025-06-01", "period_end": "2026-05-31"},
headers=landlord_headers,
)
assert response.status_code == 202
mock_delay.assert_called_once()
async def test_send_settlement_email(client, session, landlord_headers):
"""Send afregning → email task dispatches."""
with patch("src.settlements.router.send_settlement_email_task.delay") as mock_email:
settlement_id = "..."
response = await client.post(
f"/api/v1/settlements/{settlement_id}/send",
headers=landlord_headers,
)
assert response.status_code == 200
mock_email.assert_called_once_with(str(settlement_id))
Mock af Redis
# server/tests/conftest.py
from unittest.mock import AsyncMock
import fakeredis.aioredis
@pytest.fixture
async def redis():
"""Fake Redis — ingen ekstern afhængighed."""
redis = fakeredis.aioredis.FakeRedis()
yield redis
await redis.aclose()
@pytest.fixture
async def client_with_redis(session, redis):
async def override_session():
yield session
async def override_redis():
return redis
app.dependency_overrides[get_session] = override_session
app.dependency_overrides[get_redis] = override_redis
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
yield c
app.dependency_overrides.clear()
# Test med Redis cache:
async def test_portfolio_kpi_cached(client_with_redis, redis, landlord_headers):
# Første kald → cache miss (DB)
r1 = await client_with_redis.get("/api/v1/portfolio/kpi", headers=landlord_headers)
assert r1.status_code == 200
# Andet kald → cache hit (Redis)
r2 = await client_with_redis.get("/api/v1/portfolio/kpi", headers=landlord_headers)
assert r2.status_code == 200
assert r1.json() == r2.json()
Database fixtures med testdata
# server/tests/factories.py
from datetime import date
from uuid import uuid4
from sqlmodel import select
from src.db.models import Property, Unit, Occupancy, Meter, MeterInstallation
TENANT_ID = "550e8400-e29b-41d4-a716-446655440000"
async def create_property(session, **kwargs) -> Property:
prop = Property(
id=uuid4(),
tenant_id=TENANT_ID,
name=kwargs.get("name", "Testejendom"),
address=kwargs.get("address", "Testvej 1"),
zip_code="2100",
city="KBH",
country="DK",
)
session.add(prop)
await session.flush()
return prop
async def create_unit(session, property_id, **kwargs) -> Unit:
unit = Unit(
id=uuid4(),
tenant_id=TENANT_ID,
property_id=property_id,
unit_number=kwargs.get("unit_number", "1"),
floor=kwargs.get("floor", 0),
area_m2=kwargs.get("area_m2", 75.0),
)
session.add(unit)
await session.flush()
return unit
async def create_occupancy(session, unit_id, **kwargs) -> Occupancy:
occ = Occupancy(
id=uuid4(),
tenant_id=TENANT_ID,
unit_id=unit_id,
start_date=kwargs.get("start_date", date(2025, 6, 1)),
end_date=kwargs.get("end_date", None),
tenant_name=kwargs.get("tenant_name", "Test Testersen"),
tenant_email=kwargs.get("tenant_email", "test@example.com"),
)
session.add(occ)
await session.flush()
return occ
CI integration
# .github/workflows/test-server.yml (uddrag)
- name: Start TimescaleDB
run: |
docker run -d \
--name timescaledb-test \
-e POSTGRES_PASSWORD=mbus \
-e POSTGRES_USER=mbus \
-e POSTGRES_DB=mbus_test \
-p 5433:5432 \
timescale/timescaledb:latest-pg16
sleep 5
- name: Run tests
env:
TEST_DATABASE_URL: postgresql+asyncpg://mbus:mbus@localhost:5433/mbus_test
JWT_SECRET_KEY: test-secret-key-not-for-production
run: |
cd server
uv run pytest --tb=short -q
Konklusion
asyncio_mode = "auto" eliminerer gentagne @pytest.mark.asyncio dekoratorer. Rollback-fixture sikrer test-isolation uden truncation. ASGITransport + AsyncClient tester det fulde middleware-stack inkl. JWT og rate limiting. fakeredis erstatter Redis i tests uden ekstern afhængighed.
Se FastAPI multi-tenant guide eller SQLAlchemy async patterns.