Skip to content

Latest commit

 

History

History
311 lines (238 loc) · 10.9 KB

File metadata and controls

311 lines (238 loc) · 10.9 KB

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{target="_blank"} 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{target="_blank"}. However, in this section, we will adopt the 'in a file' approach.

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

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.

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:

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:

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:

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{target="_blank"} 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:

import factory
from ellar_sql.factory import EllarSQLFactory, SESSION_PERSISTENCE_FLUSH
from db_learning.models import User
from . import common

class UserFactory(EllarSQLFactory):
    class Meta:
        model = User
        sqlalchemy_session_persistence = SESSION_PERSISTENCE_FLUSH
        sqlalchemy_session_factory = lambda: common.Session()

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

The UserFactory depends on a database session. Since the pytest fixture we created applies to it, we also need a session factory in tests/common.py:

from sqlalchemy import orm

Session = orm.scoped_session(orm.sessionmaker())

Additionally, we require a fixture responsible for configuring the Factory session in tests/conftest.py:

import os
import pytest
import sqlalchemy as sa
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
from . import common

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

@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()

@pytest.fixture
def factory_session(db, tm):
    engine = tm.get(sa.Engine)
    common.Session.configure(bind=engine)
    yield 
    common.Session.remove()

In the factory_session fixture, we retrieve the Engine registered in the DI container by EllarSQLModule. Using this engine, we configure the common Session. It's important to note that if you are using an async database driver, EllarSQLModule will register AsyncEngine.

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

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.