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?
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