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
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 mymodule
1 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! š
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.
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 š).
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()
, andtime.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.
- For more information about where to patch, see the
mock
moduleās āWhere to patchā documentation. ā©
Top comments (0)