Search code examples
djangopytestfactory-boypytest-django

Parametrize attributes of Django-models with multi-table inheritance in pytest-factoryboy


I am using Django and want to write a test using pytest, pytest-django, pytest-factoryboy and pytest-lazyfixtures.

I have Django-models that are using multi-table inheritance, like this:

class User(models.Model):
    created = models.DateTimeField()
    active = models.BooleanField()

class Editor(User):
    pass

class Admin(User):
   pass

I also created factories for all models and registered them, such as:

class UserFactory(factory.django.DjangoModelFactory):
    class Meta:
        model = User

    created = ... # some datetime
    active = factory.Faker("pybool")

class EditorFactory(UserFactory):
    class Meta:
        model = Editor

...

Now I want to test a function that can take any of User, Editor or Admin as an input and parametrize the test with all user types and variations of active and created, like this (unfortunately it doesn't work like that):


@pytest.mark.parametrize("any_user", [lazy_fixture("user"), lazy_fixture("editor"), lazy_fixture("admin")])
@pytest.mark.parametrize("any_user__active", [True, False])
def test_some_func(any_user):
   ...  # test some stuff

However that fails with In test_some_func: function uses no argument 'any_user__active'.

Any idea how to best solve this?

I could of course do sth like this, but it's not as nice:


@pytest.mark.parametrize("any_user", [lazy_fixture("user"), lazy_fixture("editor"), lazy_fixture("admin")])
@pytest.mark.parametrize("active", [True, False])
def test_some_func(any_user, active):
   any_user.active = active
   # save any_user if necessary
   ...  # test some stuff

Any better suggestions?


Solution

  • pytest-factoryboy is not as expressive as I'd wish in cases like this. It would be nice to call pytest_factoryboy.register with an alternate name for model fixtures — but unfortunately, even though register takes a _name parameter intended for this purpose, _name is ignored, and underscore(factory_class._meta.model.__name__) is used instead.

    Thankfully, we can trick this logic into using the model name we desire:

    @register
    class AnyUserFactory(UserFactory):
        class Meta:
            model = type('AnyUser', (User,), {})
    

    Essentially, we create a new subclass of User with the name AnyUser. This will cause pytest-factoryboy to create the any_user model fixture, along with any_user__active, any_user__created, etc. Now, how do we parametrize any_user to use UserFactory, EditorFactory, and AdminFactory?

    Thankfully again, model fixtures work by requesting the model_name_factory fixture with request.getfixturevalue('model_name_factory'), and not by directly referencing the @register'd factory class. The upshot is that we can simply override any_user_factory with whatever factory we wish!

    @pytest.fixture(autouse=True, params=[
        lazy_fixture('user_factory'),
        lazy_fixture('editor_factory'),
        lazy_fixture('admin_factory'),
    ])
    def any_user_factory(request):
        return request.param
    

    NOTE: pytest seems to prune the graph of available fixtures based on the test method args, as well as any args requested by fixtures. When a fixture uses request.getfixturevalue, pytest may report being unable to find the requested fixture — even if it's clearly defined — because it was pruned. We pass autouse=True to our fixture, to force pytest into including it in the dependency graph.

    Now, we can parametrize any_user__active directly on our test, and any_user will be a User, Editor, and Admin with each value of active

    @pytest.mark.parametrize('any_user__active', [True, False])
    def test_some_func(any_user):
        print(f'{type(any_user)=} {any_user.active=}')
    

    Which outputs:

    py.test test.py -sq
    
    type(any_user)=<class 'test.User'> any_user.active=True
    .type(any_user)=<class 'test.User'> any_user.active=False
    .type(any_user)=<class 'test.Editor'> any_user.active=True
    .type(any_user)=<class 'test.Editor'> any_user.active=False
    .type(any_user)=<class 'test.Admin'> any_user.active=True
    .type(any_user)=<class 'test.Admin'> any_user.active=False
    .
    6 passed in 0.04s
    

    Also, if @pytest.fixture with request.param feels a bit verbose, I might suggest using pytest-lambda (disclaimer: I am the author). Sometimes, @pytest.mark.parametrize can be limiting, or can require including extra arg names in the test method that go unused; in those cases, it can be convenient to declare new fixtures without writing the full fixture method.

    from pytest_lambda import lambda_fixture
    
    any_user_factory = lambda_fixture(autouse=True, params=[
        lazy_fixture('user_factory'),
        lazy_fixture('editor_factory'),
        lazy_fixture('admin_factory'),
    ])
    
    @pytest.mark.parametrize('any_user__active', [True, False])
    def test_some_func(any_user):
        print(f'{type(any_user)=} {any_user.active=}')
    

    If including autouse=True on any_user_factory is bothersome, because it causes all other tests to be parametrized, we have to find some other way to include any_user_factory in the pytest dependency graph.

    Unfortunately, the first approach I'd try caused errors. I tried to override the any_user fixture, requesting both the original any_user fixture, and our overridden any_user_factory, like this

    @pytest.fixture
    def any_user(any_user, any_user_factory):
        return any_user
    

    Alas, pytest didn't like that

    ___________________________ ERROR collecting test.py ___________________________
    In test_some_func: function uses no argument 'any_user__active'
    

    Fortunately, pytest-lambda provides a decorator to wrap a fixture function, so the arguments of both the decorated method and the wrapped fixture are preserved. This allows us to explicitly add any_user_factory to the dependency graph

    from pytest_lambda import wrap_fixture
    
    @pytest.fixture(params=[  # NOTE: no autouse
        lazy_fixture('user_factory'),
        lazy_fixture('editor_factory'),
        lazy_fixture('admin_factory'),
    ])
    def any_user_factory(request):
        return request.param
    
    @pytest.fixture
    @wrap_fixture(any_user)
    def any_user(any_user_factory, wrapped):
        return wrapped()  # calls the original any_user() fixture method
    

    NOTE: @wrap_fixture(any_user) directly references the any_user fixture method defined by pytest_factoryboy when calling @register. It'll appear as an unresolved reference in most static code checkers / IDEs; but as long as it appears after class AnyUserFactory and in the same module, it will work.

    Now, only tests which request any_user will hit any_user_factory and receive its parametrization.

    @pytest.mark.parametrize('any_user__active', [True, False])
    def test_some_func( any_user):
        print(f'{type(any_user)=} {any_user.active=}')
    
    def test_some_other_func():
        print('some_other_func')
    

    Output:

    py.test test.py -sq
    
    type(any_user)=<class 'test.User'> any_user.active=True
    .type(any_user)=<class 'test.User'> any_user.active=False
    .type(any_user)=<class 'test.Editor'> any_user.active=True
    .type(any_user)=<class 'test.Editor'> any_user.active=False
    .type(any_user)=<class 'test.Admin'> any_user.active=True
    .type(any_user)=<class 'test.Admin'> any_user.active=False
    .some_other_func
    .
    7 passed in 0.06 seconds