Search code examples
pythonunit-testingpatchpython-dataclassesfreezegun

Python Dataclasses: Mocking the default factory in a frozen Dataclass


I'm attempting to use freezegun in my unit tests to patch a field in a dataclass that is set to the current date when the object is initialised. I would imagine the question is relevant to any attempt to patch a function being used as a default_factory outside of just freezegun. The dataclass is frozen so its immutable.

For example if my dataclass is:

@dataclass(frozen=True)
class MyClass:
    name: str
    timestamp: datetime.datetime = field(init=False, default_factory=datetime.datetime.now)

When I patch datetime with freezegun, it has no impact on the initialisation of the timestamp in MyClass (it still sets timestamp to the current date returned by now() in the unit test, causing the test to fail).

I'm assuming it has to do with the default factory and module being loaded well before the patch is in place. I have tried patching datetime, and then reloading the module with importlib.reload but with no luck.

The solution I have at the moment is:

@dataclass(frozen=True)
class MyClass:
    name: str
    timestamp: datetime.datetime = field(init=False)

def __post_init__(self):
   object.__setattr__(self, "timestamp", datetime.datetime.now())

which works.

Ideally though, I would like a non-invasive solution that doesn't require me changing my production code to enable my unit tests.


Solution

  • You're right, the dataclass creation process does something strange here which leads to your current problem. It binds the factory function during class creation, which means that it holds a reference of the code before freezegun had a chance to patch it.

    Here is an example without dataclasses that runs into the same issue:

    from datetime import datetime
    from freezegun import freeze_time
    
    class Foo:
      # looks up the function at class creation time
      now_func = datetime.now
    
      def __init__(self):
        # asks datetime for a reference at instance creation time
        self.timestamp_a = datetime.now()
        # uses an old reference we couldn't patch
        self.timestamp_b = Foo.now_func()
    
    
    with freeze_time(datetime(2020, 1, 1)):
      foo = Foo()
      assert foo.timestamp_a == datetime(2020, 1, 1)  # works
      assert foo.timestamp_b == datetime(2020, 1, 1)  # raises an AssertionError
    

    As to how to solve the problem, you can theoretically hack MyClass.__init__.__closure__ during your tests to switch out the functions, but that's a bit mad.

    Something that is still a bit better than overwriting timestamp in a __post_init__ might be to just delegate the function call with a lambda so that the name lookup is delayed to instantiation time:

    timestamp: datetime = field(init=False, default_factory=lambda: datetime.now())
    

    Or you can start using a different datetime library like pendulum that supports freezing time out of the box. FWIW, this is what I ended up doing.