Skip to content

Testing EllarSQL Models

There are various approaches to testing SQLAlchemy models, but in this section, we will focus on setting up a good testing environment for EllarSQL models using the Ellar Test factory and pytest.

For an effective testing environment, it is recommended to utilize the EllarSQLModule.register_setup() approach to set up the EllarSQLModule. This allows you to add a new configuration for ELLAR_SQL specific to your testing database, preventing interference with production or any other databases in use.

Defining TestConfig

There are various methods for configuring test settings in Ellar, as outlined here. However, in this section, we will adopt the 'in a file' approach.

Within the db_learning/config.py file, include the following code:

db_learning/config.py
import typing as t
...

class DevelopmentConfig(BaseConfig):
    DEBUG: bool = True
    # Configuration through Config
    ELLAR_SQL: t.Dict[str, t.Any] = {
        'databases': {
            'default': 'sqlite:///project.db',
        },
        'echo': True,
        'migration_options': {
            'directory': 'migrations'
        },
        'models': ['models']
    }

class TestConfig(BaseConfig):
    DEBUG = False

    ELLAR_SQL: t.Dict[str, t.Any] = {
        **DevelopmentConfig.ELLAR_SQL, 
        'databases': {
            'default': 'sqlite:///test.db',
        },
        'echo': False,
    }

This snippet demonstrates the 'in a file' approach to setting up the TestConfig class within the same db_learning/config.py file.

Changes made:

  1. Updated the databases section to use sqlite+aiosqlite:///test.db for the testing database.
  2. Set echo to True to enable SQLAlchemy output during testing for cleaner logs.
  3. Preserved the migration_options and models configurations from DevelopmentConfig.

Also, feel free to further adjust it based on your specific testing requirements!

Test Fixtures

After defining TestConfig, we need to add some pytest fixtures to set up EllarSQLModule and another one that returns a session for testing purposes. Additionally, we need to export ELLAR_CONFIG_MODULE to point to the newly defined TestConfig.

tests/conftest.py
import os
import pytest
from ellar.common.constants import ELLAR_CONFIG_MODULE
from ellar.testing import Test
from ellar_sql import EllarSQLService
from db_learning.root_module import ApplicationModule

# Setting the ELLAR_CONFIG_MODULE environment variable to TestConfig
os.environ.setdefault(ELLAR_CONFIG_MODULE, "db_learning.config:TestConfig")

# Fixture for creating a test module
@pytest.fixture(scope='session')
def tm():
    test_module = Test.create_test_module(modules=[ApplicationModule])
    yield test_module

# Fixture for creating a database session for testing
@pytest.fixture(scope='session')
def db(tm):
    db_service = tm.get(EllarSQLService)

    # Creating all tables
    db_service.create_all()

    yield

    # Dropping all tables after the tests
    db_service.drop_all()

# Fixture for creating a database session for testing
@pytest.fixture(scope='session')
def db_session(db, tm):
    db_service = tm.get(EllarSQLService)

    yield db_service.session_factory()

    # Removing the session factory
    db_service.session_factory.remove()

The provided fixtures help in setting up a testing environment for EllarSQL models. The Test.create_test_module method creates a TestModule for initializing your Ellar application, and the db_session fixture initializes a database session for testing, creating and dropping tables as needed.

If you are working with asynchronous database drivers, you can convert db_session into an async function to handle coroutines seamlessly.

Alembic Migration with Test Fixture

In cases where there are already generated database migration files, and there is a need to apply migrations during testing, this can be achieved as shown in the example below:

tests/conftest.py
import os
import pytest
from ellar.common.constants import ELLAR_CONFIG_MODULE
from ellar.testing import Test
from ellar_sql import EllarSQLService
from ellar_sql.cli.handlers import CLICommandHandlers
from db_learning.root_module import ApplicationModule

# Setting the ELLAR_CONFIG_MODULE environment variable to TestConfig
os.environ.setdefault(ELLAR_CONFIG_MODULE, "db_learning.config:TestConfig")

# Fixture for creating a test module
@pytest.fixture(scope='session')
def tm():
    test_module = Test.create_test_module(modules=[ApplicationModule])
    yield test_module


# Fixture for creating a database session for testing
@pytest.fixture(scope='session')
async def db(tm):
    db_service = tm.get(EllarSQLService)

    # Applying migrations using Alembic
    async with tm.create_application().application_context():
        cli = CLICommandHandlers(db_service)
        cli.migrate()

    yield

    # Downgrading migrations after testing
    async with tm.create_application().application_context():
        cli = CLICommandHandlers(db_service)
        cli.downgrade()

# Fixture for creating an asynchronous database session for testing
@pytest.fixture(scope='session')
async def db_session(db, tm):
    db_service = tm.get(EllarSQLService)

    yield db_service.session_factory()

    # Removing the session factory
    db_service.session_factory.remove()

The CLICommandHandlers class wraps all Alembic functions executed through the Ellar command-line interface. It can be used in conjunction with the application context to initialize all model tables during testing as shown in the illustration above. db_session pytest fixture also ensures that migrations are applied and then downgraded after testing, maintaining a clean and consistent test database state.

Testing a Model

After setting up the testing database and creating a session, let's test the insertion of a user model into the database.

In db_learning/models.py, we have a user model:

db_learning/model.py
from ellar_sql import model

class User(model.Model):
    id: model.Mapped[int] = model.mapped_column(model.Integer, primary_key=True)
    username: model.Mapped[str] = model.mapped_column(model.String, unique=True, nullable=False)
    email: model.Mapped[str] = model.mapped_column(model.String)

Now, create a file named test_user_model.py:

tests/test_user_model.py
import pytest
import sqlalchemy.exc as sa_exc
from db_learning.models import User

def test_username_must_be_unique(db_session):
    # Creating and adding the first user
    user1 = User(username='ellarSQL', email='ellarsql@gmail.com')
    db_session.add(user1)
    db_session.commit()

    # Attempting to add a second user with the same username
    user2 = User(username='ellarSQL', email='ellarsql2@gmail.com')
    db_session.add(user2)

    # Expecting an IntegrityError due to unique constraint violation
    with pytest.raises(sa_exc.IntegrityError):
        db_session.commit()

In this test, we are checking whether the unique constraint on the username field is enforced by attempting to insert two users with the same username. The test expects an IntegrityError to be raised, indicating a violation of the unique constraint. This ensures that the model behaves correctly and enforces the specified uniqueness requirement.

Testing Factory Boy

factory-boy provides a convenient and flexible way to create mock objects, supporting various ORMs like Django, MongoDB, and SQLAlchemy. EllarSQL extends factory.alchemy.SQLAlchemy to offer a Model factory solution compatible with both synchronous and asynchronous database drivers.

To get started, you need to install factory-boy:

pip install factory-boy

Now, let's create a factory for our user model in tests/factories.py:

tests/factories.py
import factory
from db_learning.models import User
from ellar.app import current_injector
from sqlalchemy.orm import Session

from ellar_sql.factory import SESSION_PERSISTENCE_FLUSH, EllarSQLFactory


def _get_session():
    session = current_injector.get(Session)
    return session

class UserFactory(EllarSQLFactory):
    class Meta:
        model = User
        sqlalchemy_session_persistence = SESSION_PERSISTENCE_FLUSH
        sqlalchemy_session_factory = _get_session

    username = factory.Faker('username')
    email = factory.Faker('email')

The UserFactory depends on a database Session as you see from _get_session() function. We need to ensure that test fixture provides ApplicationContext for current_injector to work.

So in tests/conftest.py, we make tm test fixture to run application context:

tests/conftest.py
import os

import pytest
from db_learning.root_module import ApplicationModule
from ellar.common.constants import ELLAR_CONFIG_MODULE
from ellar.testing import Test
from ellar.threading.sync_worker import execute_async_context_manager

from ellar_sql import EllarSQLService

os.environ.setdefault(ELLAR_CONFIG_MODULE, "db_learning.config:TestConfig")

@pytest.fixture(scope='session')
def tm():
    test_module = Test.create_test_module(modules=[ApplicationModule])
    app = test_module.create_application()

    with execute_async_context_manager(app.application_context()):
        yield test_module

# Fixture for creating a database session for testing
@pytest.fixture(scope='session')
def db(tm):
    db_service = tm.get(EllarSQLService)

    # Creating all tables
    db_service.create_all()

    yield

    # Dropping all tables after the tests
    db_service.drop_all()

With this setup, we can rewrite our test_username_must_be_unique test using UserFactory and factory_session:

tests/test_user_model.py
import pytest
import sqlalchemy.exc as sa_exc
from .factories import UserFactory

def test_username_must_be_unique(factory_session):
    user1 = UserFactory()
    with pytest.raises(sa_exc.IntegrityError):
        UserFactory(username=user1.username)

This test yields the same result as before. Refer to the factory-boy documentation for more features and tutorials.