Skip to content

Commit 38d247b

Browse files
benjaminpiliaBenjamin PILIA
andauthored
feat(tests): improve integration tests setup (#780)
* improve integration tests setup * Add ADR --------- Co-authored-by: Benjamin PILIA <benjamin.pilia@protonmail.com>
1 parent 56f1cb7 commit 38d247b

3 files changed

Lines changed: 227 additions & 35 deletions

File tree

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
# ADR - 2026-03-17 - Integration Test Isolation via Transaction Rollback
2+
3+
**Status:** Accepted
4+
**Date:** 2026-03-17
5+
**Authors:** Development Team
6+
**Decision Outcome:** Isolate each integration test by rolling back an external Postgres transaction
7+
8+
---
9+
10+
## Context
11+
12+
Integration tests require a real database. Two problems arise:
13+
14+
1. **Isolation**: data created by one test must not affect the following ones.
15+
2. **Performance**: cleaning up the database between each test must be fast.
16+
17+
### Considered Approaches
18+
19+
| Option | Approach | Drawback |
20+
|--------|----------|----------|
21+
| A | `drop_all` / `create_all` between each test | Dozens of DDL statements per function — prohibitive at scale |
22+
| B | `DELETE` / `TRUNCATE` between each test | Requires knowing FK ordering — non-trivial overhead at scale |
23+
| **C**| **Transaction rollback** | None — O(1) cost from Postgres's perspective |
24+
25+
---
26+
27+
## Decision
28+
29+
Use the **transaction rollback** pattern to isolate each test, implemented in `api/tests/integration/conftest.py`.
30+
31+
---
32+
33+
## Implementation
34+
35+
### Three Distinct Levels
36+
37+
**Connection** — the TCP pipe to Postgres. Opening a connection is expensive, which is why SQLAlchemy uses a pool: connections are reused across requests.
38+
39+
**Transaction** — an ACID contract with Postgres, delimited by `BEGIN` / `COMMIT` or `ROLLBACK`. It lives on a connection. If the connection is lost, the transaction disappears. This is managed by Postgres.
40+
41+
**SQLAlchemy Session** — an ORM abstraction that does not exist on the Postgres side. It manages the identity map (a single Python instance per loaded object), the unit of work (accumulating changes before flushing), and object lifecycle. The session drives the connection and transaction via a `SessionTransaction` object, but does not own them strictly: after a `commit()`, it releases the connection back to the pool and starts a new cycle.
42+
43+
### Layer Structure
44+
45+
```
46+
engine (connection pool)
47+
└── connection (TCP connection, pinned manually)
48+
└── transaction (BEGIN — outer transaction)
49+
└── savepoint (SAVEPOINT sp1 — created by begin_nested())
50+
└── [test code]
51+
```
52+
53+
### conftest Mechanics
54+
55+
```python
56+
# 1. Connection and outer transaction opened manually
57+
async with test_postgres_engine.connect() as connection:
58+
postgres_outer_transaction = await connection.begin() # BEGIN
59+
60+
# 2. Session bound to this specific connection
61+
session = AsyncSession(bind=connection, expire_on_commit=False)
62+
await session.begin_nested() # SAVEPOINT sp1
63+
```
64+
65+
By binding the session to the connection via `bind=connection`, the normal mechanism is bypassed: the session no longer borrows a connection from the pool — it always uses this one. Its transactions (savepoints) are therefore nested inside the outer transaction.
66+
67+
### The Listener — Core Mechanism
68+
69+
```python
70+
@event.listens_for(session.sync_session, "after_transaction_end")
71+
def restart_savepoint(sess, trans):
72+
if trans.nested and not trans._parent.nested:
73+
sess.begin_nested() # SAVEPOINT sp2, sp3...
74+
```
75+
76+
SQLAlchemy emits `after_transaction_end` whenever a savepoint ends. When the tested code calls `commit()`, the current savepoint terminates — the listener immediately creates a new one. The tested code can call `commit()` as many times as it wants without ever leaving the transactional bubble.
77+
78+
The condition `trans.nested and not trans._parent.nested` targets only the first-level savepoint, ignoring any sub-savepoints the tested code may create internally.
79+
80+
Note: `event.listens_for` operates on `session.sync_session` because the SQLAlchemy event system only works on the underlying synchronous API.
81+
82+
### Full SQL View of a Test
83+
84+
```sql
85+
BEGIN;
86+
SAVEPOINT sp1;
87+
INSERT INTO user ... -- factory
88+
INSERT INTO router ... -- factory
89+
SELECT * FROM router ... -- assertion or HTTP request
90+
RELEASE SAVEPOINT sp1; -- session.commit() in tested code
91+
SAVEPOINT sp2; -- restart_savepoint listener
92+
...
93+
ROLLBACK; -- end of test, everything disappears
94+
```
95+
96+
### Bridge Between the Fixture and the FastAPI Application
97+
98+
For endpoint tests, the test session must be the same one used by HTTP handlers. The `ContextVar` serves this role:
99+
100+
```python
101+
_current_db_session: ContextVar[AsyncSession | None] = ContextVar(...)
102+
103+
# In db_session (fixture)
104+
token = _current_db_session.set(session)
105+
106+
# In override_get_postgres_session (replaces get_postgres_session)
107+
session = _current_db_session.get()
108+
yield session
109+
```
110+
111+
A `ContextVar` gives each asyncio coroutine its own isolated value. It is the async equivalent of `threading.local()`.
112+
113+
### Ordered Teardown
114+
115+
```python
116+
finally:
117+
_current_db_session.reset(token)
118+
event.remove(session.sync_session, "after_transaction_end", restart_savepoint)
119+
for factory in all_sql_factories:
120+
factory._meta.sqlalchemy_session = None
121+
await session.close() # rolls back the ORM SessionTransaction
122+
if request.config.getoption("--commit-db"):
123+
await postgres_outer_transaction.commit() # debug mode: keep data visible in psql
124+
else:
125+
await postgres_outer_transaction.rollback() # ROLLBACK of the Postgres transaction
126+
```
127+
128+
The order `session.close()` before `transaction.rollback()` is intentional: the session does not own the outer transaction (created directly on the connection before the session existed). `session.close()` cleans up ORM state without touching the Postgres transaction, which survives and is rolled back separately.
129+
130+
### override_get_postgres_session
131+
132+
```python
133+
async def override_get_postgres_session():
134+
session = _current_db_session.get()
135+
try:
136+
yield session
137+
if session.in_transaction():
138+
await session.flush() # makes writes visible, without committing
139+
except Exception:
140+
if session.in_transaction():
141+
await session.rollback() # rolls back this request, not the whole test
142+
raise
143+
```
144+
145+
In production, `get_postgres_session` commits after each request. Here only a `flush()` is performed — writes are visible within the session but never leave the savepoint. On exception, the rollback undoes only the changes from that request; the listener immediately recreates a new savepoint, allowing the test to continue.
146+
147+
---
148+
149+
## Consequences
150+
151+
### Positive
152+
153+
- **Performance**: O(1) rollback, no DDL between tests.
154+
- **Perfect isolation**: each test starts from a clean state guaranteed by Postgres.
155+
- **Realistic endpoint tests**: the same session flows through the entire stack (handler → use case → repository), as in production.
156+
- **Decoupled from lifespan**: the test infrastructure is independent of the application lifespan (`skip_lifespan=True`).
157+
158+
### Negative
159+
160+
- **Complexity**: the savepoint + listener + ContextVar mechanism is non-trivial to understand without documentation.
161+
- **Coupling to SQLAlchemy internals**: `session.sync_session`, `after_transaction_end`, `trans.nested` — these internal APIs may change between major versions.
162+
- **`bind=connection` deprecated**: SQLAlchemy 2.0 marks the `bind` parameter on `AsyncSession` as deprecated. A migration away from `AsyncSession(bind=connection)` toward explicit connection passing may be required in the future.
163+
164+
---
165+
166+
## References
167+
168+
- [SQLAlchemy — Session and Transaction](https://docs.sqlalchemy.org/en/20/orm/session_transaction.html)
169+
- [SQLAlchemy — Joining a Session into an External Transaction](https://docs.sqlalchemy.org/en/20/orm/session_transaction.html#joining-a-session-into-an-external-transaction-such-as-for-test-suites)
170+
171+
---
172+
173+
## Revision History
174+
175+
| Date | Author | Changes |
176+
| --- | --- | --- |
177+
| 2026-03-17 | Development Team | Initial ADR |

api/tests/integration/conftest.py

Lines changed: 28 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from collections.abc import AsyncGenerator
2+
from contextvars import ContextVar
23

34
import asyncpg
45
from httpx import ASGITransport, AsyncClient
@@ -18,9 +19,10 @@
1819
from api.utils.dependencies import get_postgres_session as get_postgres_session_utils
1920

2021
TEST_DATABASE_URL = "postgresql+asyncpg://postgres:changeme@localhost:5432/test_db"
22+
_current_db_session: ContextVar[AsyncSession | None] = ContextVar("_current_db_session", default=None)
2123

2224

23-
@pytest.fixture
25+
@pytest.fixture(scope="session")
2426
def test_configuration():
2527
configuration = Configuration.model_construct(
2628
settings=Settings.model_construct(
@@ -45,7 +47,7 @@ def test_configuration():
4547

4648

4749
@pytest_asyncio.fixture(scope="session")
48-
async def test_engine():
50+
async def test_postgres_engine():
4951
conn = await asyncpg.connect("postgresql://postgres:changeme@localhost:5432/postgres")
5052
try:
5153
await conn.execute("CREATE DATABASE test_db")
@@ -109,9 +111,9 @@ def pytest_addoption(parser):
109111

110112

111113
@pytest_asyncio.fixture(scope="function")
112-
async def db_session(test_engine, request) -> AsyncGenerator[AsyncSession]:
113-
async with test_engine.connect() as connection:
114-
transaction = await connection.begin()
114+
async def db_session(test_postgres_engine, request) -> AsyncGenerator[AsyncSession]:
115+
async with test_postgres_engine.connect() as connection:
116+
postgres_outer_transaction = await connection.begin()
115117

116118
session = AsyncSession(bind=connection, expire_on_commit=False)
117119
await session.begin_nested()
@@ -125,17 +127,19 @@ def restart_savepoint(sess, trans):
125127
if trans.nested and not trans._parent.nested:
126128
sess.begin_nested()
127129

130+
token = _current_db_session.set(session)
128131
try:
129132
yield session
130133
finally:
134+
_current_db_session.reset(token)
131135
event.remove(session.sync_session, "after_transaction_end", restart_savepoint)
132136
for factory in all_sql_factories:
133137
factory._meta.sqlalchemy_session = None
134138
await session.close()
135139
if request.config.getoption("--commit-db"):
136-
await transaction.commit()
140+
await postgres_outer_transaction.commit()
137141
else:
138-
await transaction.rollback()
142+
await postgres_outer_transaction.rollback()
139143

140144

141145
@pytest.fixture(scope="session")
@@ -149,18 +153,19 @@ def model_registry():
149153
)
150154

151155

152-
@pytest_asyncio.fixture(scope="function")
153-
async def app(db_session, model_registry, test_configuration):
156+
@pytest_asyncio.fixture(scope="session")
157+
async def app(model_registry, test_configuration):
154158
app = create_app(test_configuration, skip_lifespan=True)
155159

156160
async def override_get_postgres_session():
161+
session = _current_db_session.get()
157162
try:
158-
yield db_session
159-
if db_session.in_transaction():
160-
await db_session.flush()
163+
yield session
164+
if session.in_transaction():
165+
await session.flush()
161166
except Exception:
162-
if db_session.in_transaction():
163-
await db_session.rollback()
167+
if session.in_transaction():
168+
await session.rollback()
164169
raise
165170

166171
app.dependency_overrides[get_postgres_session] = override_get_postgres_session
@@ -173,7 +178,15 @@ async def override_get_postgres_session():
173178
app.dependency_overrides.clear()
174179

175180

176-
@pytest_asyncio.fixture(scope="function")
181+
@pytest_asyncio.fixture(scope="function", autouse=True)
182+
async def _restore_dependency_overrides(app):
183+
snapshot = dict(app.dependency_overrides)
184+
yield
185+
app.dependency_overrides.clear()
186+
app.dependency_overrides.update(snapshot)
187+
188+
189+
@pytest_asyncio.fixture(scope="session")
177190
async def client(app) -> AsyncGenerator[AsyncClient, None]:
178191
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as ac:
179192
yield ac

api/tests/integration/test_createapp.py

Lines changed: 22 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@
99
from api.utils.variables import EndpointRoute, RouterName
1010

1111

12-
@pytest.fixture(scope="class")
13-
def test_configuration() -> Configuration:
12+
@pytest.fixture(scope="session")
13+
def createapp_configuration() -> Configuration:
1414
return Configuration.model_construct(
1515
settings=Settings.model_construct(
1616
app_title="test",
@@ -32,9 +32,9 @@ def test_configuration() -> Configuration:
3232
)
3333

3434

35-
@pytest_asyncio.fixture(scope="class")
36-
async def client(test_configuration) -> AsyncGenerator[AsyncClient, None]:
37-
app = create_app(test_configuration, skip_lifespan=True)
35+
@pytest_asyncio.fixture(scope="session")
36+
async def createapp_client(createapp_configuration) -> AsyncGenerator[AsyncClient, None]:
37+
app = create_app(createapp_configuration, skip_lifespan=True)
3838

3939
try:
4040
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as ac:
@@ -45,54 +45,56 @@ async def client(test_configuration) -> AsyncGenerator[AsyncClient, None]:
4545

4646
@pytest.mark.asyncio(loop_scope="session")
4747
class TestCreateApp:
48-
async def test_reach_swagger_with_non_default_url_configuration_is_reachable(self, client: AsyncClient, test_configuration: Configuration):
48+
async def test_reach_swagger_with_non_default_url_configuration_is_reachable(
49+
self, createapp_client: AsyncClient, createapp_configuration: Configuration
50+
):
4951
# Act
50-
response = await client.get(url=test_configuration.settings.swagger_docs_url)
52+
response = await createapp_client.get(url=createapp_configuration.settings.swagger_docs_url)
5153

5254
# Assert
5355
assert response.status_code == 200, f"Expected 200, got {response.status_code}: {response.text}"
5456

55-
async def test_redoc_with_non_default_url_configuration_is_reachable(self, client: AsyncClient, test_configuration: Configuration):
57+
async def test_redoc_with_non_default_url_configuration_is_reachable(self, createapp_client: AsyncClient, createapp_configuration: Configuration):
5658
# Act
57-
response = await client.get(url=test_configuration.settings.swagger_redoc_url)
59+
response = await createapp_client.get(url=createapp_configuration.settings.swagger_redoc_url)
5860

5961
# Assert
6062
assert response.status_code == 200, f"Expected 200, got {response.status_code}: {response.text}"
6163

62-
async def test_exposed_openapi_schema_is_reachable(self, client: AsyncClient):
64+
async def test_exposed_openapi_schema_is_reachable(self, createapp_client: AsyncClient):
6365
# Act
64-
response = await client.get(url="/openapi.json")
66+
response = await createapp_client.get(url="/openapi.json")
6567

6668
# Assert
6769
assert response.status_code == 200, f"Expected 200, got {response.status_code}: {response.text}"
6870

69-
async def test_enabled_router_is_reachable(self, client: AsyncClient, test_configuration: Configuration):
71+
async def test_enabled_router_is_reachable(self, createapp_client: AsyncClient, createapp_configuration: Configuration):
7072
# Act
71-
response = await client.get(url=f"/v1{EndpointRoute.ME_INFO}")
73+
response = await createapp_client.get(url=f"/v1{EndpointRoute.ME_INFO}")
7274

7375
# Assert
7476
assert response.status_code == 401, f"Expected 401, got {response.status_code}: {response.text}"
7577

76-
async def test_disabled_router_is_unreachable(self, client: AsyncClient, test_configuration: Configuration):
78+
async def test_disabled_router_is_unreachable(self, createapp_client: AsyncClient, createapp_configuration: Configuration):
7779
# Act
78-
response = await client.get(url=f"/v1/{test_configuration.settings.disabled_routers[0]}")
80+
response = await createapp_client.get(url=f"/v1/{createapp_configuration.settings.disabled_routers[0]}")
7981

8082
# Assert
8183
assert response.status_code == 404, f"Expected 404, got {response.status_code}: {response.text}"
8284

83-
async def test_hidden_router_is_reachable(self, client: AsyncClient, test_configuration: Configuration):
85+
async def test_hidden_router_is_reachable(self, createapp_client: AsyncClient, createapp_configuration: Configuration):
8486
# Act
85-
response = await client.get(url=f"/v1/{test_configuration.settings.hidden_routers[0]}")
87+
response = await createapp_client.get(url=f"/v1/{createapp_configuration.settings.hidden_routers[0]}")
8688

8789
# Assert
8890
assert response.status_code == 401, f"Expected 401, got {response.status_code}: {response.text}"
8991

90-
async def test_hidden_router_is_not_in_exposed_openapi_schema(self, client: AsyncClient, test_configuration: Configuration):
92+
async def test_hidden_router_is_not_in_exposed_openapi_schema(self, createapp_client: AsyncClient, createapp_configuration: Configuration):
9193
# Act
92-
response = await client.get(url="/openapi.json")
94+
response = await createapp_client.get(url="/openapi.json")
9395

9496
# Assert
95-
hidden_router_path = f"/v1/{test_configuration.settings.hidden_routers[0]}"
97+
hidden_router_path = f"/v1/{createapp_configuration.settings.hidden_routers[0]}"
9698
assert hidden_router_path not in response.json()["paths"], f"Hidden route {hidden_router_path} is exposed in OpenAPI schema"
9799

98100

0 commit comments

Comments
 (0)