Testing Time
This post will be centered around how we can test time-related aspects in Python.
Table of Contents
A simple function which uses datetime
If you’ve ever needed to use date based elements in your Python code,
then chances are you have inevitably turned to the datetime
library.
Let’s take an example.
We want a function which creates a filename for a report.
Our main requirements is that the filename needs to include the current time, precise to the microsecond:
from datetime import datetime
def create_report_file_name() -> str:
timestamp: str = datetime.now().strftime("%Y-%m-%d_%H:%M:%S:%f")
return f"{timestamp}_report.txt"
Seems simple enough right?
We use the datetime
library to create a datetime.datetime
object for the timestamp now.
The
strftime
call returns a string which represents the timestamp formatted how we want.
For further details on how to format the strftime
output, see the strftime cheatsheet.
Note that for the purposes of this demo, we have not stored the string passed to strftime
as a constant.
In practice, we would want to do this as the string "%Y-%m-%d_%H:%M:%S:%f"
has a magic feel to it.
i.e. it is hard to know what that string represents in its raw form.
To be kinder to our readers, we would likely write this as:
from datetime import datetime
TIMESTAMP_FORMAT_TO_THE_MICROSECOND = "%Y-%m-%d_%H:%M:%S:%f"
def create_report_file_name() -> str:
timestamp: str = datetime.now().strftime(TIMESTAMP_FORMAT_TO_THE_MICROSECOND)
return f"{timestamp}_report.txt"
The result of a timestamp precise to the microsecond, would look something like the following:
>>> datetime.datetime.now().strftime("%Y-%m-%d_%H:%M:%S:%f")
'2022-12-03_16:37:12:618343'
How would we test this function?
So the burning question is. How would we go about writing tests against this? Our first naive go at writing a test might look like this:
def test_create_report_file_name():
timestamp: str = datetime.datetime.now().strftime("%Y-%m-%d_%H:%M:%S:%f")
report_file_name: str = create_report_file_name()
assert report_file_name == f"{timestamp}_report.txt"
Can you spot the problem?
Our test is dependent on the microseconds value of the timestamp being the same as the one created in
create_report_file_name()
.
Running this test as things stand will give us the following AssertionError
:
Expected :'2022-12-03_16:41:06:788619_report.txt'
Actual :'2022-12-03_16:41:06:788630_report.txt'
<Click to see difference>
def test_create_report_file_name():
timestamp: str = datetime.now().strftime("%Y-%m-%d_%H:%M:%S:%f")
report_file_name: str = create_report_file_name()
> assert report_file_name == f"{timestamp}_report.txt"
E AssertionError: assert '2022-12-03_16:41:06:788630_report.txt' == '2022-12-03_16:41:06:788619_report.txt'
E - 2022-12-03_16:41:06:788619_report.txt
E ? ^^
E + 2022-12-03_16:41:06:788630_report.txt
E ? ^^
In theory, we could test this by patching the call out to the datetime
library.
But these are notoriously difficult and troublesome to do in Python.
Freezing time
One option is to introduce a library like freezegun
, which will handle the mocking for us.
With freezegun
we can take a snapshot of time in our tests by applying a freeze_time
decorator:
from freezegun import freeze_time
@freeze_time(time_to_freeze=datetime.now())
def test_create_report_file_name():
timestamp: str = datetime.now().strftime("%Y-%m-%d_%H:%M:%S:%f")
report_file_name: str = create_report_file_name()
assert report_file_name == f"{timestamp}_report.txt"
freeze_time
will patch all calls to datetime
with the snapshot of time we pass to it.
In this case we have passed datetime.now()
to the freeze_time
decorator.
So we will only have the now
timestamp calculated once, at the point at which the decorating function is called.
Therefore, whenever we call datetime.now
within the body of test_create_report_file_name
,
we are guaranteed to be returned with the same value as the timestamp which is made in create_report_file_name()
.
The problems with freezing time
There are however a couple of issues with the freezegun
solution.
- We have introduced an extra dependency to our code.
- Our
create_report_file_name()
function still makes a system call.
On point number 2), the function we are testing is impure. We do not have a fine-level of control of its output. We also have no control of any inputs to the function.
It can be said that our create_report_file_name()
is implicitly dependent on the call to the datetime
library.
Injecting the dependency of time
We can remedy this by injecting the timestamp into create_report_file_name()
.
Injecting is really just a fancy way of saying that we pass it in as an argument to the function:
import datetime
def create_report_file_name(current_timestamp: datetime.datetime) -> str:
timestamp: str = current_timestamp.strftime("%Y-%m-%d_%H:%M:%S:%f")
return f"{timestamp}_report.txt"
By passing in the timestamp, we make the caller of our create_report_file_name()
responsible for its computation.
So our tests become much easier to write:
import datetime
def test_create_report_file_name():
timestamp: datetime.datetime = datetime.now()
report_file_name: str = create_report_file_name(current_timestamp=timestamp)
assert report_file_name == f"{timestamp}_report.txt"
In this case, the caller of our function, which in this case is the test, is responsible for creating the timestamp.
It can now be said that we have a greater level of control over create_report_file_name()
.
We also have the final say in what the output of create_report_file_name()
is.
Arguably, you may also be tempted set current_timestamp
with a default argument:
import datetime
def create_report_file_name(current_timestamp: datetime.datetime = datetime.datetime.now()) -> str:
timestamp: str = current_timestamp.strftime("%Y-%m-%d_%H:%M:%S:%f")
return f"{timestamp}_report.txt"
Note that this does remove some of the responsibility from the caller to provide their computed timestamp.
But, it would be fair to argue that the purpose of create_report_file_name()
is not to take note of the current time.
In fact the job of create_report_file_name()
is as its name suggests, to give us a suitable file name for a report.
The other thing to note here is, the default value of datetime.datetime.now()
is computed when the module is imported.
This can lead to some unexpected results. To counter this, you might still be tempted to use a guard condition:
import datetime
def create_report_file_name(current_timestamp: Optional[datetime.datetime]) -> str:
if current_timestamp is None:
current_timestamp = datetime.datetime.now()
timestamp: str = current_timestamp.strftime("%Y-%m-%d_%H:%M:%S:%f")
return f"{timestamp}_report.txt"
Here the current_timestamp
is calculated when the function is called but we have still taken back some of the
responsibility that we gave to the caller when we injected the timestamp.
Because of the undeterminstic aspect of trying to manipulate time elements, you may even be tempted to consider
calls to the clock in the same vein as making calls to a database.
Personally, I try to adhere to the school of thought of allowing the caller to have greater control of our logic.
As you’ve seen, this generally leads to a smoother ride when it comes to testing our code.