Python Dataclasses: Mocking the default factory in a frozen Dataclass
Asked Answered
K

1

8

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.

Kerwon answered 16/4, 2020 at 18:41 Comment(0)
S
11

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.

Swellhead answered 17/4, 2020 at 21:31 Comment(2)
Thank you. The lambda seems so obvious in retrospect, I can't believe I missed that option. Particularly as I've been using FactoryBoy which utilises lambdas extensively for very similar reasons. I will look into the pendulum library, as python's datetime library isn't great. And the lambda solution is a great option when needing to mock other types of default_factory.Kerwon
Glad I could help =)Swellhead

© 2022 - 2024 — McMap. All rights reserved.