Test-Engine pro Test statt Modul-Global erzeugen (echter Fix für Event-Loop-Konflikt)
CI / backend-tests (push) Failing after 28s

Der vorherige Fix (asyncio_default_fixture_loop_scope/asyncio_default_test_loop_scope)
griff nicht - letztere Option existiert in der gepinnten pytest-asyncio-Version
vermutlich noch nicht, Tests liefen weiterhin auf function-scoped Loops während die
Engine session-weit global war. Echte Ursache behoben: db_session erzeugt jetzt eine
frische AsyncEngine pro Test (und disposed sie danach), sodass Engine/Pool immer auf
derselben Loop laufen wie der Test selbst.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L85hmKbvX7Cqkq47KnQhFt
This commit is contained in:
2026-09-03 23:27:06 +02:00
co-authored by Claude Sonnet 5
parent 8f9bd57c5b
commit 67cebbbf48
6 changed files with 280 additions and 30 deletions
+27 -24
View File
@@ -1,7 +1,7 @@
import pytest
import pytest_asyncio
from httpx import ASGITransport, AsyncClient
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
from app.core.app_settings import settings
from app.core.security import hash_password
@@ -11,35 +11,38 @@ from app.models.auth import Benutzer, BenutzerRolle, RolleTyp
from app.models.objekt import Objekt
from app.models.stammdaten import Bereich, Objekttyp, Standort
# Erwartet eine bereits per Alembic migrierte Test-Datenbank (CI: install -> migrate -> pytest,
# siehe testphasen.md Phase 0). Jeder Test läuft in einer Transaktion, die am Ende zurückgerollt
# wird, damit Tests sich nicht gegenseitig beeinflussen.
engine = create_async_engine(settings.database_url, pool_pre_ping=True)
TestSessionLocal = async_sessionmaker(engine, expire_on_commit=False)
@pytest_asyncio.fixture
async def db_session():
"""Jeder Test läuft in einer äußeren Transaktion, die am Ende zurückgerollt wird.
Die Session ist per `join_transaction_mode="create_savepoint"` an die äußere
Connection gebunden: ein `commit()` innerhalb des Tests (z. B. durch `get_db`,
das jetzt selbst committet) schließt nur eine SAVEPOINT ab, nicht die äußere
Transaktion das äußere `connection.rollback()` verwirft am Ende trotzdem alles.
(SQLAlchemy 2.0 "Joining a Session into an External Transaction" Review-Fund
fastapi-expert: ohne dieses Pattern hätte ein commit() in Endpunkten die
Testisolation gebrochen.)
Die Engine wird bewusst PRO TEST neu erzeugt (nicht als Modul-Global): asyncpg-
Verbindungen sind an die Event-Loop gebunden, in der sie entstanden ein
session-weit wiederverwendeter Pool kollidierte mit pytest-asyncios (Default)
function-scoped Loop pro Test ("attached to a different loop" / "another
operation is in progress", Fund im zweiten echten CI-Lauf). Mit einer frischen
Engine pro Test entsteht der Pool immer auf der Loop, die auch den Test ausführt.
Die Session ist zusätzlich per `join_transaction_mode="create_savepoint"` an die
äußere Connection gebunden: ein `commit()` innerhalb des Tests (z. B. durch
`get_db`, das jetzt selbst committet) schließt nur eine SAVEPOINT ab, nicht die
äußere Transaktion das äußere `connection.rollback()` verwirft am Ende trotzdem
alles (SQLAlchemy 2.0 "Joining a Session into an External Transaction").
"""
async with engine.connect() as connection:
await connection.begin()
session = AsyncSession(
bind=connection, expire_on_commit=False, join_transaction_mode="create_savepoint"
)
try:
yield session
finally:
await session.close()
await connection.rollback()
engine = create_async_engine(settings.database_url, pool_pre_ping=True)
try:
async with engine.connect() as connection:
await connection.begin()
session = AsyncSession(
bind=connection, expire_on_commit=False, join_transaction_mode="create_savepoint"
)
try:
yield session
finally:
await session.close()
await connection.rollback()
finally:
await engine.dispose()
@pytest_asyncio.fixture