44Key design decisions
55────────────────────
661. ``mock_external_services`` (autouse, session-scoped)
7- Patches ``init_database`` and ``close_database`` in ``app.main`` **before**
8- any TestClient / AsyncClient triggers the FastAPI lifespan. Without this
9- the lifespan tries to connect to a real PostgreSQL and Redis that do not
10- exist in CI, hanging the entire test run indefinitely.
11-
12- 2. No custom ``event_loop`` fixture.
13- The session-scoped ``event_loop`` override was deprecated in
14- pytest-asyncio 0.23 and removed in 1.x. Loop lifetime is now governed by
15- ``asyncio_default_fixture_loop_scope = session`` in pytest.ini.
16-
17- 3. SQLite in-memory engine for all DB fixtures.
18- All async DB fixtures use an independent ``sqlite+aiosqlite:///:memory:``
19- engine so tests never touch the production PostgreSQL URL.
7+ Patches ``init_database`` and ``close_database`` **before** any
8+ TestClient / AsyncClient triggers the FastAPI lifespan, preventing
9+ it from trying to connect to a real PostgreSQL / Redis that does not
10+ exist in CI.
11+
12+ 2. Engine created FRESH per test inside ``db_session``.
13+ The original code had a module-level ``test_engine`` with
14+ ``StaticPool``. With ``asyncio_default_fixture_loop_scope=function``
15+ every test runs on its own event loop. The aiosqlite background
16+ thread is bound to the event loop that CREATED the connection. When
17+ the second test reused the same StaticPool connection on a NEW loop
18+ the aiosqlite thread waited for futures that were queued on the old
19+ loop — a guaranteed deadlock regardless of timeout.
20+ Creating a fresh engine (and therefore a fresh aiosqlite connection
21+ and background thread) per test eliminates the cross-loop hazard.
22+
23+ 3. No custom ``event_loop`` fixture.
24+ Removed — it was deprecated in pytest-asyncio 0.23 and removed in
25+ 1.x. Loop lifetime is now controlled by
26+ ``asyncio_default_fixture_loop_scope = function`` in pytest.ini.
2027"""
2128
2229from typing import Any , AsyncGenerator , Generator
3441from sqlalchemy .ext .asyncio import AsyncSession , async_sessionmaker , create_async_engine
3542from sqlalchemy .pool import StaticPool
3643
37- # ---------------------------------------------------------------------------
38- # In-memory SQLite engine (used by all DB-aware fixtures)
39- # ---------------------------------------------------------------------------
40-
41- TEST_DATABASE_URL = "sqlite+aiosqlite:///:memory:"
42- test_engine = create_async_engine (
43- TEST_DATABASE_URL ,
44- poolclass = StaticPool ,
45- connect_args = {"check_same_thread" : False },
46- echo = False ,
47- )
48- TestSessionLocal = async_sessionmaker (
49- test_engine , class_ = AsyncSession , expire_on_commit = False
50- )
51-
52-
5344# ---------------------------------------------------------------------------
5445# Session-level guard: prevent the FastAPI lifespan from opening real
5546# PostgreSQL / Redis connections that don't exist in the CI environment.
56- # This fixture MUST be autouse and session-scoped so it activates before
57- # the first TestClient or AsyncClient is constructed.
5847# ---------------------------------------------------------------------------
5948
6049
@@ -63,9 +52,8 @@ def mock_external_services() -> Generator[None, None, None]:
6352 """
6453 Mock PostgreSQL + Redis initialisation for the entire test session.
6554
66- ``init_database`` (called by the FastAPI lifespan on startup) and
67- ``close_database`` (called on shutdown) are replaced with no-op coroutines.
68- Tests that need a real DB use the SQLite ``db_session`` fixture instead.
55+ ``init_database`` and ``close_database`` are replaced with no-op
56+ coroutines so the FastAPI lifespan never blocks on a missing DB.
6957 """
7058 with (
7159 patch ("app.main.init_database" , new_callable = AsyncMock ),
@@ -81,15 +69,36 @@ def mock_external_services() -> Generator[None, None, None]:
8169
8270@pytest_asyncio .fixture
8371async def db_session () -> AsyncGenerator [AsyncSession , None ]:
84- """Async SQLite session with a fresh schema for every test."""
85- async with test_engine .begin () as conn :
72+ """
73+ Async SQLite session with a completely fresh schema for every test.
74+
75+ A NEW engine (and therefore a new aiosqlite connection + background
76+ thread) is created for every test invocation so there is no
77+ cross-event-loop sharing between tests. The engine is disposed
78+ after the test finishes to release the thread cleanly.
79+ """
80+ engine = create_async_engine (
81+ "sqlite+aiosqlite:///:memory:" ,
82+ # StaticPool keeps a single persistent connection so all
83+ # operations within a test see the same in-memory database.
84+ poolclass = StaticPool ,
85+ connect_args = {"check_same_thread" : False },
86+ echo = False ,
87+ )
88+ session_factory = async_sessionmaker (
89+ engine , class_ = AsyncSession , expire_on_commit = False
90+ )
91+
92+ async with engine .begin () as conn :
8693 await conn .run_sync (Base .metadata .create_all )
8794
88- async with TestSessionLocal () as session :
95+ async with session_factory () as session :
8996 yield session
9097
91- async with test_engine .begin () as conn :
98+ # Tear down: drop schema, dispose engine (stops aiosqlite thread).
99+ async with engine .begin () as conn :
92100 await conn .run_sync (Base .metadata .drop_all )
101+ await engine .dispose ()
93102
94103
95104# ---------------------------------------------------------------------------
@@ -106,7 +115,14 @@ def client() -> Generator[TestClient, None, None]:
106115
107116@pytest_asyncio .fixture
108117async def async_client (db_session : AsyncSession ) -> AsyncGenerator [AsyncClient , None ]:
109- """Async HTTP client whose DB dependency is overridden to use SQLite."""
118+ """
119+ Async HTTP client whose DB dependency is overridden to use the
120+ per-test SQLite session created by ``db_session``.
121+
122+ Because ``db_session`` is function-scoped and creates a fresh engine
123+ per test, the aiosqlite connection handed to this fixture is always
124+ bound to the current test's event loop — no cross-loop deadlock.
125+ """
110126
111127 async def _override_get_db () -> AsyncGenerator [AsyncSession , None ]:
112128 yield db_session
@@ -131,7 +147,7 @@ async def test_user(db_session: AsyncSession) -> User:
131147 """
132148 Persisted test user with password ``testpassword``.
133149
134- Uses bcrypt directly (not passlib) to stay compatible with bcrypt >= 4.x.
150+ Uses bcrypt directly to stay compatible with bcrypt >= 4.x.
135151 """
136152 import bcrypt as _bcrypt
137153
0 commit comments