avatarPetrica Leuca

Summary

The provided content discusses methods for simulating time in software testing, particularly focusing on determining the last day of the month using Python with Typer, Freezegun, Libfaketime, and time-machine libraries, while also considering time zones.

Abstract

The article delves into the importance of manipulating time in software engineering, presenting a Python function that determines if a given date is the last day of the month. It utilizes the Typer library to build a command-line interface (CLI) that outputs a message indicating whether the current date is the month's end. The testing of this function is facilitated by libraries such as Freezegun and time-machine, which allow for freezing time at specific points to ensure consistent test outcomes. Additionally, the article addresses the complexities of handling time zones, demonstrating how to configure Docker containers to reflect different time zones and discussing the impact of daylight saving time. The author also compares the performance of Freezegun and time-machine, noting that time-machine executes tests more quickly. The article concludes with recommendations for best practices in handling timestamps and time zones in databases.

Opinions

  • The author emphasizes the necessity of dealing with time and time zones in software applications, suggesting that even senior engineers may find time zones challenging.
  • Freezegun is highlighted as a powerful tool for testing time-dependent functions, but the author notes its limitation in handling UTC offsets correctly without additional configuration.
  • The author points out the importance of considering daylight saving time when working with time zones, as it can affect the UTC offset.
  • Libfaketime is introduced as an alternative for manipulating system time during execution, which can be useful in scenarios where altering the system time is necessary.
  • The author expresses a preference for time-machine over Freezegun due to its faster execution speed in test scenarios.
  • The article recommends saving timestamps in UTC or using dedicated data types that include time zone information to avoid time zone-related issues.

Mastering Time: Tips and Tricks for Faking Time

Exploring localization with Typer, Freezegun, Libfaketime, and time-machine

Photo by Who’s Denilo ? on Unsplash

Our systems are bound to time: when the action or event happened, when the system became aware of it, and when it reacted to it.

Therefore, faking time is an essential functionality we use in our day-to-day life in software/data engineering. In this article, we will go through three ways of faking time while using Typer as well — the easiest command line interface builder.

Let’s suppose we have an application that returns a message mentioning if a certain date is the last day of a month or not. The following implementation is available on GitHub.

What’s the Last Day of the Month

We first implement a function that inputs a datetime parameter and uses structural pattern matching in Python to determine if the datetime is the last day of the month or not based on the year. For instance, it looks at whether it is a leap year, February, an odd or even month, and the day (28 or 29, 30 or 31). Here’s the code:

def is_last_day_of_month(input_datetime: date) -> bool:
    """
    :param input_datetime: date object
    :return:
        True, if datetime is last day of month
        False, otherwise
    """
    date_year = input_datetime.year
    date_month = input_datetime.month
    date_day = input_datetime.day
    match date_month:
        case odd_month if odd_month in [1, 3, 5, 7, 8, 10, 12]:
            match date_day:
                case 31:
                    return True
                case _:
                    return False
        case even_month if even_month in [4, 6, 9, 11]:
            match date_day:
                case 30:
                    return True
                case _:
                    return False
        case 2:
            match date_year % 4:
                case 0:
                    match date_day:
                        case 29:
                            return True
                        case _:
                            return False
                case _:
                    match date_day:
                        case 28:
                            return True
                        case _:
                            return False
        case _:
            raise ValueError(f"Unexpected values for {input_datetime} {date_year} {date_month} {date_day}")

For the above function, we define a test that we can execute for one year's worth of dates and verify our function returns the correct response. We will use the pytest parametrize, so we can execute the same test with a different input. Here’s what that looks like:

import datetime

import pytest
from faking_time_demo.main import is_last_day_of_month

start_date = datetime.date(2023, 1, 1)

test_dates = [start_date + datetime.timedelta(days=no_days) for no_days in range(365)]

test_scenario_bool = [
    (
        date_,
        True
        if date_
        in [
            datetime.date(2023, 1, 31),
            datetime.date(2023, 2, 28),
            datetime.date(2023, 3, 31),
            datetime.date(2023, 4, 30),
            datetime.date(2023, 5, 31),
            datetime.date(2023, 6, 30),
            datetime.date(2023, 7, 31),
            datetime.date(2023, 8, 31),
            datetime.date(2023, 9, 30),
            datetime.date(2023, 10, 31),
            datetime.date(2023, 11, 30),
            datetime.date(2023, 12, 31),
        ]
        else False,
    )
    for date_ in test_dates
]
test_scenario_bool.append((datetime.date(2024, 2, 29), True))


@pytest.mark.parametrize(("input_datetime", "expected_result"), test_scenario_bool)
def test_is_last_day_of_month(input_datetime, expected_result):
    with freeze_time(input_datetime):
        assert is_last_day_of_month(input_datetime=input_datetime) is expected_result

In the above code, we created a list with 366 dates (2023 plus 29-02-2024 to test the leap year). With parametrize, we execute the same test for each item from the list. Here’s the code:

(faking-time-demo-py3.11) acirtep ~/faking-time-demo [main] $ pytest tests/test_last_day.py::test_get_text_for_last_day 
================================================================================ test session starts ================================================================================
platform darwin -- Python 3.11.0, pytest-7.2.2, pluggy-1.0.0
collected 366 items                                                                                                                                                                 

tests/test_last_day.py ...................................................................................................................................................... [ 40%]
............................................................................................................................................................................. [ 88%]
...........................................                                                                                                                                   [100%]
================================================================================ 366 passed in 0.92s ================================================================================

Tell Me if It’s the Last Day of the Month

Now that we have a function that checks if a datetime is the last day of the month, we can use it to print a message. Here’s how to do that:

def get_text_for_day(input_timezone: AvailableTimeZone = None):
    timezone_to_set = input_timezone or time.tzname[0]
    current_datetime = datetime.now(tz=pytz.timezone(timezone_to_set))
    is_last_day = is_last_day_of_month(current_datetime)
    typer.echo(typer.style(f"{current_datetime} is expressed in {timezone_to_set}"))
    if is_last_day:
        message = f"{current_datetime} is last day of the month"
        typer.echo(typer.style(message, fg=typer.colors.GREEN))
    else:
        message = f"{current_datetime} is NOT last day of the month"
        typer.echo(typer.style(message, fg=typer.colors.RED))
    return message


if __name__ == "__main__":
    typer.run(get_text_for_day)

In the above code, we implemented a typer CLI with the following definition:

(faking-time-demo-py3.11) acirtep ~/faking-time-demo [main] $python ./faking_time_demo/main.py --help              
Usage: main.py [OPTIONS]

Options:
  --input-timezone [UTC|Europe/Amsterdam|Europe/Bucharest|US/Eastern]
                                  [default: AvailableTimeZone.UTC]
  --help                          Show this message and exit.

And here, we used an Enum called AvailableTimeZone as an argument that accepts the following four time zones:

from enum import Enum

class AvailableTimeZone(str, Enum):
    UTC = "UTC"
    NETHERLANDS = "Europe/Amsterdam"
    ROMANIA = "Europe/Bucharest"
    NEW_YORK = "US/Eastern"

The above command will print a message, in either green or red, if the current datetime is the last day of the month or not.

(faking-time-demo-py3.11) acirtep ~/faking-time-demo [main] $ python ./faking_time_demo/main.py --input-timezone UTC
2023-03-27 15:38:26.323668+00:00 is NOT last day of the month

Freezing Time, the Secret Weapon

While testing the function is_last_day_of_month was straightforward because the datetime was an input parameter, testing the get_text_for_day is different because it uses datetime.now().

def test_get_text_for_day():
    message = get_text_for_day(input_timezone=AvailableTimeZone.UTC)
    assert "is NOT last day" in message

While writing the above definition, I knew the date I executed the test on wasn’t the last day, but will that be the case for future executions? Nope. This is a flaky test that will fail at any month’s end.

And so, freezegun comes to the rescue by freezing the time based on our input.

First, we need to define a set of scenarios to test the result against. Here’s how to do that:

test_scenario_message = [
    (
        date_,
        "is last day of the month"
        if date_
        in [
            datetime.date(2023, 1, 31),
            datetime.date(2023, 2, 28),
            datetime.date(2023, 3, 31),
            datetime.date(2023, 4, 30),
            datetime.date(2023, 5, 31),
            datetime.date(2023, 6, 30),
            datetime.date(2023, 7, 31),
            datetime.date(2023, 8, 31),
            datetime.date(2023, 9, 30),
            datetime.date(2023, 10, 31),
            datetime.date(2023, 11, 30),
            datetime.date(2023, 12, 31),
        ]
        else "is NOT last day of the month",
    )
    for date_ in test_dates
]
test_scenario_message.append((datetime.date(2024, 2, 29), "is last day of the month"))

With the above test data and freeze_time, we can run the following test and expect consistency between runs:

import pytest
from freezegun import freeze_time

from faking_time_demo.constants import AvailableTimeZone
from faking_time_demo.main import get_text_for_day

@pytest.mark.parametrize(("input_datetime", "expected_result"), test_scenario_message)
def test_get_text_for_day(input_datetime, expected_result):
    with freeze_time(input_datetime):
        message = get_text_for_day(input_timezone=AvailableTimeZone.UTC)
        assert expected_result in message

In the code above, freeze_time will mock datetime with the date provided. Therefore, whenever we execute get_text_for_day, datetime.now() will be executed with the date that’s tied to the input given to freeze_time.

Source: 9gag

What’s a Date Without a Time Zone?

While time zones provoke negative reactions even to most senior engineers, they are important irrespective of how global your application is. Whenever we work with datetime, we need to be aware of the following:

  • the time zone of the system our application runs on
  • the time zone of the database
  • the time zone of the user

Let’s experiment with the above CLI on two Docker containers that have a different time zone. These are configured with the TZ env variable in docker-compose. For each value configured in AvailableTimeZone, a service is created as shown:

services:
  faking_time_utc:
    container_name: faking_time_utc
    restart: on-failure
    build:
      context: .
      dockerfile: Dockerfile
    environment:
      - PYTHONPATH=/app
      - TZ=UTC
    volumes:
      - ./faking_time_demo:/app/faking_time_demo
      - ./tests:/app/tests
    command:
      - bash
      - -c
      - tail -f /dev/null
...

Execute docker-compose up and four containers that are available for test. Here’s the code:

$ docker exec -it faking_time_est python /app/faking_time_demo/main.py 
2023-03-27 14:37:34.357152-05:00 is expressed in EST
2023-03-27 14:37:34.357152-05:00 is NOT last day of the month
$ docker exec -it faking_time_utc python /app/faking_time_demo/main.py 
2023-03-27 19:37:43.613762+00:00 is expressed in UTC
2023-03-27 19:37:43.613762+00:00 is NOT last day of the month
$ docker exec -it faking_time_ro python /app/faking_time_demo/main.py 
2023-03-27 22:40:05.918796+03:00 is expressed in EET
2023-03-27 22:40:05.918796+03:00 is NOT last day of the month
$ docker exec -it faking_time_nl python /app/faking_time_demo/main.py 
2023-03-27 21:40:13.214218+02:00 is expressed in CET
2023-03-27 21:40:13.214218+02:00 is NOT last day of the month

While checking the test for the get_text function, we observe it is always checking for the UTC time zone:

@pytest.mark.parametrize(("input_datetime", "expected_result"), test_scenario_message)
def test_get_text_for_day(input_datetime, expected_result):
    with freeze_time(input_datetime):
        message = get_text_for_day(input_timezone=AvailableTimeZone.UTC)
        assert expected_result in message

This happens because freeze_time’s default datetime is UTC. If we remove the input_timezone default value and execute pytest on each container, we can see that depending on the container’s time zone, the tests succeeded for UTC, CET, and EET and failed for EST.

$ docker exec -it faking_time_nl pytest -v -rP /app/tests/test_last_day.py::test_get_text_for_day
================================================================================================================================================= test session starts ==================================================================================================================================================
2023-01-01 01:00:00+01:00 is expressed in CET
2023-01-01 01:00:00+01:00 is NOT last day of the month
================================================================================================================================================== 1 passed in 0.08s ===================================================================================================================================================


$ docker exec -it faking_time_ro pytest -v -rP /app/tests/test_last_day.py::test_get_text_for_day
================================================================================================================================================= test session starts ==================================================================================================================================================
2023-01-01 02:00:00+02:00 is expressed in EET
2023-01-01 02:00:00+02:00 is NOT last day of the month
================================================================================================================================================== 1 passed in 0.07s ===================================================================================================================================================


$ docker exec -it faking_time_utc pytest -v -rP /app/tests/test_last_day.py::test_get_text_for_day
================================================================================================================================================= test session starts ==================================================================================================================================================
2023-01-01 00:00:00+00:00 is expressed in UTC
2023-01-01 00:00:00+00:00 is NOT last day of the month
================================================================================================================================================== 1 passed in 0.09s ===================================================================================================================================================


$ docker exec -it faking_time_est pytest -v -rP /app/tests/test_last_day.py::test_get_text_for_day
================================================================================================================================================= test session starts ==================================================================================================================================================
platform linux -- Python 3.11.0, pytest-7.2.2, pluggy-1.0.0 -- /usr/local/bin/python
cachedir: .pytest_cache
rootdir: /app
collected 1 item                                                                                                                                                                                                                                                                                                       

tests/test_last_day.py::test_get_text_for_day[input_datetime0-is NOT last day of the month] FAILED                                                                                                                                                                                                               [100%]

======================================================================================================================================================= FAILURES =======================================================================================================================================================
_________________________________________________________________________________________________________________________ test_get_text_for_day[input_datetime0-is NOT last day of the month] __________________________________________________________________________________________________________________________

input_datetime = datetime.date(2023, 1, 1), expected_result = 'is NOT last day of the month'

    @pytest.mark.parametrize(("input_datetime", "expected_result"), test_scenario_message[0:1])
    def test_get_text_for_day(input_datetime, expected_result):
        with freeze_time(input_datetime):
            message = get_text_for_day()
>           assert expected_result in message
E           AssertionError: assert 'is NOT last day of the month' in '2022-12-31 19:00:00-05:00 is last day of the month'

tests/test_last_day.py:87: AssertionError
------------------------------------------------------------------------------------------------------------------------------------------------- Captured stdout call -------------------------------------------------------------------------------------------------------------------------------------------------
2022-12-31 19:00:00-05:00 is expressed in EST
2022-12-31 19:00:00-05:00 is last day of the month
================================================================================================================================================== 1 failed in 0.09s ===================================================================================================================================================

To fix the above error, we need to configure the offset for freeze_time. Here’s how to do that:

def get_offset(tzname):
    match tzname:
        case "UTC":
            return 0
        case "EST":
            return 5
        case "CET":
            return -1
        case "EET":
            return -2
        case _:
            raise Exception("Unknown timezone")


@pytest.mark.parametrize(("input_datetime", "expected_result"), test_scenario_message[0:1])
def test_get_text_for_day(input_datetime, expected_result):
    with freeze_time(input_datetime, tz_offset=get_offset(time.tzname[0])):
        message = get_text_for_day()
        assert expected_result in message

We mock the system's timestamp with the code above by assigning the offset compared to the UTC. After re-executing the tests, we get the following:

$ docker exec -it faking_time_nl pytest -v -rP /app/tests/test_last_day.py::test_get_text_for_day 
================================================================================================================================================= test session starts ==================================================================================================================================================
2023-01-01 00:00:00+01:00 is expressed in CET
2023-01-01 00:00:00+01:00 is NOT last day of the month
================================================================================================================================================== 1 passed in 0.06s ===================================================================================================================================================


$ docker exec -it faking_time_ro pytest -v -rP /app/tests/test_last_day.py::test_get_text_for_day 
================================================================================================================================================= test session starts ==================================================================================================================================================
2023-01-01 00:00:00+02:00 is expressed in EET
2023-01-01 00:00:00+02:00 is NOT last day of the month
================================================================================================================================================== 1 passed in 0.06s ===================================================================================================================================================


$ docker exec -it faking_time_utc pytest -v -rP /app/tests/test_last_day.py::test_get_text_for_day
================================================================================================================================================= test session starts ==================================================================================================================================================
2023-01-01 00:00:00+00:00 is expressed in UTC
2023-01-01 00:00:00+00:00 is NOT last day of the month
================================================================================================================================================== 1 passed in 0.06s ===================================================================================================================================================


$ docker exec -it faking_time_est pytest -v -rP /app/tests/test_last_day.py::test_get_text_for_day
================================================================================================================================================= test session starts ==================================================================================================================================================
2023-01-01 00:00:00-05:00 is expressed in EST
2023-01-01 00:00:00-05:00 is NOT last day of the month
================================================================================================================================================== 1 passed in 0.06s ===================================================================================================================================================

Faking System Time

While freezegun is a powerful library to manipulate time for testing purposes, there might be a case in which one needs to manipulate the system time during execution too. To do this, libfaketime can be installed in the docker image using this command:

RUN apt-get update && \
      apt-get -y install libpq-dev python3-dev gcc libfaketime

We can now execute the CLI to print the message inside the docker container by providing a fake time as the system time. Here’s what that looks like:

root@c7e6aad858cc:/app# LD_PRELOAD=/usr/lib/aarch64-linux-gnu/faketime/libfaketime.so.1 FAKETIME="2023-09-29 22:00:00" python /app/faking_time_demo/main.py --input-timezone Europe/Amsterdam
2023-09-30 00:00:00+02:00 is expressed in AvailableTimeZone.NETHERLANDS
2023-09-30 00:00:00+02:00 is last day of the month

Here’s the results for the rest of the time zones:

Hmm, wait a minute! With freeze time, the UTC offset for Amsterdam is +1, and now with libfaketime, it is +2. That is because we must also be aware of daylight saving time. If you are interested in understanding UTC offset, I recommend getting started with the Wikipedia page.

An Alternative to Freezegun

While having some issues with testing UTC datetime with freezegun, I’ve discovered time-machine. Instead of using freeze_time, we can use time_machine.travel:

import time_machine


@pytest.mark.parametrize(("input_datetime", "expected_result"), test_scenario_message)
def test_get_text_for_day_time_machine(input_datetime, expected_result):
    input_datetime = input_datetime.replace(tzinfo=timezone(time.tzname[0]))
    with time_machine.travel(input_datetime):
        message = get_text_for_day()
        assert expected_result in message

The benefit of time-machine is that it is faster than freezegun, as the author describes here. The speed is easily observed in our demo tests as well: the tests with time_machine are executed in 0.23 seconds, while the ones with freezegun in 1.06 seconds.

$ docker exec -it faking_time_est pytest /app/tests/test_last_day.py::test_get_text_for_day_freeze_gun 
================================================================================================================================================= test session starts ==================================================================================================================================================
platform linux -- Python 3.11.0, pytest-7.2.2, pluggy-1.0.0
rootdir: /app
plugins: time-machine-2.9.0
collected 366 items       
tests/test_last_day.py ......................................................................................................................................................................................................................................................................................... [ 76%]
.....................................................................................                                                                                                                                                                                                                            [100%]
================================================================================================================================================= 366 passed in 1.06s ==================================================================================================================================================

$ docker exec -it faking_time_est pytest /app/tests/test_last_day.py::test_get_text_for_day_time_machine
================================================================================================================================================= test session starts ==================================================================================================================================================
platform linux -- Python 3.11.0, pytest-7.2.2, pluggy-1.0.0
rootdir: /app
plugins: time-machine-2.9.0
collected 366 items                                                                                                                                                                                                                                                                                                    
tests/test_last_day.py ......................................................................................................................................................................................................................................................................................... [ 76%]
.....................................................................................                                                                                                                                                                                                                            [100%]
================================================================================================================================================= 366 passed in 0.23s ==================================================================================================================================================                                                                                                                                                                                                                                                                                             

Conclusion

In this article, we went through a simple implementation of a function which is checking if a certain datetime is the last day of the month, experimented with freeze time, libfaketime, and tested with different time zones. The GitHub repos of the utilities used are:

  1. Typer
  2. Freezegun
  3. Libfaketime
  4. Time-machine

The above code is available at Faking-time-demo.

As a final tip, it is generally recommended to save timestamps in their UTC value or use dedicated types such as timestamp with time zone, according to Postgres.

Data Engineering
Testing
Timezone
Python
Programming
Recommended from ReadMedium