Afaan Ashiq

Software Engineering

Testing Time

December 3, 2022 5 min read

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.

  1. We have introduced an extra dependency to our code.
  2. 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.