Skip to content

Commit 4d46543

Browse files
author
Benjamin PILIA
committed
Add ADR
1 parent 39c1329 commit 4d46543

2 files changed

Lines changed: 183 additions & 6 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: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ def test_configuration():
4747

4848

4949
@pytest_asyncio.fixture(scope="session")
50-
async def test_engine():
50+
async def test_postgres_engine():
5151
conn = await asyncpg.connect("postgresql://postgres:changeme@localhost:5432/postgres")
5252
try:
5353
await conn.execute("CREATE DATABASE test_db")
@@ -111,9 +111,9 @@ def pytest_addoption(parser):
111111

112112

113113
@pytest_asyncio.fixture(scope="function")
114-
async def db_session(test_engine, request) -> AsyncGenerator[AsyncSession]:
115-
async with test_engine.connect() as connection:
116-
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()
117117

118118
session = AsyncSession(bind=connection, expire_on_commit=False)
119119
await session.begin_nested()
@@ -137,9 +137,9 @@ def restart_savepoint(sess, trans):
137137
factory._meta.sqlalchemy_session = None
138138
await session.close()
139139
if request.config.getoption("--commit-db"):
140-
await transaction.commit()
140+
await postgres_outer_transaction.commit()
141141
else:
142-
await transaction.rollback()
142+
await postgres_outer_transaction.rollback()
143143

144144

145145
@pytest.fixture(scope="session")

0 commit comments

Comments
 (0)