|
| 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 | |
0 commit comments