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?

Answers

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


Posted on by theY4Kman