jeancochrane / pytest-flask-sqlalchemy

A pytest plugin for preserving test isolation in Flask-SQLAlchemy using database transactions.
MIT License
255 stars 45 forks source link

Is it possible to use this with FactoryBoy? #12

Open philgyford opened 5 years ago

philgyford commented 5 years ago

It would be useful to see an example of how to use pytest-flask-sqlalchemy with FactoryBoy/pytest-factoryboy, if they can work together. For example, I'm not clear what would be used for factory classes' sqlalchemy_session field.

(Background: I use something like pytest-flask-sqlalchemy in my tests, and would like to switch to pytest-flask-sqlalchemy when the MySQL PR is included, and would also like to use FactoryBoy, but have never managed to get the two things to work well together.)

jeancochrane commented 5 years ago

What sorts of problems have you run into so far? I haven't used FactoryBoy before, but from glancing at the pytest-factoryboy docs I'd guess that you should be able to use both plugins at the same time. I wonder if the problems are FactoryBoy-related or MySQL-related.

If you have some non-MySQL tests that are failing, point them my way and I'll see if I can figure out what's up. Otherwise, I'll check on the progress in https://github.com/jeancochrane/pytest-flask-sqlalchemy/pull/10 and see if I can get it moving again.

philgyford commented 5 years ago

Good point about MySQL... I'll try and find time to knock together a quick project using Postgres and see if I encounter the same issues.

This is a summary of an issue I was having with some similar code, and I believe it was the same thing when I switched to trying pytest-flask-sqlalchemy: https://stackoverflow.com/questions/54773484/saving-factoryboy-instances-to-database-with-flask-pytest-and-sqlalchemy But I should verify this again, and rule out MySQL difficulties!

jeancochrane commented 5 years ago

Thanks for providing some sample code! I still don't have a full grasp on the integration between pytest and FactoryBoy, but this snippet in the SO link you shared looks intriguing:

from factory import Sequence
from factory.alchemy import SQLAlchemyModelFactory
from myapp.models import Company
from myapp.models.shared import db

class CompanyFactory(SQLAlchemyModelFactory):
    name = Sequence(lambda n: "Company %d" % n)

    class Meta:
        model = Company
        sqlalchemy_session = db.session

To attempt an integration with pytest-flask-sqlalchemy, the first thing I would try here would be to use the configuration directives in pytest-flask-sqlalchemy to mock out the myapp.models.shared.db.session object that's being assigned to the sqlalchemy_session above. You should be able to do that using the mocked-sessions configuration directive in a your pytest config file, e.g.:

# setup.cfg
[tool:pytest]
mocked-sessions=myapp.models.shared.db.session

That should instruct pytest-flask-sqlalchemy to replace all imports of myapp.models.shared.db.session with a transactional db_session object in application code whenever you require db_session as a fixture in a test.

Let me know if that works! If not, the simplified Postgres example will be helpful for reproducing the problem and helping me hack around on it.

philgyford commented 5 years ago

Thanks. I have now got pytest-flask-sqlalchemy working with my app that uses MySQL, although I'm not doing anything particularly advanced, database-wise. The mocked-sessions pointer was crucial, thank you!

I tried again to get FactoryBoy working, but no joy. It seems it wasn't rolling the database back between tests -- I'd get errors about duplicates for model fields that should be unique from subsequent tests. I'll have to try putting together a minimal example, combining pytest-flask-sqlalchemy and FactoryBoy when I get a chance.

killthekitten commented 5 years ago

A reproducible example of your setup would help. We use FactoryBoy with pytest-flask-sqlalchemy + postgres without any troubles.

layoaster commented 5 years ago

I'm not using this library either but I implemented the same pattern myself and so far the only way I got it working was by defining the factory as a pytest fixture too.

So I got my session fixture defined, similar to the db_session:

@pytest.fixture(scope="function")
def session(db):
    ....
    yield session
    ....

My user factory:

@pytest.fixture
def user_factory(session):
    """Fixture to inject the session dependency."""

    class UserFactory(factory.alchemy.SQLAlchemyModelFactory):
        """User model factory."""

        class Meta:
            model = User
            exclude = ("id", "created_at")
            sqlalchemy_session = session
            sqlalchemy_session_persistence = "commit"

        id = factory.Sequence(lambda n: n)
        first_name = factory.Faker("first_name")
        last_name = factory.Faker("last_name")
        email = factory.Faker("email")
        password = factory.Faker("password", length=10)

    return UserFactory

So in a test function you can use it like this:

def test_duplicate_email(session, user_factory):
    """Test persisting two users with same email."""
    user_factory.create(email="duplicate@example.com")

    session.begin_nested()
    with pytest.raises(IntegrityError):
        user_factory.create(email="duplicate@example.com")
    session.rollback()

    assert session.query(User).count() == 1

I've tried to make it work with the pytest-factoryboy plugin but I simply couldn't. They kind of refuse to support it: issue, issue

citizen-stig commented 5 years ago

@philgyford @killthekitten

I have the same issue, I investigated a little bit, and I guess the issue is that mocked-sessions patches session after it is bind to Factory, so in this example

from factory import Sequence
from factory.alchemy import SQLAlchemyModelFactory
from myapp.models import Company
from myapp.models.shared import db

class CompanyFactory(SQLAlchemyModelFactory):
    name = Sequence(lambda n: "Company %d" % n)

    class Meta:
        model = Company
        sqlalchemy_session = db.session      # this holds original unmocked session

CompanyFactory._meta.sqalchemy_session holds reference to origin db.session. I can only assume, that it happens, because db.session has been patched after initialization of factory.

So @layoaster solution is only one, that suppose to work, but didn't work for me, I'm getting:

sqlalchemy.orm.exc.FlushError: Instance <Company at 0x10ddf8080> has a NULL identity key.
If this is an auto-generated value, check that the database table allows generation of new primary key values, and that the mapped Column object is configured to expect these generated values.
Ensure also that this flush() is not occurring at an inappropriate time, such as within a load() event.
killthekitten commented 5 years ago

Right, I use vanilla FactoryBoy, without the pytest- prefix 👍 it might be a solution as well, but you'll obviously loose some handy helpers.

layoaster commented 5 years ago

I also forgot to mention @citizen-stig that my sample code assumed to be using the vanilla FactoryBoy.

Anyways, at first sight, the exception doesn't seem to be related to the session object initialization but rather to the DB model object init.

philgyford commented 5 years ago

Thanks for all this.. @layoaster's solution seems to work well!

The only difficulty I've found so far is how to use SubFactorys. I tried doing this (see penultimate line):

import factory

@pytest.fixture
def author_factory(db_session):

    class AuthorFactory(factory.alchemy.SQLAlchemyModelFactory):

        class Meta:
            model = Author
            sqlalchemy_session = db_session
            sqlalchemy_session_persistence = "commit"

        name = factory.Faker("name")

    return AuthorFactory

@pytest.fixture
def book_factory(db_session):

    class BookFactory(factory.alchemy.SQLAlchemyModelFactory):

        class Meta:
            model = Book
            sqlalchemy_session = db_session
            sqlalchemy_session_persistence = "commit"

        title = factory.Faker("sentence", nb_words=4)
        author = factory.SubFactory(author_factory(db_session))

    return BookFactory

But calling fixtures directly isn't allowed and gets this error:

Fixture "author_factory" called directly. Fixtures are not meant to be called directly, but are created automatically when test functions request them as parameters.

Maybe there isn't a way with this setup.

layoaster commented 5 years ago

@philgyford when a fixture needs anothers fixture you just pass them via paremeters so your fixture book_factory should look like this:

@pytest.fixture
def book_factory(db_session, author_factory):

    class BookFactory(factory.alchemy.SQLAlchemyModelFactory):

        class Meta:
            model = Book
            sqlalchemy_session = db_session
            sqlalchemy_session_persistence = "commit"

        title = factory.Faker("sentence", nb_words=4)
        author = factory.SubFactory(author_factory)

    return BookFactory
philgyford commented 5 years ago

@layoaster Thank you! I swear, one day my brain will find pytest simple...

revmischa commented 4 years ago

I'm doing something like this:

class BaseFactory(factory.alchemy.SQLAlchemyModelFactory):
    class Meta:
        sqlalchemy_session = scoped_session(
            lambda: current_app.extensions["sqlalchemy"].db.session
        )

Which works... almost... The first time you create() a factory object it works great. The next time you do it in the next test, you get a transient object. I think the FactoryBoy session is not getting refreshed for each test. Not sure how to address this.

If I try setting: sqlalchemy_session_persistence = "commit" I get the error:

../../../../.virtualenvs/hr-backend-PnsIdmCs/lib/python3.7/site-packages/sqlalchemy/engine/base.py:382: in connection
    return self._revalidate_connection()
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <sqlalchemy.engine.base.Connection object at 0x10e3c3828>

    def _revalidate_connection(self):
        if self.__branch_from:
            return self.__branch_from._revalidate_connection()
        if self.__can_reconnect and self.__invalid:
            if self.__transaction is not None:
                raise exc.InvalidRequestError(
                    "Can't reconnect until invalid "
                    "transaction is rolled back"
                )
            self.__connection = self.engine.raw_connection(_connection=self)
            self.__invalid = False
            return self.__connection
>       raise exc.ResourceClosedError("This Connection is closed")
E       sqlalchemy.exc.ResourceClosedError: This Connection is closed
revmischa commented 4 years ago

Check out https://github.com/pytest-dev/pytest-factoryboy/issues/11#issuecomment-569696976

seandstewart commented 4 years ago

Hello - I'm attempting to use this library with FactoryBoy, and afaict, I've set everything up correctly, but I'm still seeing commits persist beyond each test lifecycle. This is what I've got: pytest.ini

[pytest]
mocked-sessions=storage.connection.db.session

conftest.py

import logging
import os
import pathlib
from typing import TYPE_CHECKING, Type

import pytest

from app import create_app, Config
from .compat import *  # noqa: F403,F401

if TYPE_CHECKING:
    from models.users import User  # noqa: F401
    from pytests import factories

logging.captureWarnings(True)
logging.getLogger("faker").setLevel(logging.ERROR)

CUR_DIR = pathlib.Path(__file__).parent.resolve()
AUDIT = CUR_DIR / "audit.db"
DEFAULT = CUR_DIR / "default.db"

os.environ["DISABLE_TRACING"] = "0"
os.environ["DISABLE_JSON_LOGGING"] = "1"

@pytest.fixture(scope="session")
def database(request):
    if AUDIT.exists():
        AUDIT.unlink()
    AUDIT.touch()
    if DEFAULT.exists():
        DEFAULT.unlink()
    DEFAULT.touch()

@pytest.fixture(scope="session")
def app(database, request):

    Config.SQLALCHEMY_BINDS.update(
        default=f"sqlite:///{str(DEFAULT)}",
        replica1=f"sqlite:///{str(DEFAULT)}",
        audit=f"sqlite:///{str(AUDIT)}",
    )
    Config.SQLALCHEMY_DATABASE_URI = Config.SQLALCHEMY_BINDS["default"]

    app = create_app()
    app.testing = True
    app.env = "testing"
    with app.app_context():
        yield app

@pytest.fixture(scope="session")
def client(app):

    with app.test_client() as client:
        yield client

@pytest.fixture(scope="session")
def _db(app):

    from storage.connection import db

    try:
        db.create_all(bind=["default", "audit"])
    except Exception:
        db.session.rollback()
        db.create_all(bind=["default", "audit"])
    db.session.commit()

    yield db

# Automatically enable transactions for all tests, without importing any extra fixtures.
@pytest.fixture(autouse=True)
def enable_transactional_tests(db_session):
    pass

@pytest.fixture(scope="function")
def factories(db_session):
    from pytests import factories

    return factories

@pytest.fixture
def default_user(factories) -> "User":
    return factories.DefaultUserFactory.create()

factories.py

import factory
from factory.alchemy import SQLAlchemyModelFactory
from flask import current_app
from flask_sqlalchemy import get_state
from sqlalchemy.orm import scoped_session

from models.users import User

Session = scoped_session(
    lambda: get_state(current_app).db.session,
    scopefunc=lambda: get_state(current_app).db.session,
)

class DefaultUserFactory(SQLAlchemyModelFactory):
    class Meta:
        model = User
        sqlalchemy_session = Session
        sqlalchemy_session_persistence = "commit"

    email = factory.Faker("email")
    password = factory.Faker("password")
    first_name = factory.Faker("first_name")
    last_name = factory.Faker("last_name")

test_factories.py

import pytest

from models.users import User
from storage.connection import db

def test_session(db_session):
    assert db.session is db_session
    assert User.query.session is db_session()

@pytest.mark.parametrize(argnames="n", argvalues=range(10))
def test_default_user_factory(default_user: User, n):
    assert default_user.id
    assert User.query.count() == 1, User.query.all()

Now - test_session() always passes, so I know that the patching is working, however only the first iteration of test_default_user_factory passes, after which every previous iteration's user is persisted to the database. I'm also using a lazy getter for the factory Session object, and only importing the factories module within the scope of the db_session in the fixtures.

I can't think of another reason why this would be failing, but I know it's likely something I'm doing. Any thoughts on how I might rectify this?

barraponto commented 4 years ago

@seandstewart did you find a solution? I see it as a consequence of #23 and I kind of embraced it: my factories just start counting at 1 and automatically increases (id = factory.Sequence). See https://github.com/DevStonks/selfsolver-backend/blob/c8dbdf89169d89a589f4dd2222cb9b8050926e86/tests/factories.py for more details.

x-7 commented 3 years ago

Check out pytest-dev/pytest-factoryboy#11 (comment)

this worked for me, thanks