DEV Community

Cover image for Better date mocking in Python tests with FreezeGun
Paul Cochrane šŸ‡ŖšŸ‡ŗ
Paul Cochrane šŸ‡ŖšŸ‡ŗ

Posted on • Originally published at peateasea.de on

Better date mocking in Python tests with FreezeGun

Originally published on peateasea.de.

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.

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
Enter fullscreen mode Exit fullscreen mode

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)
)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

The line

@patch('mymodule.date')
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
         )
Enter fullscreen mode Exit fullscreen mode

The tests still pass! Yay! šŸŽ‰

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

itā€™s possible to write

   @freeze_time('2006-08-24')
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

This is such an improvement, I canā€™t rave about it enough (although, Iā€™m sure Iā€™ll get over it šŸ˜‰).

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 dateor 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
Enter fullscreen mode Exit fullscreen mode

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. ā†©

Top comments (0)