Skip to content

Commit 348e1c5

Browse files
committed
Refactored with much improved stability and less hacks
1 parent f9c6661 commit 348e1c5

File tree

6 files changed

+126
-155
lines changed

6 files changed

+126
-155
lines changed

README.rst

Lines changed: 60 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -41,28 +41,83 @@ Requirements
4141
Installation
4242
------------
4343

44-
You can install "pytest-sqlalchemy" via `pip`_ from `GitHub`_::
44+
You can install "pytest-sqlalchemy" via `pip`_ from `GitHub`_
45+
46+
.. code-block:: shell
4547
4648
$ pip install -e git+https://github.com/crowdcomms/pytest-sqlalchemy.git#egg=pytest-sqlalchemy
4749
4850
4951
Usage
5052
-----
5153

52-
You need to provide a couple of fixtures to inject your sqlalchemy Base and Session classes ( the ones your regular app uses ). eg::
54+
The plugin will create a completely new test database that lasts as long as the test session and is destroyed at the end.
55+
You can execute each test case in an isolated transaction by including the ``db_session`` fixture, eg:
56+
57+
.. code-block:: python
58+
59+
def test_create_foo(db_session):
60+
foo = Foo(name="bar")
61+
db_session.add(foo)
62+
db_session.commit()
63+
assert db_session.query(Foo).count()
64+
65+
The transaction is automatically rolled back at the end of the test function giving you a clean slate for the next test.
66+
67+
You need to define a couple of fixtures to be able to use the plugin, this is mostly to 'patch' your existing Session and Base classes to use the testing database. This is probably the most tricky bit of the plugin as sqlalchemy usage in projects can vary somewhat
68+
69+
Required fixtures
70+
^^^^^^^^^^^^^^^^^
71+
``sqlalchemy_base_class``
72+
73+
You must set this to your base class that you use for defining models in your project, eg:
74+
75+
.. code-block:: python
5376
5477
# my_project/tests/conftest.py
55-
56-
from my_app.db import Base, Session
78+
79+
from my_project.database import Base
5780
5881
@pytest.fixture(scope='session')
5982
def sqlalchemy_base():
6083
return Base
6184
85+
``sqlalchemy_session_class``
86+
87+
Use this fixture to supply your project's sqlalchemy Session factory, eg:
88+
89+
.. code-block:: python
90+
91+
# my_project/database.py
92+
93+
from sqlalchemy.ext.declarative import declarative_base
94+
from sqlalchemy.orm import scoped_session, sessionmaker
95+
96+
Base = declarative_base()
97+
Session = scoped_session(sessionmaker())
98+
99+
...
100+
101+
# my_project/tests/conftest.py
102+
103+
from my_project.database import Session
104+
62105
@pytest.fixture(scope='session')
63-
def sqlalchemy_session():
106+
def sqlalchemy_session_class():
64107
return Session
65108
109+
If your project uses a different way to obtain a sqlalchemy session, then you'll need to figure out some other way to configure that session to use the test database, possibly by mocking it in individual test cases.
110+
111+
Optional Fixtures
112+
^^^^^^^^^^^^^^^^^
113+
114+
``database_url``
115+
116+
This defaults to ``os.environ['DATABASE_URL']`` but is designed to be overridden to supply an alternative. The plugin will attempt to connect to whatever database is specified and create another database alongside the original, prefixed with ``test_``
117+
118+
``test_db_prefix``
119+
120+
If you don't like ``test_`` as a prefix for your testing database, return something else here.
66121

67122
Contributing
68123
------------

example_app/db.py

Lines changed: 4 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,13 @@
1-
from sqlalchemy import Column, Integer, String, Table, ForeignKey, DateTime
2-
from sqlalchemy.orm import relationship
1+
from sqlalchemy import Column, Integer, String
32
from sqlalchemy.ext.declarative import declarative_base
4-
from sqlalchemy import create_engine
53
from sqlalchemy.orm import scoped_session, sessionmaker
6-
import os
74

8-
engine = create_engine(os.environ['DATABASE_URL'])
9-
10-
Base = declarative_base(bind=engine)
11-
12-
Session = sessionmaker(expire_on_commit=False, bind=engine)
13-
14-
session = Session()
5+
Base = declarative_base()
6+
Session = scoped_session(sessionmaker())
157

168

179
class User(Base):
18-
1910
__tablename__ = 'users'
2011

2112
id = Column(Integer, primary_key=True)
22-
name = Column(String)
13+
name = Column(String)

pytest_sqlalchemy.py

Lines changed: 51 additions & 131 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,14 @@
11
# -*- coding: utf-8 -*-
2-
import pytest
2+
import os
3+
34
from sqlalchemy import create_engine
4-
from sqlalchemy.orm import sessionmaker
5-
# from db.models import Base, Session, App
6-
import contextlib
5+
from sqlalchemy.engine.url import make_url
6+
from sqlalchemy.exc import ProgrammingError
77
import logging
88
import pytest
9-
import sqlalchemy
10-
import os
11-
import importlib
129

13-
log = logging.getLogger(__name__)
10+
logger = logging.getLogger(__name__)
11+
1412

1513
def pytest_addoption(parser):
1614
group = parser.getgroup('sqlalchemy')
@@ -27,8 +25,8 @@ def pytest_addoption(parser):
2725

2826

2927
@pytest.fixture(scope='session')
30-
def db_prefix(request):
31-
return request.config.option.test_db_prefix
28+
def test_db_prefix():
29+
return 'test_'
3230

3331

3432
@pytest.fixture(scope='session')
@@ -37,146 +35,68 @@ def database_url():
3735

3836

3937
@pytest.fixture(scope='session')
40-
def test_db(request, database_url, db_prefix):
41-
"""
42-
Create a testing database for the test session.
43-
Returns an instance of "TestingDB".
44-
"""
45-
test_db = TestingDB(
46-
base_db_url=database_url,
47-
drop_existing=request.config.getini('drop_existing_test_db'),
48-
prefix=db_prefix
49-
)
50-
test_db.create()
51-
request.addfinalizer(test_db.drop)
52-
return test_db
38+
def test_database_url(test_db_prefix, database_url):
39+
test_url = make_url(database_url)
40+
test_url.database = test_db_prefix + test_url.database
41+
return test_url
5342

5443

5544
@pytest.fixture(scope='session')
56-
def test_engine(request, test_db):
57-
"""
58-
A database Engine connected to the testing database.
59-
"""
60-
engine = test_db.create_engine()
61-
62-
@request.addfinalizer
63-
def cleanup():
64-
log.info("Disposing test_engine")
65-
engine.dispose()
66-
67-
return engine
68-
69-
70-
class TestingDB:
71-
"""
72-
A test database used for a testing session.
73-
This class just provides an empty test database which then can be used to
74-
create the schema elements.
75-
"""
76-
77-
def __init__(self, base_db_url, drop_existing=False, prefix='test'):
78-
self._base_db_url = self._to_url(base_db_url)
79-
self._db_url = self._create_test_url(prefix)
80-
self._drop_existing = drop_existing
81-
82-
@property
83-
def database(self):
84-
return self._db_url.database
85-
86-
@property
87-
def url(self):
88-
return self._db_url
89-
90-
def create(self):
91-
log.info("Creating test database %s", self.database)
92-
with self.connect() as conn:
93-
if self._drop_existing:
94-
self._try_drop(conn)
95-
conn.execute("CREATE DATABASE {}".format(self.database))
96-
97-
def _try_drop(self, conn):
98-
try:
99-
conn.execute("DROP DATABASE {}".format(self.database))
100-
except sqlalchemy.exc.ProgrammingError:
101-
pass
102-
finally:
103-
conn.execute("ROLLBACK")
104-
105-
def drop(self):
106-
log.info("Dropping test database %s", self.database)
107-
with self.connect() as conn:
108-
conn.execute("DROP DATABASE {}".format(self.database))
109-
110-
@contextlib.contextmanager
111-
def connect(self):
112-
engine = sqlalchemy.create_engine(self._base_db_url)
113-
conn = engine.connect()
114-
conn.execution_options(autocommit=False)
115-
conn.execute("ROLLBACK")
116-
yield conn
117-
conn.close()
118-
engine.dispose()
119-
120-
def create_engine(self):
121-
"""
122-
Create an Engine for the test database.
123-
"""
124-
log.info("Creating new Engine for %s", self.database)
125-
return sqlalchemy.create_engine(
126-
self.url,
127-
# TODO: johbo: Without this pool I see trouble due to a left
128-
# connection which prevents us dropping the database. Needs
129-
# investigation how to solve this in a proper way. Based on the
130-
# docs I would expect StaticPool to just work fine, but for some
131-
# reason it does not.
132-
poolclass=sqlalchemy.pool.AssertionPool,
133-
echo=False,
134-
echo_pool=False,
135-
connect_args={'options': '-c timezone=utc'}
136-
)
137-
138-
def _to_url(self, url):
139-
# Ensure to create a copy actually
140-
url = str(url)
141-
return sqlalchemy.engine.url.make_url(url)
142-
143-
def _create_test_url(self, prefix):
144-
test_url = self._to_url(self._base_db_url)
145-
test_url.database = prefix + self._base_db_url.database
146-
return test_url
45+
def test_db(database_url, test_database_url):
46+
engine = create_engine(database_url)
47+
conn = engine.connect()
48+
conn.execution_options(autocommit=False)
49+
conn.execute('ROLLBACK')
50+
51+
try:
52+
conn.execute(f"DROP DATABASE {test_database_url.database}")
53+
except ProgrammingError:
54+
pass
55+
finally:
56+
conn.execute('ROLLBACK')
57+
58+
logger.debug('Creating Test Database {}'.format(test_database_url.database))
14759

60+
conn.execute("CREATE DATABASE {}".format(test_database_url.database))
61+
62+
conn.close()
63+
engine.dispose()
14864

14965

15066
@pytest.fixture(scope='session')
15167
def sqlalchemy_base():
152-
return None
68+
raise ValueError('Please supply sqlalchemy_base fixture')
15369

15470

15571
@pytest.fixture(scope='session')
156-
def sqlalchemy_session():
157-
return None
72+
def sqlalchemy_session_class():
73+
raise ValueError('Please supply sqlalchemy_session_class fixture')
15874

15975

16076
@pytest.fixture(scope='session')
161-
def connection(request, test_engine, sqlalchemy_base, sqlalchemy_session):
162-
163-
sqlalchemy_base.metadata.bind = test_engine
164-
sqlalchemy_base.metadata.create_all(test_engine)
77+
def engine(test_database_url):
78+
return create_engine(test_database_url)
16579

166-
with test_engine.connect() as conn:
167-
yield conn
16880

169-
sqlalchemy_base.metadata.drop_all()
81+
@pytest.yield_fixture(scope='session')
82+
def tables(engine, sqlalchemy_base, test_db):
83+
sqlalchemy_base.metadata.create_all(engine)
84+
yield
85+
sqlalchemy_base.metadata.drop_all(engine)
17086

17187

172-
@pytest.fixture
173-
def db_session(request, connection, sqlalchemy_session, monkeypatch):
88+
@pytest.yield_fixture(scope='function')
89+
def db_session(engine, tables, sqlalchemy_session_class):
90+
sqlalchemy_session_class.remove()
91+
with engine.connect() as connection:
92+
transaction = connection.begin_nested()
93+
sqlalchemy_session_class.configure(bind=connection)
94+
session = sqlalchemy_session_class()
17495

175-
transaction = connection.begin()
176-
sqlalchemy_session.bind = connection
96+
session.begin_nested()
17797

178-
yield sqlalchemy_session
98+
yield session
17999

180-
transaction.rollback()
181-
sqlalchemy_session.close()
100+
session.close()
101+
transaction.rollback()
182102

tests/conftest.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
import pytest
22
pytest_plugins = 'pytester'
33

4-
from example_app.db import Base, session
4+
from example_app.db import Base, Session
5+
56

67
@pytest.fixture(scope='session')
78
def sqlalchemy_base():
89
return Base
910

11+
1012
@pytest.fixture(scope='session')
11-
def sqlalchemy_session():
12-
return session
13+
def sqlalchemy_session_class():
14+
return Session

tests/test_db_fixture.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
from example_app.db import User
22

3+
34
def test_create_user(db_session):
45
user = User(name='The User')
56
db_session.add(user)
67
db_session.flush()
78
assert db_session.query(User).count() == 1
89

10+
911
def test_update_object(db_session):
1012
user = User(name='The User')
1113
db_session.add(user)
@@ -17,5 +19,6 @@ def test_update_object(db_session):
1719

1820
db_session.query(User).filter(User.name=='Bongo').one()
1921

22+
2023
def test_tests_run_in_transactions(db_session):
21-
assert db_session.query(User).count() == 0
24+
assert db_session.query(User).count() == 0

tests/test_pytest_sqlalchemy.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@
33
def test_db_prefix_fixture(testdir):
44

55
testdir.makepyfile("""
6-
def test_dbprefix(db_prefix):
7-
assert db_prefix == "test"
6+
def test_dbprefix(test_db_prefix):
7+
assert test_db_prefix == "test_"
88
""")
99

1010
result = testdir.runpytest(

0 commit comments

Comments
 (0)