Skip to content

Commit 54143e7

Browse files
authored
Merge pull request #32 from pallets-eco/test-isolation
add test isolation manager
2 parents f52fdcd + e1c2639 commit 54143e7

File tree

5 files changed

+181
-22
lines changed

5 files changed

+181
-22
lines changed

CHANGES.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ Unreleased
55
- Drop support for Python 3.9.
66
- Add `get_or_abort` and `one_or_abort` methods, which get a single row or
77
otherwise tell Flask to abort with a 404 error.
8+
- Add `test_isolation` context manager, which isolates changes to the database
9+
so that tests don't affect each other.
810

911
## Version 0.1.0
1012

docs/testing.md

Lines changed: 7 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -149,7 +149,8 @@ Each engine in `db.engines` can be patched to represent a connection with a
149149
transaction instead of a pool. Then all operations will occur inside the
150150
transaction and be discarded at the end, without writing anything permanently.
151151

152-
Modify the `app` fixture to do this patching.
152+
Modify the `app` fixture to do this patching with the
153+
{meth}`.SQLAlchemy.test_isolation` context manager.
153154

154155
```python
155156
import pytest
@@ -160,32 +161,17 @@ def app(monkeypatch):
160161
app = create_app({
161162
"SQLALCHEMY_ENGINES": {"default": "postgresql:///project-test"}
162163
})
163-
cleanup = []
164164

165-
with app.app_context():
166-
monkeypatch.setitem(
167-
db.sessionmaker.kw, "join_transaction_mode", "create_savepoint"
168-
)
169-
170-
for engine in db.engines.values():
171-
connection = engine.connect()
172-
transaction = connection.begin()
173-
cleanup.append(transaction.rollback)
174-
cleanup.append(connection.close)
175-
connection.close = lambda: None
176-
connection.begin = connection.begin_nested
177-
monkeypatch.setattr(engine, "connect", lambda _c=connection: _c)
178-
179-
yield app
180-
181-
for f in cleanup:
182-
f()
165+
with db.test_isolation():
166+
yield app
183167
```
184168

185169
This is not needed when using a SQLite in memory database as discussed above, as
186170
each test will already be using a separate app with a separate in memory
187-
database.
171+
database. If you do use it with SQLite, you'll need to [fix the SQLite driver's
172+
transaction behavior, as described in SQLAlchemy's docs][transaction].
188173

174+
[transaction]: https://docs.sqlalchemy.org/dialects/sqlite.html#sqlite-transactions
189175

190176
## Async
191177

src/flask_sqlalchemy_lite/_extension.py

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,13 @@
11
from __future__ import annotations
22

3+
import collections.abc as cabc
34
import typing as t
5+
from contextlib import asynccontextmanager
6+
from contextlib import AsyncExitStack
7+
from contextlib import contextmanager
8+
from contextlib import ExitStack
49
from dataclasses import dataclass
10+
from unittest import mock
511
from weakref import WeakKeyDictionary
612

713
import sqlalchemy as sa
@@ -406,6 +412,89 @@ async def async_one_or_abort(
406412
except (sa_exc.NoResultFound, sa_exc.MultipleResultsFound):
407413
abort(code, **(abort_kwargs or {}))
408414

415+
@contextmanager
416+
def test_isolation(self) -> cabc.Iterator[None]:
417+
"""Context manager to isolate the database during a test. Commits are
418+
rolled back when the ``with`` block exits, and will not be seen by other
419+
tests.
420+
421+
This patches the SQLAlchemy engine and session to use a single
422+
connection, in a transaction that is rolled back when the context exits.
423+
424+
If the code being tested uses async features, use
425+
:meth:`async_test_isolation` instead. It will isolate both the sync and
426+
async operations.
427+
428+
When using SQLite, follow the `SQLAlchemy docs`__ to fix the driver's
429+
transaction handling.
430+
431+
__ https://docs.sqlalchemy.org/en/20/dialects/sqlite.html#sqlite-transactions
432+
433+
.. versionadded:: 0.2
434+
"""
435+
with ExitStack() as exit_stack:
436+
# Instruct the session to use nested transactions when it sees that
437+
# its connection is already in a transaction.
438+
exit_stack.enter_context(
439+
mock.patch.dict(
440+
self.sessionmaker.kw, {"join_transaction_mode": "create_savepoint"}
441+
)
442+
)
443+
444+
for engine in self.engines.values():
445+
# Create the connection, to be closed when the context exits.
446+
connection: sa.Connection = exit_stack.enter_context(engine.connect())
447+
# The connection cannot be closed by code being tested. This
448+
# ensures the transaction remains active.
449+
connection.close = _nop # type: ignore[method-assign]
450+
# The engine will always return the same connection, with the
451+
# active transaction.
452+
exit_stack.enter_context(
453+
mock.patch.object(engine, "connect", lambda _c=connection: _c)
454+
)
455+
# Start the transaction, to be rolled back when the context exits.
456+
transaction = connection.begin()
457+
exit_stack.callback(transaction.rollback)
458+
# If code being tested tries to start the transaction, start a
459+
# nested transaction instead.
460+
connection.begin = connection.begin_nested # type: ignore[assignment]
461+
462+
yield None
463+
464+
@asynccontextmanager
465+
async def async_test_isolation(self) -> cabc.AsyncIterator[None]:
466+
"""Async version of :meth:`test_isolation` to be used as an
467+
``async with`` block. It will isolate the sync code as well, so you do
468+
not need to use ``test_isolation`` as well.
469+
470+
.. versionadded:: 0.2
471+
"""
472+
async with AsyncExitStack() as exit_stack:
473+
# Also isolate the sync operations.
474+
exit_stack.enter_context(self.test_isolation())
475+
476+
await exit_stack.enter_context(
477+
mock.patch.dict(
478+
self.async_sessionmaker.kw,
479+
{"join_transaction_mode": "create_savepoint"},
480+
)
481+
)
482+
483+
for engine in self.async_engines.values():
484+
connection: sa_async.AsyncConnection = (
485+
await exit_stack.enter_async_context(engine.connect())
486+
)
487+
connection.close = _async_nop # type: ignore[method-assign]
488+
connection.aclose = _async_nop # type: ignore[method-assign]
489+
exit_stack.enter_context(
490+
mock.patch.object(engine, "connect", lambda _c=connection: _c)
491+
)
492+
transaction = connection.begin()
493+
exit_stack.push_async_callback(transaction.rollback)
494+
connection.begin = connection.begin_nested # type: ignore[method-assign]
495+
496+
yield None
497+
409498

410499
@dataclass
411500
class _State:
@@ -439,3 +528,11 @@ async def _close_async_sessions(e: BaseException | None) -> None:
439528

440529
for session in sessions.values():
441530
await session.close()
531+
532+
533+
def _nop() -> None:
534+
pass
535+
536+
537+
async def _async_nop() -> None:
538+
pass

tests/conftest.py

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,17 @@
22

33
import collections.abc as cabc
44
import os
5+
import sys
56
import typing as t
67
from pathlib import Path
78

89
import pytest
10+
import sqlalchemy as sa
911
from flask import Flask
1012
from flask.ctx import AppContext
13+
from sqlalchemy import event
14+
from sqlalchemy.engine.interfaces import DBAPIConnection
15+
from sqlalchemy.pool import ConnectionPoolEntry
1116

1217
from flask_sqlalchemy_lite import SQLAlchemy
1318

@@ -44,4 +49,32 @@ def app_ctx(app: Flask) -> t.Iterator[AppContext]:
4449

4550
@pytest.fixture
4651
def db(app: Flask) -> SQLAlchemy:
47-
return SQLAlchemy(app)
52+
engine_options: dict[str, t.Any] = {}
53+
54+
if sys.version_info >= (3, 12):
55+
# Fix sqlite driver's handling of transactions.
56+
# https://docs.sqlalchemy.org/en/20/dialects/sqlite.html#sqlite-transactions
57+
engine_options["connect_args"] = {"autocommit": False}
58+
59+
return SQLAlchemy(app, engine_options=engine_options)
60+
61+
62+
if sys.version_info < (3, 12):
63+
# Fix sqlite3 driver's handling of transactions.
64+
# https://docs.sqlalchemy.org/en/20/dialects/sqlite.html#sqlite-transactions
65+
66+
def _sqlite_connect(
67+
dbapi_connection: DBAPIConnection, connection_record: ConnectionPoolEntry
68+
) -> None:
69+
dbapi_connection.isolation_level = None
70+
71+
def _sqlite_begin(conn: sa.Connection) -> None:
72+
conn.exec_driver_sql("BEGIN")
73+
74+
@pytest.fixture(scope="session", autouse=True)
75+
def _sqlite_isolation() -> cabc.Iterator[None]:
76+
event.listen(sa.Engine, "connect", _sqlite_connect)
77+
event.listen(sa.Engine, "begin", _sqlite_begin)
78+
yield
79+
event.remove(sa.Engine, "begin", _sqlite_begin)
80+
event.remove(sa.Engine, "connect", _sqlite_connect)

tests/test_isolation.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
from __future__ import annotations
2+
3+
import sqlalchemy as sa
4+
from flask import Flask
5+
from sqlalchemy import orm
6+
7+
from flask_sqlalchemy_lite import SQLAlchemy
8+
9+
10+
class Base(orm.DeclarativeBase):
11+
pass
12+
13+
14+
class Todo(Base):
15+
__tablename__ = "todo"
16+
id: orm.Mapped[int] = orm.mapped_column(primary_key=True)
17+
18+
19+
def test_isolation(app: Flask, db: SQLAlchemy) -> None:
20+
# Database setup, this item will be present at the start of each isolated block.
21+
with app.app_context():
22+
Base.metadata.create_all(db.engine)
23+
db.session.add(Todo())
24+
db.session.commit()
25+
26+
# Sees the setup item, adds a new item.
27+
with app.app_context(), db.test_isolation():
28+
db.session.add(Todo())
29+
db.session.commit()
30+
assert db.session.scalar(sa.select(sa.func.count(Todo.id))) == 2
31+
32+
# Does not see the previous added item, deletes the setup item.
33+
with app.app_context(), db.test_isolation():
34+
assert db.session.scalar(sa.select(sa.func.count(Todo.id))) == 1
35+
db.session.delete(db.session.get_one(Todo, 1))
36+
db.session.commit()
37+
assert db.session.scalar(sa.select(sa.func.count(Todo.id))) == 0
38+
39+
# Deleted setup item has returned.
40+
with app.app_context():
41+
assert db.session.scalar(sa.select(sa.func.count(Todo.id))) == 1

0 commit comments

Comments
 (0)