pytest-factoryboy icon indicating copy to clipboard operation
pytest-factoryboy copied to clipboard

Support LazyFixtures as declarations in factory class fixtures

Open theY4Kman opened this issue 3 years ago • 9 comments

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.

theY4Kman avatar May 26 '21 07:05 theY4Kman

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?

MRigal avatar Jun 23 '21 16:06 MRigal

I'm not sure I'm following. What do you mean by "it"?

theY4Kman avatar Jun 23 '21 18:06 theY4Kman

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

MRigal avatar Jun 24 '21 07:06 MRigal

Forget what? I didn't hear you say anything

;)

theY4Kman avatar Jun 24 '21 20:06 theY4Kman

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. :)

Fiona avatar Jul 28 '21 19:07 Fiona

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()

theY4Kman avatar Jul 28 '21 22:07 theY4Kman

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.

Fiona avatar Jul 29 '21 14:07 Fiona

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.

youtux avatar Jun 11 '22 09:06 youtux

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.

theY4Kman avatar Jun 14 '22 03:06 theY4Kman

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)

youtux avatar Jul 23 '23 20:07 youtux