|
| 1 | +# **Testing EllarSQL Models** |
| 2 | +There are various approaches to testing SQLAlchemy models, but in this section, we will focus on setting |
| 3 | +up a good testing environment for EllarSQL models using the |
| 4 | +Ellar [Test](https://python-ellar.github.io/ellar/basics/testing/){target="_blank"} factory and pytest. |
1 | 5 |
|
| 6 | +For an effective testing environment, it is recommended to utilize the `EllarSQLModule.register_setup()` |
| 7 | +approach to set up the **EllarSQLModule**. This allows you to add a new configuration for `ELLAR_SQL` |
| 8 | +specific to your testing database, preventing interference with production or any other databases in use. |
2 | 9 |
|
| 10 | +### **Defining TestConfig** |
| 11 | +There are various methods for configuring test settings in Ellar, |
| 12 | +as outlined |
| 13 | +[here](https://python-ellar.github.io/ellar/basics/testing/#overriding-application-conf-during-testing){target="_blank"}. |
| 14 | +However, in this section, we will adopt the 'in a file' approach. |
3 | 15 |
|
4 |
| -## Testing Fixtures |
| 16 | +Within the `db_learning/config.py` file, include the following code: |
5 | 17 |
|
6 |
| -## Alembic Migration with Test Fixture |
| 18 | +```python title="db_learning/config.py" |
| 19 | +import typing as t |
| 20 | +... |
7 | 21 |
|
8 |
| -## Testing a model |
| 22 | +class DevelopmentConfig(BaseConfig): |
| 23 | + DEBUG: bool = True |
| 24 | + # Configuration through Config |
| 25 | + ELLAR_SQL: t.Dict[str, t.Any] = { |
| 26 | + 'databases': { |
| 27 | + 'default': 'sqlite:///project.db', |
| 28 | + }, |
| 29 | + 'echo': True, |
| 30 | + 'migration_options': { |
| 31 | + 'directory': 'migrations' |
| 32 | + }, |
| 33 | + 'models': ['models'] |
| 34 | + } |
9 | 35 |
|
10 |
| -## Factory Boy |
| 36 | +class TestConfig(BaseConfig): |
| 37 | + DEBUG = False |
| 38 | + |
| 39 | + ELLAR_SQL: t.Dict[str, t.Any] = { |
| 40 | + **DevelopmentConfig.ELLAR_SQL, |
| 41 | + 'databases': { |
| 42 | + 'default': 'sqlite:///test.db', |
| 43 | + }, |
| 44 | + 'echo': False, |
| 45 | + } |
| 46 | +``` |
| 47 | + |
| 48 | +This snippet demonstrates the 'in a file' approach to setting up the `TestConfig` class within the same `db_learning/config.py` file. |
| 49 | + |
| 50 | +#### **Changes made:** |
| 51 | +1. Updated the `databases` section to use `sqlite+aiosqlite:///test.db` for the testing database. |
| 52 | +2. Set `echo` to `True` to enable SQLAlchemy output during testing for cleaner logs. |
| 53 | +3. Preserved the `migration_options` and `models` configurations from `DevelopmentConfig`. |
| 54 | + |
| 55 | +Also, feel free to further adjust it based on your specific testing requirements! |
| 56 | + |
| 57 | +## **Test Fixtures** |
| 58 | +After defining `TestConfig`, we need to add some pytest fixtures to set up **EllarSQLModule** and another one |
| 59 | +that returns a `session` for testing purposes. Additionally, we need to export `ELLAR_CONFIG_MODULE` |
| 60 | +to point to the newly defined **TestConfig**. |
| 61 | + |
| 62 | +```python title="tests/conftest.py" |
| 63 | +import os |
| 64 | +import pytest |
| 65 | +from ellar.common.constants import ELLAR_CONFIG_MODULE |
| 66 | +from ellar.testing import Test |
| 67 | +from ellar_sql import EllarSQLService |
| 68 | +from db_learning.root_module import ApplicationModule |
| 69 | + |
| 70 | +# Setting the ELLAR_CONFIG_MODULE environment variable to TestConfig |
| 71 | +os.environ.setdefault(ELLAR_CONFIG_MODULE, "db_learning.config:TestConfig") |
| 72 | + |
| 73 | +# Fixture for creating a test module |
| 74 | +@pytest.fixture(scope='session') |
| 75 | +def tm(): |
| 76 | + test_module = Test.create_test_module(modules=[ApplicationModule]) |
| 77 | + yield test_module |
| 78 | + |
| 79 | +# Fixture for creating a database session for testing |
| 80 | +@pytest.fixture(scope='session') |
| 81 | +def db(tm): |
| 82 | + db_service = tm.get(EllarSQLService) |
| 83 | + |
| 84 | + # Creating all tables |
| 85 | + db_service.create_all() |
| 86 | + |
| 87 | + yield |
| 88 | + |
| 89 | + # Dropping all tables after the tests |
| 90 | + db_service.drop_all() |
| 91 | + |
| 92 | +# Fixture for creating a database session for testing |
| 93 | +@pytest.fixture(scope='session') |
| 94 | +def db_session(db, tm): |
| 95 | + db_service = tm.get(EllarSQLService) |
| 96 | + |
| 97 | + yield db_service.session_factory() |
| 98 | + |
| 99 | + # Removing the session factory |
| 100 | + db_service.session_factory.remove() |
| 101 | +``` |
| 102 | + |
| 103 | +The provided fixtures help in setting up a testing environment for EllarSQL models. |
| 104 | +The `Test.create_test_module` method creates a **TestModule** for initializing your Ellar application, |
| 105 | +and the `db_session` fixture initializes a database session for testing, creating and dropping tables as needed. |
| 106 | + |
| 107 | +If you are working with asynchronous database drivers, you can convert `db_session` |
| 108 | +into an async function to handle coroutines seamlessly. |
| 109 | + |
| 110 | +## **Alembic Migration with Test Fixture** |
| 111 | +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: |
| 112 | + |
| 113 | +```python title="tests/conftest.py" |
| 114 | +import os |
| 115 | +import pytest |
| 116 | +from ellar.common.constants import ELLAR_CONFIG_MODULE |
| 117 | +from ellar.testing import Test |
| 118 | +from ellar_sql import EllarSQLService |
| 119 | +from ellar_sql.cli.handlers import CLICommandHandlers |
| 120 | +from db_learning.root_module import ApplicationModule |
| 121 | + |
| 122 | +# Setting the ELLAR_CONFIG_MODULE environment variable to TestConfig |
| 123 | +os.environ.setdefault(ELLAR_CONFIG_MODULE, "db_learning.config:TestConfig") |
| 124 | + |
| 125 | +# Fixture for creating a test module |
| 126 | +@pytest.fixture(scope='session') |
| 127 | +def tm(): |
| 128 | + test_module = Test.create_test_module(modules=[ApplicationModule]) |
| 129 | + yield test_module |
| 130 | + |
| 131 | + |
| 132 | +# Fixture for creating a database session for testing |
| 133 | +@pytest.fixture(scope='session') |
| 134 | +async def db(tm): |
| 135 | + db_service = tm.get(EllarSQLService) |
| 136 | + |
| 137 | + # Applying migrations using Alembic |
| 138 | + async with tm.create_application().application_context(): |
| 139 | + cli = CLICommandHandlers(db_service) |
| 140 | + cli.migrate() |
| 141 | + |
| 142 | + yield |
| 143 | + |
| 144 | + # Downgrading migrations after testing |
| 145 | + async with tm.create_application().application_context(): |
| 146 | + cli = CLICommandHandlers(db_service) |
| 147 | + cli.downgrade() |
| 148 | + |
| 149 | +# Fixture for creating an asynchronous database session for testing |
| 150 | +@pytest.fixture(scope='session') |
| 151 | +async def db_session(db, tm): |
| 152 | + db_service = tm.get(EllarSQLService) |
| 153 | + |
| 154 | + yield db_service.session_factory() |
| 155 | + |
| 156 | + # Removing the session factory |
| 157 | + db_service.session_factory.remove() |
| 158 | +``` |
| 159 | + |
| 160 | +The `CLICommandHandlers` class wraps all `Alembic` functions executed through the Ellar command-line interface. |
| 161 | +It can be used in conjunction with the application context to initialize all model tables during testing as shown in the illustration above. |
| 162 | +`db_session` pytest fixture also ensures that migrations are applied and then downgraded after testing, |
| 163 | +maintaining a clean and consistent test database state. |
| 164 | + |
| 165 | +## **Testing a Model** |
| 166 | +After setting up the testing database and creating a session, let's test the insertion of a user model into the database. |
| 167 | + |
| 168 | +In `db_learning/models.py`, we have a user model: |
| 169 | + |
| 170 | +```python title="db_learning/model.py" |
| 171 | +from ellar_sql import model |
| 172 | + |
| 173 | +class User(model.Model): |
| 174 | + id: model.Mapped[int] = model.mapped_column(model.Integer, primary_key=True) |
| 175 | + username: model.Mapped[str] = model.mapped_column(model.String, unique=True, nullable=False) |
| 176 | + email: model.Mapped[str] = model.mapped_column(model.String) |
| 177 | +``` |
| 178 | + |
| 179 | +Now, create a file named `test_user_model.py`: |
| 180 | + |
| 181 | +```python title="tests/test_user_model.py" |
| 182 | +import pytest |
| 183 | +import sqlalchemy.exc as sa_exc |
| 184 | +from db_learning.models import User |
| 185 | + |
| 186 | +def test_username_must_be_unique(db_session): |
| 187 | + # Creating and adding the first user |
| 188 | + user1 = User(username='ellarSQL', email='ellarsql@gmail.com') |
| 189 | + db_session.add(user1) |
| 190 | + db_session.commit() |
| 191 | + |
| 192 | + # Attempting to add a second user with the same username |
| 193 | + user2 = User(username='ellarSQL', email='ellarsql2@gmail.com') |
| 194 | + db_session.add(user2) |
| 195 | + |
| 196 | + # Expecting an IntegrityError due to unique constraint violation |
| 197 | + with pytest.raises(sa_exc.IntegrityError): |
| 198 | + db_session.commit() |
| 199 | +``` |
| 200 | + |
| 201 | +In this test, we are checking whether the unique constraint on the `username` |
| 202 | +field is enforced by attempting to insert two users with the same username. |
| 203 | +The test expects an `IntegrityError` to be raised, indicating a violation of the unique constraint. |
| 204 | +This ensures that the model behaves correctly and enforces the specified uniqueness requirement. |
| 205 | + |
| 206 | +## **Testing Factory Boy** |
| 207 | +[factory-boy](https://pypi.org/project/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. |
| 208 | + |
| 209 | +To get started, you need to install `factory-boy`: |
| 210 | + |
| 211 | +```shell |
| 212 | +pip install factory-boy |
| 213 | +``` |
| 214 | + |
| 215 | +Now, let's create a factory for our user model in `tests/factories.py`: |
| 216 | + |
| 217 | +```python title="tests/factories.py" |
| 218 | +import factory |
| 219 | +from ellar_sql.factory import EllarSQLFactory, SESSION_PERSISTENCE_FLUSH |
| 220 | +from db_learning.models import User |
| 221 | +from . import common |
| 222 | + |
| 223 | +class UserFactory(EllarSQLFactory): |
| 224 | + class Meta: |
| 225 | + model = User |
| 226 | + sqlalchemy_session_persistence = SESSION_PERSISTENCE_FLUSH |
| 227 | + sqlalchemy_session_factory = lambda: common.Session() |
| 228 | + |
| 229 | + username = factory.Faker('username') |
| 230 | + email = factory.Faker('email') |
| 231 | +``` |
| 232 | + |
| 233 | +The `UserFactory` depends on a database session. Since the pytest fixture we created applies to it, |
| 234 | +we also need a session factory in `tests/common.py`: |
| 235 | + |
| 236 | +```python title="tests/common.py" |
| 237 | +from sqlalchemy import orm |
| 238 | + |
| 239 | +Session = orm.scoped_session(orm.sessionmaker()) |
| 240 | +``` |
| 241 | + |
| 242 | +Additionally, we require a fixture responsible for configuring the Factory session in `tests/conftest.py`: |
| 243 | + |
| 244 | +```python title="tests/conftest.py" |
| 245 | +import os |
| 246 | +import pytest |
| 247 | +import sqlalchemy as sa |
| 248 | +from ellar.common.constants import ELLAR_CONFIG_MODULE |
| 249 | +from ellar.testing import Test |
| 250 | +from ellar_sql import EllarSQLService |
| 251 | +from db_learning.root_module import ApplicationModule |
| 252 | +from . import common |
| 253 | + |
| 254 | +os.environ.setdefault(ELLAR_CONFIG_MODULE, "db_learning.config:TestConfig") |
| 255 | + |
| 256 | +@pytest.fixture(scope='session') |
| 257 | +def tm(): |
| 258 | + test_module = Test.create_test_module(modules=[ApplicationModule]) |
| 259 | + yield test_module |
| 260 | + |
| 261 | +# Fixture for creating a database session for testing |
| 262 | +@pytest.fixture(scope='session') |
| 263 | +def db(tm): |
| 264 | + db_service = tm.get(EllarSQLService) |
| 265 | + |
| 266 | + # Creating all tables |
| 267 | + db_service.create_all() |
| 268 | + |
| 269 | + yield |
| 270 | + |
| 271 | + # Dropping all tables after the tests |
| 272 | + db_service.drop_all() |
| 273 | + |
| 274 | +# Fixture for creating a database session for testing |
| 275 | +@pytest.fixture(scope='session') |
| 276 | +def db_session(db, tm): |
| 277 | + db_service = tm.get(EllarSQLService) |
| 278 | + |
| 279 | + yield db_service.session_factory() |
| 280 | + |
| 281 | + # Removing the session factory |
| 282 | + db_service.session_factory.remove() |
| 283 | + |
| 284 | +@pytest.fixture |
| 285 | +def factory_session(db, tm): |
| 286 | + engine = tm.get(sa.Engine) |
| 287 | + common.Session.configure(bind=engine) |
| 288 | + yield |
| 289 | + common.Session.remove() |
| 290 | +``` |
| 291 | + |
| 292 | +In the `factory_session` fixture, we retrieve the `Engine` registered in the DI container by **EllarSQLModule**. |
| 293 | +Using this engine, we configure the common `Session`. It's important to note that if you are using an |
| 294 | +async database driver, **EllarSQLModule** will register `AsyncEngine`. |
| 295 | + |
| 296 | +With this setup, we can rewrite our `test_username_must_be_unique` test using `UserFactory` and `factory_session`: |
| 297 | + |
| 298 | +```python title="tests/test_user_model.py" |
| 299 | +import pytest |
| 300 | +import sqlalchemy.exc as sa_exc |
| 301 | +from .factories import UserFactory |
| 302 | + |
| 303 | +def test_username_must_be_unique(factory_session): |
| 304 | + user1 = UserFactory() |
| 305 | + with pytest.raises(sa_exc.IntegrityError): |
| 306 | + UserFactory(username=user1.username) |
| 307 | +``` |
| 308 | + |
| 309 | +This test yields the same result as before. |
| 310 | +Refer to the [factory-boy documentation](https://factoryboy.readthedocs.io/en/stable/orms.html#sqlalchemy) |
| 311 | +for more features and tutorials. |
0 commit comments