AJ Kerrigan
Posted on August 14, 2020
This post originally appeared as a guest article on PyBites.
Introduction
If you've ever tested code involving dates and times in Python you've probably had to mock the datetime module. And if you've mocked the datetime module, at some point it probably mocked you back when your tests failed.
Let's look at some problems that pop up with fake datetimes, and how freezegun can help address them.
A simple test
First, here's a snippet of date-sensitive code:
tomorrow.py
import datetime
def tomorrow():
return datetime.date.today() + datetime.timedelta(days=1)
When we test that function, we probably want the result to be the same every day. One way to handle that is by using a fake date in the test:
import datetime
from unittest.mock import patch, Mock
import tomorrow
fake_date = Mock(wraps=datetime.date)
fake_date.today.return_value = datetime.date(2020, 7, 2)
@patch('datetime.date', fake_date)
def test_tomorrow():
assert tomorrow.tomorrow() == datetime.date(2020, 7, 3)
That works, great! Now let's break it.
A catalog of failures
Let's say that during a refactor, we change this:
import datetime
def tomorrow():
return datetime.date.today() + datetime.timedelta(days=1)
to this:
from datetime import date, timedelta
def tomorrow():
return date.today() + timedelta(days=1)
or perhaps this:
from datetime import date as dt, timedelta as td
def tomorrow():
return dt.today() + td(days=1)
Both changes cause the test to fail, even though there is no functional change. Why?
With from datetime import date, timedelta
, the code under test gets a reference to the unpatched datetime.date
at import time. By the time the test runs, its @patch
has no practical effect.
Following the advice in "Where to patch", we could get the test working again by patching our own code rather than the builtin datetime module:
@patch('tomorrow.date', fake_date)
def test_tomorrow():
assert tomorrow.tomorrow() == datetime.date(2020, 7, 3)
That wouldn't cover the second change though - we'd need to also patch tomorrow.dt
for that. Just a couple examples make this test start to feel brittle and tightly coupled to the implementation.
We can do better though.
A more resilient test
We're looking for a test that can verify the behavior of our code even if subtle implementation details change. Aside from import variations, we've got different ways to fetch the current date (date.today()
, datetime.now()
, datetime.utcnow()
, et cetera). By starting with the simplest test possible and working toward flexibility, it's possible to end up with something like this:
fake_dttm = Mock(wraps=datetime)
fake_dttm.date.today.return_value = datetime.date(2020, 7, 2)
fake_dttm.datetime.now.return_value = datetime.datetime(2020, 7, 2)
fake_dttm.datetime.utcnow.return_value = datetime.datetime(2020, 7, 2)
@patch('tomorrow.date', fake_dttm.date, create=True)
@patch('tomorrow.datetime', fake_dttm, create=True)
@patch('tomorrow.dt', fake_dttm, create=True)
def test_tomorrow():
assert tomorrow.tomorrow() == datetime.date(2020, 7, 3)
Which feels like an uncanny valley between thoroughness and pragmatism.
For a PyBites code challenge , the tests carry an extra consideration. You write tests for an "official solution", but the tests also run against user-submitted code. So it shouldn't matter if one person uses import datetime as dt
while another opts for from datetime import date as dt, timedelta as td
.
There are a few different ways to tackle this. I list some references at the end of this post, but for now we'll look at the freezegun library.
Adding freezegun to our tests
Freezegun provides a freeze_time()
decorator that we can use to set a fixed date for our test functions. Picking up from the last section, that helps evolve this:
fake_date = Mock(wraps=datetime.date)
fake_date.today.return_value = datetime.date(2020, 7, 2)
@patch('tomorrow.date', fake_date)
def test_tomorrow():
assert tomorrow.tomorrow() == datetime.date(2020, 7, 3)
into this:
from freezegun import freeze_time
@freeze_time('2020-07-02')
def test_tomorrow():
assert tomorrow.tomorrow(include_time=True) == datetime.datetime(2020, 7, 3)
There are a few neat things going on there. For one, we can use a friendly date string (courtesy of the excellent dateutil library) rather than a handcrafted date
or datetime
object. That gets even more useful when we're dealing with full timestamps rather than dates.
It's also useful (but less immediately clear) that freeze_time
patches a number of common datetime methods under the hood:
datetime.datetime.now()
datetime.datetime.utcnow()
datetime.date.today()
time.time()
time.localtime()
time.gmtime()
time.strftime()
Because of how freezegun patches those methods, we can guarantee seeing the frozen date as long as the code under test uses those builtin methods. So our tests will smoothly handle import variations like:
import datetime
from datetime import date
from datetime import date as dt
Deep and thorough fakes
Freezegun keeps your tests simple by faking Python datetimes thoroughly. Not only does it patch methods from datetime
and time
, it looks at existing imports so it can be sure they're patched too. For anyone interested in the details, this section of the freezegun API code is a fine read!
If you're rolling your own fakes, you're not likely to be as thorough as freezegun. You probably don't need to be! But for at least some cases, a library like freezegun can offer more thorough tests that are also simpler and more readable.
Taking it further
For high volume, performance-sensitive tests with fake dates, libfaketime may be worth a look. Additionally, there are pytest plugins available for both freezegun and libfaketime.
References
This post was a pretty narrowly-focused look at some common issues that pop up when testing with fake dates. I'm not an expert on any of this stuff, but I've been inspired by folks who know it better. So if you found this post interesting, some of these resources may also be worth a look:
- Understanding the Python Mock Object Library (Real Python)
- Why your mock doesn't work (Ned Batchelder)
- Stop using datetime.now! (Haki Benita)
- unittest.mock - "Where to patch" (Python Documentation)
- Anything with Brian Okken's name attached to it. There's a whole Okkensphere out there, including:
- Test and Code (Podcast)
- Python Testing with Pytest (Book)
- Python Testing (Web site)
And since we're talking about dates and times here, I can't help including Falsehoods programmers believe about time.
Acknowledgements
Thanks to the PyBites community for inspiring this post. Notably:
- Nitin George Cherian, for kickstarting a discussion about fake dates
- Terry Spotts, for authoring and refactoring a relevant code challenge
- Bob Belderbos, for pointing out that this topic deserved a post
And of course, thanks to Steve Pulec for creating freezegun. (While I'm at it, thanks for moto too!)
Posted on August 14, 2020
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.