In work projects in the past, my team would try to avoid dealing with the complexity of SQLAlchemy database sessions by making one global session that every module referenced. It made things easy and straightforward and it played nice with FactoryBoy - whose factories seem to work quite well under those conditions.
Going into building lobit.io, I tried to do things the same way, but as the code base and the number of unit tests grew, I kept facing what seemed to be an unquashable avalanche of OperationalErrors griping about “Too many connections”.
I implemented an overhaul of the codebase, wrapping every piece of ORM code in a separate session and creating a DbUtils class to handle sessions consistently without too much boilerplate:
from contextlib import contextmanager
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.orm.session import Session
from config import config
class DbUtils:
Session = sessionmaker(bind=create_engine(config.db_connection_url))
@classmethod
@contextmanager
def session_scope(cls) -> Session:
"""Provide a transactional scope around a series of operations."""
session = cls.Session()
try:
yield session
session.commit()
except:
session.rollback()
raise
finally:
session.close()
Fixing the test factories
When it came to unit tests, I was at a loss for how to best handle all of the factories I was using. They were all tied into the global session. Furthermore, factories took in the session at the time of class creation, not instantiation, e.g.
import factory
from models.post import Post
from tests.factories.post_thread_factory import PostThreadFactory
class PostFactory(factory.alchemy.SQLAlchemyModelFactory):
class Meta:
model = Post
sqlalchemy_session_persistence = "commit"
sqlalchemy_session = session
text = factory.Faker("text", max_nb_chars=100)
external_id = factory.Faker("text", max_nb_chars=50)
post_thread = factory.SubFactory(PostThreadFactory)
Wrapping factory class creation in a function did the trick:
import factory
from models.post import Post
from tests.factories.post_thread_factory import PostThreadFactory
def PostFactory(session):
class _PostFactory(factory.alchemy.SQLAlchemyModelFactory):
class Meta:
model = Post
sqlalchemy_session_persistence = "commit"
sqlalchemy_session = session
text = factory.Faker("text", max_nb_chars=100)
external_id = factory.Faker("text", max_nb_chars=50)
post_thread = factory.SubFactory(PostThreadFactory(session))
return _PostFactory
While simple wrapping worked, it made my tests look God-awful. Manually managing sessions had already added boilerplate overhead to every unit test that hit the database.
Before refactoring, I could create a post using PostFactory.create()
. Now it was PostFactory(session).create()
. Most of the unit tests hit multiple factories several times each, so it was also less efficient to keep creating the same classes over and over.
I took to creating a single instance of each required factory at the top of each session:
def test_post_stuff(self):
with DbUtils.session_scope() as session:
post_factory = PostFactory(session)
post_thread_factory = PostThreadFactory(session)
...
Cleaning it up
A given test could have nearly a dozen factories defined at the top - which looked terrible even if it didn’t trigger a too-many-locals
warning from Pylint (which it almost always did).
So I made one more change and hired a manager to keep all the factories running smoothly.
from tests.factories.post_factory import PostFactory
from tests.factories.post_thread_factory import PostThreadFactory
...
class FactoryManager:
known_factories = [
PostFactory,
PostThreadFactory,
...
]
def __init__(self, session):
self.session = session
for factory_func in self.known_factories:
setattr(self, factory_func.__name__, factory_func(self.session))
The FactoryManager didn’t require a lot of code, but let me leave my tests largely unchanged. Instead of initializing several objects at the top of each test, I could initialize one.
def test_post_stuff(self):
with DbUtils.session_scope() as session:
fm = FactoryManager(session)
Now I could prefix all factory calls with fm.
and get something almost as clean as before. At least, fm.PostFactory.create()
looks better to me than PostFactory(session).create()
or littering each test with a bunch of locals.