factory_boy icon indicating copy to clipboard operation
factory_boy copied to clipboard

`_original_params` do not get cleared out when the factory is used as a `SubFactory`

Open mrwbarg opened this issue 4 months ago • 1 comments

Description

Using a factory to create an object (using .create()) will set args passed in onto the _original_params class attribute. If a second factory uses that same factory as a SubFactory in another test case, the _original_params will still be set to the values from the first test case because the SubFactory call does not reset the _original_params attribute.

To Reproduce

There's a working example below.

Model / Factory code

Check the code below for the factories and models.

The issue

This causes an issue when overriding the _create method in the factory to grab the _original_params since those will not be valid/stale (especially because the SubFactory passes no params). When one of the _original_params is a model instance, on the subfactory call (in the separate test case) and is used on the _create override to set something in the object an integrity error will be raised since the object referenced in the _original_params no longer exists in the database (it was deleted after the first test was done).

  from datetime import timedelta
  from unittest import skip

  import factory
  import pytz
  from django.test import TestCase
  from factory.django import DjangoModelFactory

  from appointments.models import Appointment, AppointmentAttendance, AppointmentType
  from people.models import Person
  from utils.testing_utils import CustomSubFactory


  class PersonFactory(DjangoModelFactory):
      class Meta:
          model = Person


  class AppointmentTypeFactory(DjangoModelFactory):
      name = factory.Faker("company")
      custom_id = factory.Faker("numerify", text="########")

      class Meta:
          model = AppointmentType


  class AppointmentFactory(DjangoModelFactory):
      appointment_type = factory.SubFactory(AppointmentTypeFactory)
      start_at = factory.Faker("future_datetime", tzinfo=pytz.timezone("UTC"))
      end_at = factory.LazyAttribute(lambda d: d.start_at + timedelta(hours=1))

      @classmethod
      def _create(cls, model_class, *args, **kwargs):
          # NOTE:
          # Without the CustomSubFactory this params variable is still reflecting
          # values from the first use of the AppointmentFactory class. Notice that the
          # values are bound to the class and not to the instance.

          params = getattr(cls, "_original_params", {}) or {}
          kwargs["appointment_type"] = cls._get_appointment_type(params)
          return super()._create(model_class, *args, **kwargs)

      @classmethod
      def _get_appointment_type(cls, params):
          explicit = params.get("appointment_type", None)
          if isinstance(explicit, AppointmentType):
              return explicit
          return AppointmentTypeFactory.create(name="Foo", custom_id="123456")

      class Meta:
          model = Appointment


  class AppointmentAttendanceFactory(DjangoModelFactory):
      appointment = factory.SubFactory(AppointmentFactory)
      member = factory.SubFactory(PersonFactory)

      class Meta:
          model = AppointmentAttendance


  class BetterAppointmentAttendanceFactory(DjangoModelFactory):
      appointment = CustomSubFactory(AppointmentFactory)
      member = factory.SubFactory(PersonFactory)

      class Meta:
          model = AppointmentAttendance


  # -------------------------------------------------------------------------------------


  class TestA(TestCase):
      def test_this_will_work_but_must_come_first(self):
          type_ = AppointmentTypeFactory.create(name="Bar")
          # This usage is setting the original params in the AppointmentFactory class.
          # Those values get stuck there for the second use of this class.
          appointment = AppointmentFactory.create(appointment_type=type_)
          assert appointment.appointment_type.name == "Bar"


  class TestB(TestCase):
      @skip("This will fail because it's not using the CustomSubFactory")
      def test_this_will_fail_if_second(self):
          attendance = AppointmentAttendanceFactory.create()
          # This errors with an InegrityError because the AppointmentFactory is trying to
          # use an AppointmentType that was crafted in the previous test. (note that we
          # can't assert the error because it occurs during the tear down.)
          assert isinstance(attendance.appointment.appointment_type.name, str)

      def test_this_will_work_if_second(self):
          # The CustomSubFactory will reset the _original_params attribute when using the
          # SubFactory. This doens't happen with the default SubFactory.
          attendance = BetterAppointmentAttendanceFactory.create()
          assert isinstance(attendance.appointment.appointment_type.name, str)

Notes

To fix this, we created the following class:

    import factory


    class CustomSubFactory(factory.SubFactory):
        def evaluate(self, *args, **kwargs):
            subfactory = self.get_factory()
            subfactory._original_params = None
            return super().evaluate(*args, **kwargs)

This resets the _original_params when the factory is called through as a SubFactory Maybe another way to see it is as a DjangoModelSubFactory.

mrwbarg avatar Oct 04 '24 14:10 mrwbarg