Setting Up a SQLAlchemy and Pytest Based Test Suite
✨ This is an improved article of the previous Python Transactional Tests Using SQL Alchemy, Pytest, and Factory Boy article.
In this post I will show you how you can setup a Pytest test suite that automatically sets up and teardowns your database using SQAlchemy on each test run. It will also provide transactional tests capabilities as described in Python Transactional Tests Using SQL Alchemy, Pytest, and Factory Boy.
Database Setup & Teardown
We are going to be defining the fixtures that will setup and teardown the database. First we will define the database connection responsible for creating the engine and the temporal test database:
# conftest.py
import pytest
from sqlalchemy import create_engine
TEST_DB_NAME = "testdb"
@pytest.fixture(scope="session")
def connection(request):
# Modify this URL according to your database backend
engine = create_engine("mysql+pymysql://root:password@localhost:3306/")
with engine.connect() as connection:
connection.execute(f"CREATE DATABASE {TEST_DB_NAME} CHARACTER SET = 'utf8'")
# Create a new engine/connection that will actually connect
# to the test database we just created. This will be the
# connection used by the test suite run.
engine = create_engine(
f"mysql+pymysql://root:password@localhost:3306/{TEST_DB_NAME}"
)
connection = engine.connect()
def teardown():
connection.execute(f"DROP DATABASE {TEST_DB_NAME}")
connection.close()
request.addfinalizer(teardown)
return connection
This fixture relies on creating two different engines because we first need to create an engine that does not point to any specific database. This is what will allow us to create the actual test database for the test suite execution.
Creating All Database Tables
Now let's go ahead and create the fixture that will create all of our database tables. This assumes that we already have some declared models in our codebase.
# conftest.py
import pytest
from myapp.models import Base
@pytest.fixture(scope="session", autouse=True)
def setup_db(connection, request):
"""Setup test database.
Creates all database tables as declared in SQLAlchemy models,
then proceeds to drop all the created tables after all tests
have finished running.
"""
Base.metadata.bind = connection
Base.metadata.create_all()
def teardown():
Base.metadata.drop_all()
request.addfinalizer(teardown)
This fixture will always run automatically and we do not need to request it thanks to the autouse
flag.
Transactional Tests
Even though the test database will be re-created on each Pytest run, we can further improve this and ensure that each test in the suite runs with no data in the database, ensuring a clean state for each test.
# conftest.py
import pytest
# Import a session factory in our app code. Possibly created using
# `sessionmaker()`
from myapp.db import Session
@pytest.fixture(autouse=True)
def session(connection, request):
transaction = connection.begin()
session = Session(bind=connection)
session.begin_nested()
@event.listens_for(session, "after_transaction_end")
def restart_savepoint(db_session, transaction):
if transaction.nested and not transaction._parent.nested:
session.expire_all()
session.begin_nested()
def teardown():
Session.remove()
transaction.rollback()
request.addfinalizer(teardown)
return session
An example of a the session factory in our application could be:
# myapp/db.py
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, scoped_session
engine = create_engine("mysql+pymysql://root:password@localhost:3306/")
Session = scoped_session(sessionmaker(engine))
Notice the use of scoped_session
. For more information on this usage, read Contextual/Thread-local Sessions from the SQLAlchemy docs.
Factories
You can easily integrate Factory Boy factories into this workflow:
# tests/factories/base.py
import factory
# Same session factory used by the previous fixture
from myapp.db import Session
class BaseFactory(factory.alchemy.SQLAlchemyModelFactory):
class Meta:
sqlalchemy_session = Session
sqlalchemy_session_persistence = "commit"
Now with an actual factory:
# tests/factories/users.py
from myapp.db.models import User
from tests.factories.base import BaseFactory
class UserFactory(BaseFactory):
class Meta:
abstract = True
model = User
Objects generated by these factories will also be rollbacked in each test thanks to the session
fixture we created before. This is
because they both reference the same session factory:
from myapp.db.models import User
from tests.factories.users import UserFactory
class TestUsers:
def test_1(self, session):
user = UserFactory()
assert session.query(User).count() == 1
def test_2(self, session):
assert session.query(User).count() == 0
user = UserFactory.create_batch(3)
assert session.query(User).count() == 3