pytest-factoryboy
pytest-factoryboy copied to clipboard
Support LazyFixtures as declarations in factory class fixtures
Brief
This PR adds support for using LazyFixture
as a declaration directly in the factory, when using the factory class fixture. This makes the following possible:
import factory
import pytest
from pytest_factoryboy import LazyFixture, register
class MyDictFactory(factory.DictFactory):
apples = LazyFixture("pears")
register(MyDictFactory)
@pytest.fixture()
def pears():
return "pears"
def test_my_apples(my_dict_factory):
my_dict = my_dict_factory()
assert my_dict == {"apples": "pears"}
Description
Under the hood, factory class fixtures now return a subclass of the factory class (instead of the exact factory class that was registered). This subclass adds the pytest request
fixture as a Params declaration — essentially doing this:
class WrappedMyDictFactory(MyDictFactory):
class Params:
pytest_request = actual_pytest_request
LazyFixture
now inherits from factory.declarations.BaseDeclaration
. Its evaluate
method has been overloaded to support both direct invocation from pytest-factoryboy when evaluating a model fixture, and invocation by factory_boy during factory class building.
When LazyFixture.evaluate
is invoked for a model fixture, the pytest request is passed in directly; however, when invoked by factory_boy, the pytest_request
is pulled from the factory's Params.
That's nice, but wouldn't it be more appropriate to add it to FactoryBoy directly? And then add a hook/mod to use it in pytest_factoryboy?
I'm not sure I'm following. What do you mean by "it"?
I was thinking about the full rewritten LazyFixture, but at a second thought, it's true that the whole "fixture" thingy is pretty pytest-correlated... Maybe just forget what I've said :-p
Forget what? I didn't hear you say anything
;)
Hi! I've been smacking my head against this particular problem recently, and your PR solves it!
This is great, but I'm not too keen on using an unmerged branch for our dependencies.
Do you know of a suitable workaround for the current version pytest-factoryboy to achieve this behaviour?
Thanks! Very excited to see this merged. :)
Heh, well, before opening this PR, we were using our own custom @register
decorator to wrap pytest_factoryboy.register
, then overwrite the factory fixture using our own factory_fixture
implementation (which basically looks exactly the same as factory_fixture
in this PR — though it also adds support for having a Factory._pytest_initialize(request)
method, in case the class wanted to initialize itself with fixture values).
However, this gets a bit hairy, because the way pytest_factoryboy.register
"creates" its fixtures is by doing setattr(module, fixture_name, fixture_func)
... and the way it gets the module
is by peering into the caller's stack frame — so if you wrap pytest_factoryboy.register
, you also have to temporarily monkeypatch get_caller_module
, otherwise all model and factory fixtures will be defined in the module your decorator is defined.
But if you don't mind substituting an unmerged branch for a total hack, here's basically our code, which we threw in tests/util/decorators.py
(or something), and switched calls to pytest_factoryboy.register
with our register_factory
Hacky patch
import inspect
from copy import copy
from functools import partial
from unittest.mock import Mock
import _pytest.monkeypatch
import factory
import pytest_factoryboy
from pytest_factoryboy.fixture import get_caller_module, get_factory_name, make_fixture
class MonkeyPatch(_pytest.monkeypatch.MonkeyPatch):
"""pytest's MonkeyPatch class, which allows usage as a context manager
Supports usage in a "with" statement, so undo() needn't be manually called
Usage:
with MonkeyPatch() as mp:
mp.setattr('module.attr', 123)
"""
def __enter__(self):
return self
def __exit__(self, exc_type, exc_val, exc_tb):
self.undo()
def register_factory(factory_class=None, _module=None, **kwargs):
"""Register a factory, allowing kwargs to be passed to register()
Usage:
@register_factory(account=LazyFixture('account'))
class VendorFactory(factory.django.DjangoModelFactory):
class Meta:
model = Vendor
@register_factory
class UserFactory(factory.django.DjangoModelFactory):
class Meta:
model = User
"""
if factory_class is None:
_module = get_caller_module()
return partial(register_factory, _module=_module, **kwargs)
else:
# XXX: haaaaaaaaaack.
# The pytest-factoryboy plugin inspects the stack frame to determine
# the module to place its "model_name" and "model_name_factory"
# fixtures. Since we add a layer of indirection, we must determine the
# module ourselves. Unfortunately, pytest-factoryboy doesn't let us
# configure the module, so we patch get_caller_module to return the
# correct module (and return it to normal afterward).
if not _module:
_module = get_caller_module()
with MonkeyPatch() as mp:
mp.setattr('pytest_factoryboy.fixture.get_caller_module', Mock(return_value=_module))
factory_class = pytest_factoryboy.register(factory_class, **kwargs)
# We override the factory fixture, so it supports LazyFixtures
factory_name = get_factory_name(factory_class)
extra_fixtures = getattr(factory_class, '_pytest_fixtures', ())
make_fixture(
factory_name,
_module,
_factory_class_lazy_fixture_evaluator,
args=extra_fixtures,
factory_class=factory_class,
)
return factory_class
def _factory_class_lazy_fixture_evaluator(request, factory_class, *args, **kwargs):
"""
This will be called when requesting the <model>_factory fixture. It turns
LazyFixtures (which are unsupported by pytest-factoryboy inside the
<model>_factory fixture) into LazyFunctions calling LazyFixture.evaluate
with the pytest request.
This allows the following:
@register_factory
class ModelFactory(factory.django.DjangoModelFactory):
class Meta:
model = Model
account = LazyFixture('account')
"""
factory_class = wrap_factory_class(request, factory_class)
if hasattr(factory_class, '_pytest_initialize'):
factory_class._pytest_initialize(request)
return factory_class
def wrap_factory_class(request, factory_class):
decls = inspect.getmembers(factory_class) # XXX: is this too broad?
attrs = dict(vars(factory_class))
for name, decl in decls:
wrapped_decl = wrap_lazy_decl(request, name, decl)
if wrapped_decl is not decl:
attrs[name] = wrapped_decl
Params = attrs.get('Params')
params_bases = (Params,) if Params else ()
# Expose pytest request to simplify LazyFixture resolution
class Params(*params_bases):
pytest_request = request
attrs['Params'] = Params
return type(factory_class.__name__, (factory_class,), attrs)
def wrap_lazy_decl(request, name, decl):
if isinstance(decl, (factory.RelatedFactory, factory.SubFactory)):
# Ensure we pass the pytest request along to any sub factories
decl = copy(decl)
make_get_factory = lambda get_factory: lambda: _factory_class_lazy_fixture_evaluator(
request, get_factory()
)
decl.get_factory = make_get_factory(decl.get_factory)
elif isinstance(decl, factory.Maybe):
decl = copy(decl)
decl.yes = wrap_lazy_decl(request, name, decl.yes)
decl.no = wrap_lazy_decl(request, name, decl.no)
return decl
Uh, but even with that, you'd find that with the default LazyFixture
, you run into ordering problems... FactoryBoy's declarations all maintain an internal creation counter, so the evaluation order of fields — barring clear dependency graph stuff — is dependent on the order the declarations are listed in the class. Since LazyFixture
, by default, doesn't inherit from BaseDeclaration
, this ordering can get all fouled up, leading to strange and confusing errors and lost hair (trust my bald spot).
To fix that, we also monkeypatched LazyFixture
to inject the ordering. However, if you control all usages of LazyFixture
, you could just use your own class.
Though, I guess you'd have to override LazyFixture
in some sense, because it needs to read from the pytest_request
field with the above code.
Anyway, this is what we were using, which was simply thrown into the root conftest.py
:
patch_lazy_fixture
def _patch_lazy_fixture():
"""
pytest-factoryboy's LazyFixture does not inherit from factory's
BaseDeclaration, and thus does not record its order relative to other
declarations. To avoid unexpected behaviour in resolution (e.g. a
LazyFixture being resolved after other declarations, despite being declared
before them), we inject BaseDeclaration into LazyFixture's base classes.
We also augment LazyFixture evaluation to support usage from both model
attribute fixtures (e.g. `team__name`) and manual factory class
instantiation.
"""
import pytest_factoryboy
from factory.declarations import BaseDeclaration
class OrderedLazyFixture(pytest_factoryboy.LazyFixture, BaseDeclaration):
# Compatibility
_defaults = []
real_evaluate = pytest_factoryboy.LazyFixture.evaluate
def evaluate(self, instance, step=None, extra=None):
"""
We overload this method, so it can be called by pytest_factoryboy's
model fixtures (e.g. `team` of `TeamFactory`) as well as in factory
class fixtures (e.g. `team_factory`).
The model fixtures grab their declarations' values from individual
fixtures (e.g. `team__name`). To support LazyFixtures in these decl
fixtures, pytest_factoryboy evaluates them itself.
pytest_factoryboy does not support using LazyFixtures with factory
class fixtures out of the box. We support this ourselves by
injecting `pytest_request` as a Parameter, which we can reference
as a dependency when the factory class evaluates our LazyFixture
by passing a Resolver as instance (as well as step and extra).
"""
if step is None and extra is None:
request = instance
else:
# pytest_request is added as a Parameter
# by our _factory_class_lazy_fixture_evaluator
request = instance.pytest_request
return self.real_evaluate(request)
pytest_factoryboy._LazyFixture = pytest_factoryboy.LazyFixture
pytest_factoryboy.LazyFixture = OrderedLazyFixture
_patch_lazy_fixture()
Thank you very much for the detailed reply! Holy wow, that is hacky. Amazing work. Not convinced that I'm going to adopt it, I think I'll stick with workarounds, but I will give it some thought.
The factory class fixture is only there to return the factory class, no modification should be applied.
In the latest version of pytest-factoryboy we support LazyFixture already as a declaration (although it's usually discouraged), but that will only work for model fixture.
The factory itself should not be able to evaluate LazyFixture, since it requires a pytest context.
What's the specific use case you are trying to solve here? Maybe there is a better way.
When testing list API endpoints, a bunch of model instances need to be created. Very often, those instances need related models. RelatedFactory
and SubFactory
are great if every created instance can deal with having different related instances. If they all need shared instances, passing a declaration override to create_batch()
is usually the perfect play. However, there are times where some related object must be shared across all created models, but because we're good testers, this "singleton" instance is created dynamically for each test (or test session).
Take, for instance, the concept of Django Sites, which allows for multiple domains/subdomains to be run from a single Django server. When testing, we'll probably only care about a single site. So we have a site
fixture, and we want all our models to be related to it. This works perfectly when testing retrieve/update/delete endpoints, because we can use model fixtures... but when we have to test the list endpoint, we need multiple models — so now the same convenience we were afforded with model fixtures (which does alter the factory class) sharply drops off whenever we need two instances.
Honestly, once models reach a certain level of complexity, model fixtures become a tangled web at best, and dependency hell at worst. Though one can register the same factory twice to get two different model fixtures, they will each request the same fixtures for their related factories — and though this can be dealt with by supplying overrides, this quickly gets out of hand when those related factories have related factories of their own.
We very quickly ditched model factories and only used the factory class fixtures. With LazyFixture
support in the factory class fixtures, we never had to be concerned with needing 1 or 100 instances. Testing should be frictionless, so devs are more likely to write tests. The more friction, the more knowledge is needed to do things the "better way", the more likely a human is to just forego it altogether.
Is this PR still relevant? pytest-factoryboy supports LazyFixture in the factory definition since https://github.com/pytest-dev/pytest-factoryboy/pull/161 (version 2.4.0)