時間を自在に操る魔法!🧙 Pythonライブラリ freezegun 徹底解説 ⏱️

ソフトウェアテスト

テストにおける時間依存性の悩みを解決しよう

はじめに:なぜ時間操作が必要なのか?🤔

ソフトウェア開発、特にテストの場面で「時間」はしばしば厄介な存在となります。特定の時間にのみ発生するバグ、有効期限のチェック、定期実行タスクの検証など、現在時刻に依存するロジックはテストが難しいものです。 システム時刻を手動で変更するのは手間がかかり、再現性も低く、自動テストにも組み込みにくいですよね。😭

そんな悩みを解決してくれるのが、今回ご紹介するPythonライブラリ freezegun です! freezegunは、Pythonのテスト中に時間を「凍結」させたり、任意の日時に「移動」させたりすることを可能にする強力なツールです。 これにより、時間依存のコードを簡単かつ確実にテストできるようになります。

このブログ記事では、freezegunの基本的な使い方から応用的な機能、そして実際のテストでの活用例まで、詳しく解説していきます。さあ、freezegunを使ってテストの時間旅行に出かけましょう!🚀

インストール:冒険の準備 🛠️

freezegunの利用を開始するのはとても簡単です。pipを使ってインストールするだけです。 仮想環境を利用することをお勧めします。

pip install freezegun

これで、あなたのPythonプロジェクトでfreezegunを使う準備が整いました!🎉

なお、freezegunは内部で python-dateutil ライブラリを使用しているため、もしインストールされていなければ依存関係として自動的にインストールされます。これにより、柔軟な日付文字列のパースなどが可能になっています。

基本的な使い方①:デコレータで時間を固定 ❄️

freezegunの最も一般的な使い方は、テスト関数やテストクラスに @freeze_time デコレータを付与することです。 これにより、デコレータが適用されたスコープ内で datetime.datetime.now(), datetime.datetime.utcnow(), datetime.date.today(), time.time(), time.localtime(), time.gmtime(), time.strftime() などの呼び出しが、指定した固定時刻を返すようになります。

具体的な使い方を見てみましょう。

import datetime
import time
from freezegun import freeze_time

# 文字列で日時を指定
@freeze_time("2023-10-27 10:00:00")
def test_specific_time_str():
    now = datetime.datetime.now()
    today = datetime.date.today()
    current_time = time.time()
    print(f"固定された現在時刻 (datetime.now): {now}")
    print(f"固定された今日の日付 (date.today): {today}")
    # time.time() も固定される (Unixタイムスタンプ)
    # 2023-10-27 10:00:00 UTC のタイムスタンプを確認してみましょう
    assert now == datetime.datetime(2023, 10, 27, 10, 0, 0)
    assert today == datetime.date(2023, 10, 27)

# datetimeオブジェクトで日時を指定
specific_dt = datetime.datetime(1985, 10, 26, 1, 22, 0)
@freeze_time(specific_dt)
def test_specific_time_datetime_object():
    now = datetime.datetime.now()
    print(f"バック・トゥ・ザ・フューチャー!🕰️ 固定時刻: {now}")
    assert now == specific_dt

# 日付のみを指定 (時刻は00:00:00になる)
@freeze_time("2024-01-01")
def test_specific_date_only():
    now = datetime.datetime.now()
    today = datetime.date.today()
    print(f"新年あけましておめでとうございます🎍 固定時刻: {now}")
    assert now == datetime.datetime(2024, 1, 1, 0, 0, 0)
    assert today == datetime.date(2024, 1, 1)

# テストクラス全体に適用 (unittestの場合)
import unittest

@freeze_time("1955-11-12 06:38:00")
class MyTimeTravelTests(unittest.TestCase):
    def test_delorean_arrival(self):
        print(f"デロリアン到着時刻!⚡: {datetime.datetime.now()}")
        self.assertEqual(datetime.datetime.now(), datetime.datetime(1955, 11, 12, 6, 38, 0))

    def test_another_event_in_1955(self):
        # このテストメソッド内でも時間は固定されている
        print(f"1955年の別のイベント: {datetime.datetime.now()}")
        self.assertEqual(datetime.datetime.now(), datetime.datetime(1955, 11, 12, 6, 38, 0))

# 実行例
test_specific_time_str()
test_specific_time_datetime_object()
test_specific_date_only()

# unittestの実行 (通常はテストランナー経由で実行)
# suite = unittest.TestSuite()
# suite.addTest(unittest.makeSuite(MyTimeTravelTests))
# runner = unittest.TextTestRunner()
# runner.run(suite)

このように、@freeze_time デコレータを使うことで、テストコード内の時間を簡単に固定できます。 引数には、日付や日時の文字列(ISO 8601形式推奨ですが、dateutilが解釈できる形式ならOK)や、datetimeオブジェクトdateオブジェクトなどを指定できます。

また、time.monotonic()time.perf_counter() も凍結されますが、これらの関数の絶対値は保証されず、時間経過に伴う変化のみが凍結される点に注意が必要です。

基本的な使い方②:コンテキストマネージャで一時的に固定 ⏳

テスト関数全体ではなく、特定のコードブロック内でのみ時間を固定したい場合もあります。 そのような場合には、freeze_time をコンテキストマネージャとして使用します。

import datetime
from freezegun import freeze_time

def test_context_manager():
    print(f"コンテキストマネージャに入る前の時刻: {datetime.datetime.now()}")
    assert datetime.datetime.now() != datetime.datetime(2023, 1, 1, 12, 0, 0)

    with freeze_time("2023-01-01 12:00:00") as frozen_datetime:
        print(f"コンテキストマネージャ内の固定時刻: {datetime.datetime.now()}")
        assert datetime.datetime.now() == datetime.datetime(2023, 1, 1, 12, 0, 0)
        # コンテキストマネージャは固定されたdatetimeオブジェクトを返す
        print(f"返された固定時刻オブジェクト: {frozen_datetime}")
        assert frozen_datetime == datetime.datetime(2023, 1, 1, 12, 0, 0)
        # 内部では time_to_freeze という属性でアクセスできる
        assert frozen_datetime.time_to_freeze == datetime.datetime(2023, 1, 1, 12, 0, 0)

    print(f"コンテキストマネージャを出た後の時刻: {datetime.datetime.now()}")
    # コンテキストマネージャを抜けると元の時間に戻る
    assert datetime.datetime.now() != datetime.datetime(2023, 1, 1, 12, 0, 0)

test_context_manager()

with 文を使うことで、そのブロック内(withブロック)でのみ時間が固定され、ブロックを抜けると元の時刻に戻ります。 これにより、テストコード内で時間の固定が必要な箇所を明確に区切ることができます。✅ また、as で受け取ったオブジェクト (frozen_datetime) は、固定された datetime オブジェクトそのものであり、テストのアサーションなどに利用できます。

コンテキストマネージャは、時間固定の影響範囲を限定したい場合に特に便利です。

なお、デコレータやコンテキストマネージャを使わずに、直接 start()stop() メソッドを呼び出す方法もあります。

import datetime
from freezegun import freeze_time

freezer = freeze_time("2012-01-14 12:00:01")

print(f"Start前の時刻: {datetime.datetime.now()}")
freezer.start()
print(f"Start後の固定時刻: {datetime.datetime.now()}")
assert datetime.datetime.now() == datetime.datetime(2012, 1, 14, 12, 0, 1)

# ... 何らかの処理 ...

freezer.stop()
print(f"Stop後の時刻: {datetime.datetime.now()}")
assert datetime.datetime.now() != datetime.datetime(2012, 1, 14, 12, 0, 1)

この方法は、より細かい制御が必要な場合や、テストのセットアップ・ティアダウン処理などで利用できます。

時間の移動と進行:ティック(tick)と移動(move_to) ➡️

freezegunは単に時間を固定するだけでなく、時間を進めたり、特定の日時にジャンプさせたりすることも可能です。 これにより、時間経過に伴う変化をテストできます。

デコレータやコンテキストマネージャに tick=True を指定すると、時間は指定した時刻からスタートしますが、その後は通常通り時間が経過します(ただし、実際の時間経過ではなく、time.sleep() などが呼び出されたり、内部的な処理が進むことによる時間の擬似的な進行です)。

import datetime
import time
from freezegun import freeze_time

@freeze_time("2023-11-01 10:00:00", tick=True)
def test_time_ticks():
    start_time = datetime.datetime.now()
    print(f"tick=True 開始時刻: {start_time}")

    # 時間がかかる処理をシミュレート
    time.sleep(5) # 実際には時間は経過しないが、freezegunが時間を進める

    end_time = datetime.datetime.now()
    print(f"tick=True 5秒後の時刻: {end_time}")

    # time.sleep(5) の呼び出しにより、内部時間が5秒進んでいるはず
    # (注意: 実際の経過時間とは異なる可能性がある)
    # freezegun 1.1.0以降、time.sleep() は自動的に時間を進めます
    assert end_time > start_time
    assert end_time == start_time + datetime.timedelta(seconds=5)

test_time_ticks()

tick=True は、処理の前後で時刻を比較するようなテストに役立ちます。 注意: 以前のバージョンでは tick=True だけでは time.sleep() で自動的に時間が進まない場合がありましたが、freezegun 1.1.0 (2021年3月頃リリース) 以降では、time.sleep() を呼び出すと内部時間が自動的に進むようになりました。

コンテキストマネージャの返すオブジェクト(または freeze_time インスタンス)の tick() メソッドを使うと、時間を手動で進めることができます。 引数に進めたい時間の timedelta オブジェクトを指定します。

import datetime
from freezegun import freeze_time

def test_manual_tick():
    with freeze_time("2024-02-10 15:00:00") as ft:
        print(f"手動tick 開始時刻: {datetime.datetime.now()}")
        assert datetime.datetime.now() == datetime.datetime(2024, 2, 10, 15, 0, 0)

        # 30秒進める
        ft.tick(delta=datetime.timedelta(seconds=30))
        print(f"手動tick 30秒後: {datetime.datetime.now()}")
        assert datetime.datetime.now() == datetime.datetime(2024, 2, 10, 15, 0, 30)

        # さらに1時間進める
        ft.tick(delta=datetime.timedelta(hours=1))
        print(f"手動tick さらに1時間後: {datetime.datetime.now()}")
        assert datetime.datetime.now() == datetime.datetime(2024, 2, 10, 16, 0, 30)

test_manual_tick()

tick() メソッドは、特定のイベント発生後に時間が経過した状況をシミュレートするのに便利です。

move_to() メソッドを使うと、時間を特定の絶対時刻にジャンプさせることができます。 引数には、freeze_time と同様に、日時文字列やdatetimeオブジェクトなどを指定できます。

import datetime
from freezegun import freeze_time

def test_move_to():
    with freeze_time("2025-03-20 09:00:00") as ft:
        print(f"move_to 開始時刻: {datetime.datetime.now()}")
        assert datetime.datetime.now() == datetime.datetime(2025, 3, 20, 9, 0, 0)

        # 昼休み時間に移動
        ft.move_to("2025-03-20 12:30:00")
        print(f"move_to 昼休み時間: {datetime.datetime.now()}")
        assert datetime.datetime.now() == datetime.datetime(2025, 3, 20, 12, 30, 0)

        # 退勤時間に移動 (datetimeオブジェクトで指定)
        ft.move_to(datetime.datetime(2025, 3, 20, 18, 0, 0))
        print(f"move_to 退勤時間: {datetime.datetime.now()}")
        assert datetime.datetime.now() == datetime.datetime(2025, 3, 20, 18, 0, 0)

test_move_to()

move_to() は、テストのシナリオに応じて特定の時点の状態を確認したい場合に役立ちます。

タイムゾーンの扱い 🌍

グローバルなアプリケーションを開発している場合など、タイムゾーンを考慮したテストが必要になることがあります。 freezegunはタイムゾーンの指定もサポートしています。

freeze_time の引数 tz_offset を使うと、UTCからのオフセット(時差)を指定できます。

import datetime
from freezegun import freeze_time

# UTC+9 (日本標準時 JST) をシミュレート
@freeze_time("2024-05-05 12:00:00", tz_offset=+9)
def test_jst_timezone():
    now = datetime.datetime.now() # ローカル時刻 (JST)
    utcnow = datetime.datetime.utcnow() # UTC時刻
    today = datetime.date.today() # ローカル日付

    print(f"JSTでの固定時刻 (now): {now}")
    print(f"対応するUTC時刻 (utcnow): {utcnow}")
    print(f"JSTでの今日の日付 (today): {today}")

    # now() は指定したタイムゾーンの時刻になる
    assert now == datetime.datetime(2024, 5, 5, 12, 0, 0)
    # utcnow() は対応するUTC時刻になる (12:00 JST -> 03:00 UTC)
    assert utcnow == datetime.datetime(2024, 5, 5, 3, 0, 0)
    # date.today() はローカルタイムゾーンの日付になる
    assert today == datetime.date(2024, 5, 5)

# UTC-5 (アメリカ東部標準時 EST) をシミュレート
@freeze_time("2024-05-05 12:00:00", tz_offset=-5)
def test_est_timezone():
    now = datetime.datetime.now() # ローカル時刻 (EST)
    utcnow = datetime.datetime.utcnow() # UTC時刻
    today = datetime.date.today() # ローカル日付

    print(f"\nESTでの固定時刻 (now): {now}")
    print(f"対応するUTC時刻 (utcnow): {utcnow}")
    print(f"ESTでの今日の日付 (today): {today}")

    # now() は指定したタイムゾーンの時刻になる
    assert now == datetime.datetime(2024, 5, 5, 12, 0, 0)
    # utcnow() は対応するUTC時刻になる (12:00 EST -> 17:00 UTC)
    assert utcnow == datetime.datetime(2024, 5, 5, 17, 0, 0)
    # date.today() はローカルタイムゾーンの日付になる
    assert today == datetime.date(2024, 5, 5)

test_jst_timezone()
test_est_timezone()

tz_offset を指定すると、datetime.datetime.now() はそのタイムゾーンでの時刻を返し、datetime.datetime.utcnow() は対応するUTC時刻を返します。 datetime.date.today() はローカルタイムゾーンに基づいた日付を返します。

これにより、異なるタイムゾーンでの挙動を正確にテストすることが可能です。 タイムゾーン起因のバグは発見が難しいことがあるため、freezegunのこの機能は非常に価値があります。💡

注意点: tz_offset で指定するのは単純なUTCからのオフセットです。夏時間(Daylight Saving Time)などの複雑なルールは自動的には考慮されません。もしタイムゾーン名(例: “America/New_York”)に基づいたより正確な挙動が必要な場合は、pytz などのライブラリと組み合わせて利用することを検討してください(ただし、freezegun自体がpytzに直接依存しているわけではありません)。freezegunの基本的なタイムゾーンサポートは、固定オフセットに基づいています。

高度な機能:より柔軟な時間操作 ✨

freezegunには、さらに高度で便利な機能がいくつか用意されています。

場合によっては、テスト対象のコード全体ではなく、特定のライブラリやモジュールに対してはfreezegunの影響を与えたくないことがあります。 例えば、外部APIとの通信を行うライブラリが内部で時刻を使用しており、そこは実際の時刻で動作してほしい、といったケースです。

ignore 引数にモジュール名の文字列リストを渡すことで、指定したモジュール配下での時刻関連関数の呼び出しをfreezegunのモック対象から除外できます。

import datetime
import time
import threading # 例としてthreadingモジュールをignoreしてみる
from freezegun import freeze_time

# freezegunのデフォルトignoreリストを確認 (参考)
# from freezegun.api import _freeze_time, TickingDateTime # プライベートAPIなので通常は使わない
# print(f"デフォルトのignoreリスト: {_freeze_time.ignore_lists}")
# 一般的なデフォルト: ['select', 'signal', 'socket', 'threading', 'multiprocessing', 'subprocess', 'celery'] (バージョンにより変動あり)

@freeze_time("2022-08-15 11:00:00", ignore=['time', 'my_custom_module'])
def test_ignore_modules():
    # freezegunが有効なdatetime
    frozen_dt = datetime.datetime.now()
    print(f"凍結されたdatetime: {frozen_dt}")
    assert frozen_dt == datetime.datetime(2022, 8, 15, 11, 0, 0)

    # ignoreされたtimeモジュール (実際の時刻が返るはず)
    real_time_t = time.time()
    print(f"無視されたtime.time(): {real_time_t} (実際のUnix時間)")
    # アサートは難しいのでコメントアウト (実行タイミングで変わるため)
    # assert real_time_t != datetime.datetime(2022, 8, 15, 11, 0, 0).timestamp()

    # ignoreされていない threading モジュール内の時間は凍結されるか?
    # (注: threading自体がデフォルトでignoreされることが多い。ここでは例として)
    # def thread_func():
    #     print(f"スレッド内の時刻: {datetime.datetime.now()}")
    # t = threading.Thread(target=thread_func)
    # t.start()
    # t.join()

# ignoreをコンテキストマネージャで使う例
def some_function_using_real_time():
    # この関数内では実際の時刻を使いたい
    print(f"some_function_using_real_time内の時刻: {time.time()}")
    return time.time()

def test_ignore_with_context():
    with freeze_time("2021-01-10") as ft:
        print(f"凍結時刻: {datetime.datetime.now()}")
        # timeモジュールを無視して関数を呼び出す
        with freeze_time(ft.time_to_freeze, ignore=['time']):
             real_timestamp = some_function_using_real_time()
        # コンテキストを抜けても、外側のfreeze_timeは有効
        print(f"再度凍結時刻: {datetime.datetime.now()}")
        assert datetime.datetime.now() == datetime.datetime(2021, 1, 10)

test_ignore_modules()
print("-" * 20)
test_ignore_with_context()

ignore オプションは、外部システムとの連携や、特定のライブラリの挙動を維持したい場合に非常に重要です。 デフォルトでいくつかのモジュール(threading, multiprocessing, select, socketなど)が無視リストに含まれている場合があります(バージョンによって異なります)。

注意: ignore の指定が期待通りに機能しないケースも報告されています。特に、google-cloudライブラリやsnowflake-connector-pythonなど、内部で複雑な認証や通信を行うライブラリでは、時刻の不整合によるエラーが発生することがあります。その場合は、ライブラリの依存関係も含めて ignore リストを調整するか、テスト戦略を見直す必要があるかもしれません。

デコレータを使用する際に as_arg=True (または as_kwarg='引数名') を指定すると、固定された時刻を表す FrozenDateTimeFactory オブジェクトがテスト関数の引数として渡されます。 このオブジェクトを通じて、固定された時刻にアクセスしたり、時間を操作したりできます。

import datetime
from freezegun import freeze_time

# as_arg=True の場合、デフォルトで 'frozen_time' という名前のキーワード引数で渡される
@freeze_time("2024-07-04 10:00:00", as_arg=True)
def test_receive_frozen_time_as_arg(frozen_time):
    print(f"関数内でアクセスする時刻: {datetime.datetime.now()}")
    print(f"引数で受け取った固定時刻オブジェクト: {frozen_time}")
    print(f"オブジェクトから取得した固定時刻: {frozen_time()}") # オブジェクトを呼び出すと固定時刻が返る

    assert datetime.datetime.now() == datetime.datetime(2024, 7, 4, 10, 0, 0)
    assert frozen_time() == datetime.datetime(2024, 7, 4, 10, 0, 0)
    assert frozen_time.time_to_freeze == datetime.datetime(2024, 7, 4, 10, 0, 0)

    # 引数オブジェクトを使って時間を進めることも可能
    frozen_time.tick(delta=datetime.timedelta(minutes=15))
    print(f"引数オブジェクトで時間を進めた後の時刻: {datetime.datetime.now()}")
    assert datetime.datetime.now() == datetime.datetime(2024, 7, 4, 10, 15, 0)

# as_kwarg で引数名を指定
@freeze_time("1999-12-31 23:59:55", as_kwarg='the_final_countdown')
def test_receive_frozen_time_as_kwarg(the_final_countdown):
    print(f"\nカスタム引数名での受け取り: {the_final_countdown.time_to_freeze}")
    assert the_final_countdown() == datetime.datetime(1999, 12, 31, 23, 59, 55)

    # 時間を進めて年越しをシミュレート
    the_final_countdown.tick(delta=datetime.timedelta(seconds=10))
    print(f"年越し後の時刻: {datetime.datetime.now()}")
    assert datetime.datetime.now() == datetime.datetime(2000, 1, 1, 0, 0, 5)


test_receive_frozen_time_as_arg()
test_receive_frozen_time_as_kwarg()

この機能は、テスト関数内で固定時刻自体を参照したり、テストの途中で時間を動的に操作したい場合に便利です。コンテキストマネージャの as で受け取るオブジェクトと同様の操作が可能です。

auto_tick_seconds 引数を指定すると、時刻関連関数 (now(), today() など) が呼び出されるたびに、指定した秒数だけ自動的に時間が進みます。 これは、呼び出しごとに少しずつ時間が経過するような状況をシミュレートするのに役立ちます。

import datetime
from freezegun import freeze_time

@freeze_time("2025-01-01 00:00:00", auto_tick_seconds=5)
def test_auto_tick():
    time1 = datetime.datetime.now()
    print(f"自動tick 1回目の呼び出し: {time1}")
    assert time1 == datetime.datetime(2025, 1, 1, 0, 0, 0)

    # 何か処理をする (ここでは何もしない)

    time2 = datetime.datetime.now()
    print(f"自動tick 2回目の呼び出し: {time2} (5秒進んでいるはず)")
    assert time2 == datetime.datetime(2025, 1, 1, 0, 0, 5)

    time3 = datetime.datetime.now()
    print(f"自動tick 3回目の呼び出し: {time3} (さらに5秒進んでいるはず)")
    assert time3 == datetime.datetime(2025, 1, 1, 0, 0, 10)

    # date.today() を呼んでも進む
    today1 = datetime.date.today()
    print(f"自動tick date.today() 呼び出し後: {datetime.datetime.now()}")
    assert datetime.datetime.now() == datetime.datetime(2025, 1, 1, 0, 0, 15)

test_auto_tick()

注意: auto_tick_seconds が指定されている場合、tick=True 引数は無視されます。 また、どの関数呼び出しが時間を進めるトリガーになるかを意識する必要があります。

固定する時刻を、関数 (lambda含む) やジェネレータを使って動的に決定することも可能です。

import datetime
from freezegun import freeze_time

# lambda関数で常に現在から1日前の時刻を返すようにする
@freeze_time(lambda: datetime.datetime.now() - datetime.timedelta(days=1))
def test_freeze_yesterday_dynamically():
    # このテストを実行する実際の時刻によって、固定される時刻が変わる
    frozen_time = datetime.datetime.now()
    real_now = datetime.datetime.now() # これはlambda内で評価される実際の時刻
    # 正確なアサートは難しいが、1日前の日付になっているはず
    print(f"動的に固定された過去の時刻: {frozen_time}")
    # assert frozen_time.date() == (datetime.date.today() - datetime.timedelta(days=1))

# ジェネレータで呼び出すたびに異なる日時を返す
def datetime_generator():
    yield datetime.datetime(2020, 1, 1)
    yield datetime.datetime(2021, 1, 1)
    yield datetime.datetime(2022, 1, 1)

def test_freeze_with_generator():
    gen = datetime_generator()
    with freeze_time(gen):
        dt1 = datetime.datetime.now()
        print(f"ジェネレータ1回目: {dt1}")
        assert dt1 == datetime.datetime(2020, 1, 1)

    with freeze_time(gen):
        dt2 = datetime.datetime.now()
        print(f"ジェネレータ2回目: {dt2}")
        assert dt2 == datetime.datetime(2021, 1, 1)

    with freeze_time(gen):
        dt3 = datetime.datetime.now()
        print(f"ジェネレータ3回目: {dt3}")
        assert dt3 == datetime.datetime(2022, 1, 1)

    # 次に呼び出すと StopIteration が発生する
    try:
        with freeze_time(gen):
            pass
    except StopIteration:
        print("ジェネレータが終了しました。")

test_freeze_yesterday_dynamically()
print("-" * 20)
test_freeze_with_generator()

関数やジェネレータを使うことで、テスト実行時の状況に応じて固定する時刻を変えたり、一連のテストで異なる時刻を順番に適用したりといった、より複雑なシナリオに対応できます。

他のテストフレームワークとの連携 🤝

freezegunは、Pythonの主要なテストフレームワークとスムーズに連携できます。

pytestでは、デコレータやコンテキストマネージャをそのまま使用できます。 さらに、pytest-freezegun という専用のプラグインを使うと、フィクスチャ (freezer) を使ってよりpytestらしい方法で時間を操作できます。

まず、プラグインをインストールします。

pip install pytest-freezegun

そして、テストコードで freezer フィクスチャを使用します。 このフィクスチャが注入されると、そのテスト関数の実行中はデフォルトで時間が凍結されます (通常はテスト開始時の時刻)。 freezer オブジェクトは freezegun.api.FrozenDateTimeFactory のインスタンスであり、move_to()tick() メソッドを使って時間を操作できます。

# test_pytest_integration.py (pytestで実行するファイル)
import pytest
import datetime
import time
# pytest-freezegun プラグインがインストールされていれば、freezerフィクスチャが使える
# from freezegun import freeze_time # デコレータも併用可能

def get_greeting():
    current_hour = datetime.datetime.now().hour
    if 5 <= current_hour < 12:
        return "おはよう!☀️"
    elif 12 <= current_hour < 18:
        return "こんにちは!😊"
    else:
        return "こんばんは!🌙"

# freezerフィクスチャを使う例
def test_greeting_morning(freezer):
    # 特定の時刻に移動
    freezer.move_to("2024-08-10 09:00:00")
    assert get_greeting() == "おはよう!☀️"
    print(f"(test_greeting_morning) 固定時刻: {datetime.datetime.now()}")

def test_greeting_afternoon(freezer):
    freezer.move_to("2024-08-10 14:30:00")
    assert get_greeting() == "こんにちは!😊"
    print(f"(test_greeting_afternoon) 固定時刻: {datetime.datetime.now()}")

def test_greeting_evening(freezer):
    freezer.move_to("2024-08-10 21:00:00")
    assert get_greeting() == "こんばんは!🌙"
    print(f"(test_greeting_evening) 固定時刻: {datetime.datetime.now()}")

# デコレータを使う例 (pytest-freezegunなしでも動作)
from freezegun import freeze_time

@freeze_time("2024-09-01 07:15:00")
def test_greeting_morning_decorator():
     assert get_greeting() == "おはよう!☀️"
     print(f"(test_greeting_morning_decorator) 固定時刻: {datetime.datetime.now()}")

# freezer フィクスチャで時間の進行をテストする例
def test_time_advance_with_freezer(freezer):
    freezer.move_to("2023-01-01 12:00:00")
    start_time = datetime.datetime.now()
    print(f"(test_time_advance_with_freezer) 開始時刻: {start_time}")

    # freezerオブジェクトのtickで時間を進める
    freezer.tick(delta=datetime.timedelta(minutes=10))

    end_time = datetime.datetime.now()
    print(f"(test_time_advance_with_freezer) 10分後の時刻: {end_time}")
    assert end_time == start_time + datetime.timedelta(minutes=10)

# 実行コマンド: pytest test_pytest_integration.py

pytest-freezegun プラグインを使うと、テストコードがよりシンプルになり、pytestのフィクスチャシステムを最大限に活用できます。 また、@pytest.mark.freeze_time('2023-10-27') のようにマーカーを使って時間を固定することも可能です。

補足: pytestでは、autouse=True を指定したフィクスチャを conftest.py に定義することで、全てのテストに対して自動的に時間を固定することも可能です。

# conftest.py
import pytest
from freezegun import freeze_time
import datetime

# プロジェクト全体で時間を固定したい場合 (例: 2023-01-01)
# @pytest.fixture(autouse=True)
# def global_frozen_time():
#     with freeze_time("2023-01-01") as ft:
#         yield ft

# autouse=True を使う場合、個別のテストで時間を変更するには注意が必要
# freezer フィクスチャ (pytest-freezegun) との併用も可能だが、
# どちらの固定時刻が優先されるかなどを確認する必要がある。

標準ライブラリの unittest フレームワークでは、主にデコレータを使用します。 テストクラス全体にデコレータを適用すると、そのクラス内のすべてのテストメソッド (test_*) およびセットアップ・ティアダウンメソッド (setUp, tearDown, setUpClass, tearDownClass) で時間が固定されます。 個々のテストメソッドにのみデコレータを適用することも可能です。

import unittest
import datetime
from freezegun import freeze_time

# クラス全体に適用
@freeze_time("2022-11-10 15:30:00")
class MyUnittestClass(unittest.TestCase):
    @classmethod
    def setUpClass(cls):
        print(f"\nsetUpClassでの時刻: {datetime.datetime.now()}")
        cls.class_setup_time = datetime.datetime.now()

    def setUp(self):
        print(f"setUpでの時刻: {datetime.datetime.now()}")
        self.setup_time = datetime.datetime.now()

    def test_method_one(self):
        print(f"test_method_oneでの時刻: {datetime.datetime.now()}")
        self.assertEqual(datetime.datetime.now(), datetime.datetime(2022, 11, 10, 15, 30, 0))
        self.assertEqual(self.setup_time, datetime.datetime(2022, 11, 10, 15, 30, 0))
        # setUpClassも同じ時刻のはず
        self.assertEqual(self.class_setup_time, datetime.datetime(2022, 11, 10, 15, 30, 0))

    def test_method_two(self):
        print(f"test_method_twoでの時刻: {datetime.datetime.now()}")
        self.assertEqual(datetime.datetime.now(), datetime.datetime(2022, 11, 10, 15, 30, 0))

    def tearDown(self):
        print(f"tearDownでの時刻: {datetime.datetime.now()}")

    @classmethod
    def tearDownClass(cls):
        print(f"tearDownClassでの時刻: {datetime.datetime.now()}")

# メソッド個別に適用
class AnotherUnittestClass(unittest.TestCase):
    def test_without_freeze(self):
        # ここでは時間は固定されない
        print(f"\n固定なしのテストメソッド: {datetime.datetime.now()}")
        pass # アサートなし

    @freeze_time("2020-02-29 10:00:00") # うるう年!
    def test_with_freeze(self):
        print(f"個別固定のテストメソッド: {datetime.datetime.now()}")
        self.assertEqual(datetime.datetime.now(), datetime.datetime(2020, 2, 29, 10, 0, 0))

# unittestの実行 (通常はテストランナー経由で実行)
# suite = unittest.TestSuite()
# suite.addTest(unittest.makeSuite(MyUnittestClass))
# suite.addTest(unittest.makeSuite(AnotherUnittestClass))
# runner = unittest.TextTestRunner()
# runner.run(suite)

unittestにおいても、freezegunはシームレスに統合され、時間依存テストの実装を容易にします。

ユースケース例:どんな時に役立つ? 🎯

freezegunが具体的にどのような場面で役立つのか、いくつかのユースケースを見てみましょう。

  • 有効期限のテスト:
    • クーポンの有効期限切れチェック
    • セッションやトークンの有効期限切れ処理の検証
    • 会員資格の有効期限確認
    • 証明書の有効期限 (発行前、有効期間中、期限切れ後など) のテスト
    • import datetime
      from freezegun import freeze_time
      
      def is_coupon_valid(coupon_code, expiration_date_str):
          expiration_date = datetime.datetime.strptime(expiration_date_str, "%Y-%m-%d").date()
          return datetime.date.today() <= expiration_date
      
      @freeze_time("2023-12-10")
      def test_coupon_valid():
          assert is_coupon_valid("XMAS23", "2023-12-25") == True
      
      @freeze_time("2023-12-26")
      def test_coupon_expired():
          assert is_coupon_valid("XMAS23", "2023-12-25") == False
      
      @freeze_time("2023-12-25")
      def test_coupon_valid_on_last_day():
          assert is_coupon_valid("XMAS23", "2023-12-25") == True
      
  • 定期実行タスク (バッチ処理) のテスト:
    • 月末や月初に実行されるレポート生成処理
    • 毎日深夜に行われるデータ集計処理
    • 毎週特定の曜日に実行される通知処理
    • import datetime
      from freezegun import freeze_time
      
      def should_run_weekly_report():
          # 日曜日にレポートを実行
          return datetime.date.today().weekday() == 6 # Sunday is 6
      
      @freeze_time("2023-10-29") # 日曜日
      def test_run_report_on_sunday():
          assert should_run_weekly_report() == True
      
      @freeze_time("2023-10-28") # 土曜日
      def test_dont_run_report_on_saturday():
           assert should_run_weekly_report() == False
      
  • キャッシュの有効期限テスト:
    • キャッシュが正しく期限切れになり、再取得されるかの確認
    • import datetime
      import time
      from freezegun import freeze_time
      
      cache = {}
      CACHE_EXPIRATION_SECONDS = 60 # 1分
      
      def get_data_with_cache(key):
          now = time.monotonic() # freezegunはmonotonicもfreezeする
          if key in cache:
              data, timestamp = cache[key]
              if now - timestamp < CACHE_EXPIRATION_SECONDS:
                  print(f"Cache hit for key: {key}")
                  return data
              else:
                  print(f"Cache expired for key: {key}")
          # データを取得 (ここではダミー)
          print(f"Fetching data for key: {key}")
          data = f"Data for {key} fetched at {datetime.datetime.now()}"
          cache[key] = (data, now)
          return data
      
      def test_cache_expiration():
           with freeze_time("2024-01-15 10:00:00") as ft:
              key = "mydata"
              # 1回目: キャッシュなし -> 取得
              data1 = get_data_with_cache(key)
              assert "fetched at 2024-01-15 10:00:00" in data1
      
              # 2回目: キャッシュ有効期間内 -> キャッシュヒット
              ft.tick(delta=datetime.timedelta(seconds=30))
              data2 = get_data_with_cache(key)
              assert data1 == data2 # 同じデータが返る
      
              # 3回目: キャッシュ期限切れ -> 再取得
              ft.tick(delta=datetime.timedelta(seconds=40)) # 合計70秒経過
              data3 = get_data_with_cache(key)
              assert data1 != data3 # 違うデータが返る
              assert "fetched at 2024-01-15 10:01:10" in data3 # 再取得時の時刻
      
  • 時間経過による状態変化のテスト:
    • 一定時間操作がない場合の自動ログアウト処理
    • 時間によって表示内容が変わるUIコンポーネント
    • 予約されたアクションが指定時刻に実行されるかの確認
  • 時刻に依存するロギングや監査証跡のテスト:
    • ログメッセージに正しいタイムスタンプが付与されているか
    • 監査ログの時刻が正確か
  • タイムゾーン依存のロジックテスト:
    • ユーザーのタイムゾーンに合わせて表示を切り替える機能
    • 異なるタイムゾーン間での時刻計算
    • 特定のタイムゾーンの祝日や営業時間を考慮する処理
    • import datetime
      from freezegun import freeze_time
      # pytzは別途インストールが必要: pip install pytz
      import pytz # タイムゾーン名を扱う例
      
      def get_local_time_str(timezone_name):
          try:
              tz = pytz.timezone(timezone_name)
              local_time = datetime.datetime.now(tz)
              return local_time.strftime("%Y-%m-%d %H:%M:%S %Z%z")
          except pytz.UnknownTimeZoneError:
              return "Unknown Timezone"
      
      # UTCの正午を基準にテスト
      @freeze_time("2024-12-25 12:00:00")
      def test_timezone_display():
          # tz_offsetを使わない場合、now()はnaive datetimeになるためpytzが必要
          # freezegun単体でaware datetimeを扱うには tz_offset を使う方がシンプル
          # ここでは例としてpytzと組み合わせる
      
          # ニューヨーク (UTC-5、冬時間)
          ny_time_str = get_local_time_str("America/New_York")
          print(f"NY Time: {ny_time_str}")
          assert ny_time_str == "2024-12-25 07:00:00 EST-0500"
      
          # 東京 (UTC+9)
          tokyo_time_str = get_local_time_str("Asia/Tokyo")
          print(f"Tokyo Time: {tokyo_time_str}")
          assert tokyo_time_str == "2024-12-25 21:00:00 JST+0900"
      
          # ロンドン (UTC+0、冬時間)
          london_time_str = get_local_time_str("Europe/London")
          print(f"London Time: {london_time_str}")
          assert london_time_str == "2024-12-25 12:00:00 GMT+0000"
      

これらの例のように、freezegunは時間に関わるあらゆるテストシナリオにおいて、開発者の強力な味方となります。💪

注意点とベストプラクティス ⚠️💡

freezegunは非常に便利なライブラリですが、利用にあたっていくつか注意点と、より効果的に使うためのベストプラクティスがあります。

  • `ignore` の適切な使用: 前述の通り、外部ライブラリやシステムコール(特にネットワーク通信やファイルI/Oに関わるもの)が内部で時刻に依存している場合、予期せぬ動作やエラーを引き起こす可能性があります。SSL証明書の検証失敗などが典型例です。必要に応じて ignore オプションを使い、影響範囲をコントロールしましょう。どのモジュールを無視すべきか判断が難しい場合もあります。
  • パフォーマンスへの影響: freezegunはPythonの時刻関連関数をモックするため、わずかながらオーバーヘッドが発生します。通常は問題になるレベルではありませんが、非常に多数のテストを実行する場合や、パフォーマンスがクリティカルなテストでは意識しておくと良いでしょう。
  • テストの可読性: 時間を固定したり移動させたりする操作は、テストコードを複雑にする可能性があります。なぜその時刻に固定しているのか、なぜ時間を進めているのかが明確になるように、テストメソッド名やコメントで意図を説明することが重要です。コンテキストマネージャを使って、時間操作の影響範囲を局所化するのも良い方法です。
  • 多用しすぎない: 多くのテストでfreezegunが必要になる場合、それはテスト対象のコードが時間に強く依存しすぎている(密結合)サインかもしれません。可能であれば、時刻を外部から注入できるようにリファクタリングするなど、時間への依存度を下げる設計を検討することも有効です。
  • `tick=True` と `time.sleep()` の挙動: freezegun 1.1.0以降、time.sleep() は時間を自動的に進めますが、それ以前のバージョンでは手動で tick() を呼び出す必要がありました。また、tick=True の場合でも、CPU負荷の高い処理が長時間続いても、その処理時間分だけ時間が自動で進むわけではない点に注意が必要です(あくまで sleep や明示的な tick() で時間が進みます)。
  • タイムゾーンの扱い: tz_offset は固定オフセットであり、夏時間などを考慮しません。複雑なタイムゾーンルールが必要な場合は、pytz や Python 3.9 以降の zoneinfo と組み合わせて使うことを検討しましょう。
  • 本番環境での利用は避ける: freezegunはあくまでテスト用のライブラリです。本番環境のコードにfreezegunを含めたり、本番環境で時間を操作したりすることは、予期せぬ重大な問題を引き起こす可能性があるため、絶対に避けるべきです。テスト用の依存関係として管理しましょう。
ヒント: テストコードが読みやすくなるよう、固定する日時には意味のある日付(例: `VALID_DATE = “2024-01-10″`, `EXPIRED_DATE = “2023-12-31″`)や変数名を使い、マジックナンバーを避けましょう。

まとめ:時間を制してテストを加速! ⚡

Pythonライブラリ freezegun は、テストにおける時間依存性の問題を解決するための強力で使いやすいツールです。 デコレータやコンテキストマネージャを使って簡単に時間を固定・操作でき、tickmove_to で時間の経過をシミュレートしたり、tz_offset でタイムゾーンを扱ったりすることも可能です。

freezegunを活用することで、

  • ✅ 時間依存コードのテストの信頼性再現性が向上する
  • ✅ 境界値(有効期限切れの瞬間など)のテストが容易になる
  • ✅ 開発サイクルがスピードアップする

というメリットがあります。 もしあなたのプロジェクトで時間に関わるテストに苦労しているなら、ぜひfreezegunの導入を検討してみてください。きっと、テストコードの記述がより快適で効率的になるはずです! 😉

Happy Time Traveling Testing! 🚀⏱️🎉

コメント

タイトルとURLをコピーしました