Better date mocking in Python tests with FreezeGun

9 minute read

A couple of years ago I stumbled across the FreezeGun library. It’s been a great help in making the date/time-related intent in my Python tests much clearer. Since then it’s been in regular use replacing my legacy mocked dates with bright shiny frozen ones.

Atomic freeze ray gun design by Edgar Montes.
Freeze ray! Ahahaha! Image credits: Edgar Montes.

Treading a well-worn path

The standard pattern I’d been using in my test code was taken directly from the Python mock library documentation. It turns out I’ve been using this pattern for a long time: while writing this post I realised that I’ve been using it since before the mock library was integrated into the standard library. In other words, since back in the Python 2 days! Crikey!

Such a tried-and-true (and well-documented) pattern is great. It’s robust and one can lean on it again and again to do the job. Other devs have likely seen the pattern before, so there’s little need to explain the usage to someone new.

The main downside is needing a mock (and knowing how to use mocking well) and that brings with it its own slew of problems.

But before I get too far ahead of myself, you might not be familiar with the partial mocking pattern, so let’s see what it looks like on working code. Also, note that this post only discusses dates; even so, the concepts apply to times as well.

What did sign myself up for?

Imagine you’ve got a service to automatically deliver high-resolution satellite images to customers. For the discussion here, let’s also give this service the unimaginative name of img-send. I’m sure you can come up with a better one!

The service is subscription-based and you want to make sure that image delivery works for users with an active subscription. This means we don’t want to send images to users whose subscription hasn’t started yet. Nor do we want to send images to users whose subscription has expired. A subscription will therefore need a start and end date, and we only send images to customers if today’s date lies between these dates.

With those basic requirements defined, we can model such a subscription with a Subscription class like this:

# img_src/subscription.py
from datetime import date


class Subscription:
    def __init__(self, *, start_date, end_date):
        self.start_date = start_date
        self.end_date = end_date

    @property
    def is_active(self):
        return self.start_date <= date.today() <= self.end_date

where we pass the start and end dates as keyword arguments to the constructor when instantiating an object. We can use this code like so:

from datetime import date


subscription = Subscription(
    start_date=date(2023, 12, 1),
    end_date=date(2024, 2, 1)
)

Note that we could also check that start_date doesn’t fall on or later than end_date (it doesn’t make sense for the start date to be after the end date), but I don’t want the example to be too involved.

Ok, given that both dates mentioned in the example above are well in the past, it’s clear that the is_active property will return False:

# check if the subscription is active
subscription.is_active  # => False

If a subscription were set up to align with today’s date, then we’d expect is_active to return True (try it out yourself!).

We can codify these expectations in tests:

# tests/test_subscription.py
from unittest import TestCase
from datetime import date

from img_send.subscription import Subscription


class TestSubscription(TestCase):
    def test_subscription_is_active_in_subcription_period(self):
        start_date = date(2024, 4, 23)
        end_date = date(2024, 5, 23)
        subscription = Subscription(
            start_date=start_date, end_date=end_date
        )

        self.assertTrue(subscription.is_active)

    def test_subscription_is_not_active_outside_subcription_period(self):
        start_date = date(2023, 12, 1)
        end_date = date(2024, 2, 1)
        subscription = Subscription(
            start_date=start_date, end_date=end_date
        )

        self.assertFalse(subscription.is_active)

where we check that a subscription is active within the given subscription period and is not active outside it.

Stopping the inexorable march of time

There’s one big issue here: while these tests pass on the 23rd of April 2024 (and will continue to do so until the 23rd of May 2024), they will always fail thereafter. This is because time marches relentlessly onwards and thus the simple progression of time will make our test suite inevitably fail.

How can we fix that? The solution is to stop time in its tracks. We must ensure that “today” is always a well-known value that either lies within or outside the given subscription period. As mentioned at the beginning, my go-to solution to do this was partial mocking.

When using the partial mocking pattern, we mock out only that part of the module we need to change and leave the remaining functionality intact. For the code that we’re working with here, that means date.today() should always return a constant value, whereas date() should still work as usual. This is exactly the situation discussed in the Python mock module’s partial mocking documentation. Adapting the documented example to use the @patch decorator (we’ll use this in the test code soon), we end up with code of this form:

@patch('mymodule.date')
def test_mymodule_has_constant_today(self, mock_date):
    mock_date.today.return_value = date(2010, 10, 8)
    mock_date.side_effect = lambda *args, **kw: date(*args, **kw)

    # ... test code which uses the fact that "today" is 2010-10-08

The line

@patch('mymodule.date')

mocks out the entire date module as used within mymodule1 and adds the mock_date argument to the test method.

The next line after the test method definition sets the value to return when calling date.today():

    mock_date.today.return_value = date(2010, 10, 8)

i.e. any call to date.today() within the test method will always return date(2010, 10, 8).

The subsequent line

    mock_date.side_effect = lambda *args, **kw: date(*args, **kw)

ensures that calling date() has the usual behaviour and returns a date object from the given arguments.

Focussing on the differences after having updated the test code, we have:

 from unittest import TestCase
+from unittest.mock import patch
 from datetime import date

 from img_send.subscription import Subscription


 class TestSubscription(TestCase):
-    def test_subscription_is_active_in_subcription_period(self):
+    @patch('img_send.subscription.date')
+    def test_subscription_is_active_in_subcription_period(self, mock_date):
+        mock_date.today.return_value = date(2024, 5, 3)
+        mock_date.side_effect = lambda *args, **kw: date(*args, **kw)
+
         start_date = date(2024, 4, 23)
         end_date = date(2024, 5, 23)
         subscription = Subscription(
             start_date=start_date, end_date=end_date
         )

         self.assertTrue(subscription.is_active)

-    def test_subscription_is_not_active_outside_subcription_period(self):
+    @patch('img_send.subscription.date')
+    def test_subscription_is_not_active_outside_subcription_period(
+            self, mock_date
+    ):
+        mock_date.today.return_value = date(2024, 5, 3)
+        mock_date.side_effect = lambda *args, **kw: date(*args, **kw)
+
         start_date = date(2023, 12, 1)
         end_date = date(2024, 2, 1)
         subscription = Subscription(
             start_date=start_date, end_date=end_date
         )

         self.assertFalse(subscription.is_active)

To make it very obvious that this works, let’s make all dates lie in the past. The diff after making the change is as follows:

 class TestSubscription(TestCase):
     @patch('img_send.subscription.date')
     def test_subscription_is_active_in_subcription_period(self, mock_date):
-        mock_date.today.return_value = date(2024, 5, 3)
+        mock_date.today.return_value = date(2023, 8, 2)
         mock_date.side_effect = lambda *args, **kw: date(*args, **kw)

-        start_date = date(2024, 4, 23)
-        end_date = date(2024, 5, 23)
+        start_date = date(2023, 7, 1)
+        end_date = date(2023, 9, 1)
         subscription = Subscription(
             start_date=start_date, end_date=end_date
         )
@@ -25,11 +25,11 @@ class TestSubscription(TestCase):
     def test_subscription_is_not_active_outside_subcription_period(
             self, mock_date
     ):
-        mock_date.today.return_value = date(2024, 5, 3)
+        mock_date.today.return_value = date(2006, 8, 24)
         mock_date.side_effect = lambda *args, **kw: date(*args, **kw)

-        start_date = date(2023, 12, 1)
-        end_date = date(2024, 2, 1)
+        start_date = date(2006, 7, 1)
+        end_date = date(2006, 8, 1)
         subscription = Subscription(
             start_date=start_date, end_date=end_date
         )

The tests still pass! Yay! :tada:

The full test code now looks like this:

# tests/test_subscription.py
from unittest import TestCase
from unittest.mock import patch
from datetime import date

from img_send.subscription import Subscription


class TestSubscription(TestCase):
    @patch('img_send.subscription.date')
    def test_subscription_is_active_in_subcription_period(self, mock_date):
        mock_date.today.return_value = date(2023, 8, 2)
        mock_date.side_effect = lambda *args, **kw: date(*args, **kw)

        start_date = date(2023, 7, 1)
        end_date = date(2023, 9, 1)
        subscription = Subscription(
            start_date=start_date, end_date=end_date
        )

        self.assertTrue(subscription.is_active)

    @patch('img_send.subscription.date')
    def test_subscription_is_not_active_outside_subcription_period(
            self, mock_date
    ):
        mock_date.today.return_value = date(2006, 8, 24)
        mock_date.side_effect = lambda *args, **kw: date(*args, **kw)

        start_date = date(2006, 7, 1)
        end_date = date(2006, 8, 1)
        subscription = Subscription(
            start_date=start_date, end_date=end_date
        )

        self.assertFalse(subscription.is_active)

Note how this digs around in the module internals: we need to know how the date module works and we need to know exactly where date.today() is used so that we can override things correctly. This leaks implementation detail knowledge into our test code, which is where it doesn’t belong. Surely, there’s a better way, right?

Freezing time

Partial mocking definitely does the job, but it’s rather low-level. We need to know exactly which function to mock so that it always returns a known value and we have to make sure that the remaining functionality (in our case the date() function) still works as normal. This is a lot of unnecessary work.

Fortunately, there’s a simple solution: we can freeze time with the FreezeGun library. In particular, we can use the freeze_time decorator as a drop-in replacement for the partial mocking pattern.

To be able to use FreezeGun, install it via pip and add it to your project’s requirements (e.g. in bash):

$ pip install freezegun
$ pip freeze | grep i freezegun >> requirements.txt

Now, instead of

    @patch('img_send.subscription.date')
    def test_subscription_is_not_active_outside_subcription_period(
            self, mock_date
    ):
        mock_date.today.return_value = date(2006, 8, 24)
        mock_date.side_effect = lambda *args, **kw: date(*args, **kw)

it’s possible to write

   @freeze_time('2006-08-24')

That’s wonderful. It’s just like magic.

Spongebob with a rainbow above him and the words "it's magic"
Image credits: imgflip.com.

Our test code now reduces to this:

# tests/test_subscription.py
from unittest import TestCase
from datetime import date

from freezegun import freeze_time

from img_send.subscription import Subscription


class TestSubscription(TestCase):
    @freeze_time('2023-08-02')
    def test_subscription_is_active_in_subcription_period(self):
        start_date = date(2023, 7, 1)
        end_date = date(2023, 9, 1)
        subscription = Subscription(
            start_date=start_date, end_date=end_date
        )

        self.assertTrue(subscription.is_active)

    @freeze_time('2006-08-24')
    def test_subscription_is_not_active_outside_subcription_period(self):
        start_date = date(2006, 7, 1)
        end_date = date(2006, 8, 1)
        subscription = Subscription(
            start_date=start_date, end_date=end_date
        )

        self.assertFalse(subscription.is_active)

This is such an improvement, I can’t rave about it enough (although, I’m sure I’ll get over it :wink:).

Frozen and crystal clear

Making this change has made the test code and its associated context much clearer. It’s now obvious up front what date is held constant for the test. Primarily, this clarity is due to the removal of a lot of boilerplate code.

Not only is this cleaner–and more readable–but it’s more general. FreezeGun also handles all the internal date/time-related manipulations and overrides that one would usually need to take care of oneself, namely

all calls to datetime.datetime.now(), datetime.datetime.utcnow(), datetime.date.today(), time.time(), time.localtime(), time.gmtime(), and time.strftime() will return the time that has been frozen.

This is way better than the partial mocking solution. Now one can specify the date without having to meddle with any module internals and one get on with one’s life.

Talking about “just specifying the date”: did you notice how simple it was to specify the date? FreezeGun handles date-related inputs as strings (among other things). For instance, it parses date strings into the relevant date or datetime object so you don’t have to create the objects explicitly yourself.

The FreezeGun library is much more flexible than the example I presented above. For instance, to set a constant date for an entire class of tests (i.e. a test suite), it’s not necessary to specify the date for each and every test, one need only decorate the test class, e.g.:

@freeze_time('2023-08-02')
class TestSubscription(TestCase):

    # ... rest of test suite code

One can also use freeze_time with a context manager, making it possible to set the date/time for a very small portion of a test if desired. It even handles timezones easily, which is one less headache to worry about.

There are even more features, but the ones I’ve mentioned here are those that I’ve found most useful in my own code. To find out more, have a look at the docs.

Long story short

In short, if you need constant date or time-related information in your tests, don’t reach for a mock, draw your freeze gun.

  1. For more information about where to patch, see the mock module’s “Where to patch” documentation

Support

If you liked this post and want to see more like this, please buy me a coffee!

buy me a coffee logo