8
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

Pythonでユニットテストしてたら突如こんなエラーが。

TypeError: can't set attributes of built-in/extension type 'datetime.datetime'

Pythonでシステム日時 datetime.datetime.now() を扱う関数のユニットテストを実行したときに現れました。

理由は実は、このシステム日時の取得関数が標準のunittestではモックできない関数だったからでした。

今回はなんとしてもこの関数をユニットテストするために考えた方法3選をご紹介します。

背景

システム日時をモックしたい! 

こんな関数をユニットテストしたいとき、あなたならどうしますか?

def is_past(time: datetime.datetime) -> bool:
    """
    与えられた時刻がシステム日時より前かどうかを返却
    """
    return time < datetime.datetime.now()

datetime.datetime.now()はシステム日時を取得する関数で、実行したときの現在時刻を取得します。
それを使ってこの関数では与えられたdatetimeが現在時刻より前かどうかをbool値で返却しています。

ユニットテストで実行日時によってテスト結果が変わってしまったら大変です。
このdatetime.datetime.now()をモックしたいと思って、こんなテストコードを書きました。

datetime_now_test.py
import unittest
from unittest.mock import patch

class TestSample(unittest.TestCase):
    def test_case_1(self):
        """
        2022/12/25 は 2022/12/31 よりも過去
        """
        target_time = datetime.datetime(2022, 12, 31, 0, 0, 0)
        with patch('datetime.datetime.now', return_value=datetime.datetime(2022, 12, 25, 0, 0, 0)):
            self.assertFalse(is_past(target_time))

システム日時の値を2022年の12月25日にずっと固定して、2023年になっても2154年になっても1925年にタイムスリップしてもユニットテストの結果を変えたくないのです。

結果は TypeError

でも上のテストを実行すると、冒頭に出したようなエラーが出てきてしまいました。

テスト実行
$ python3 -m unittest datetime_now_test.py
E
======================================================================
ERROR: test_case_1 (datetime_now_test.TestSample)
----------------------------------------------------------------------
...
TypeError: can't set attributes of built-in/extension type 'datetime.datetime'
...
FAILED (errors=1)

調べてみると、どうやらこのdatetime.datetime.now()関数がCから拡張されている「モックできない」関数なのが原因で怒られているみたいです。
標準のunittest.mockだと、純粋なPythonオブジェクトのモックしかサポートしていないんですね。

datetime.datetime.now()をモックする方法3選

そっかぁ。モックできないかぁ。じゃあテストしなくていいや。
というわけにはいかないので、なんとかこいつをユニットテストする方法を考えたのでご紹介します。

テストツールを変えてみる

さっきのエラーメッセージで検索すると1番に出てくるのがpytestの記事。

pytestは標準のユニットテストの上位互換で、Pythonのテストフレームワークとしてはむしろこちらが主流になっています。
標準のunittestモジュールよりもPythonっぽいシンプルなテストができると評判です。

上で書いたように、Pythonの標準のunittestではCから拡張されたタイプなどはサポートしていません。
一方でpytestを使うと、monkeypatchという機能を使って無理矢理にでもdatetimeのモックを注入することができます。

テストコードを書いてみるとこんな感じ。

datetime_now_test.py
def test_case_1(monkeypatch):
    datetime_mock = MagicMock(wraps=datetime.datetime)
    datetime_mock.now.return_value = datetime.datetime(2022, 12, 25, 0, 0, 0)
    monkeypatch.setattr(datetime, "datetime", datetime_mock)

    target_time = datetime.datetime(2022, 12, 31, 0, 0, 0)

    assert not is_past(target_time)
テスト実行
$ pytest -s datetime_now_test.py
================================================================================================================================== test session starts ==================================================================================================================================
...
collected 1 item                                                                                                                                                                                                                                                                        

datetime_now_test.py .

=================================================================================================================================== 1 passed in 0.01s ===================================================================================================================================
$    

テストクラスを準備しなくていい分コードもわかりやすくなりました。
この方法のデメリットは別途pytestモジュールをインストールしないといけないことで、主流のフレームワークとはいえチーム開発なんかで標準のユニットテストを使うように決まっている場合なんかには難しいかもしれません。

ライブラリでドーピング

テストツールは変えたくない!でもユニットテストは作らないと!
そんなときにdatetimeを固定しちゃうライブラリを2つほど見つけたのでご紹介します。

freezegun

freezegunはPythonのテストで時刻を固定できる画期的なライブラリです。
FreezeGun: Let your Python tests travel through time | GitHub

テストコードにこんな感じで書いて使います。

datetime_now_test.py
...
import freezegun

class TestSample(unittest.TestCase):
    @freezegun.freeze_time('2022-12-25')
    def test_case_1(self):
        target_time = datetime.datetime(2022, 12, 31, 0, 0, 0)
        self.assertFalse(is_past(target_time))

実行は標準のユニットテストの方法で問題なし!

テスト実行
$ python3 -m unittest datetime_now_test.py
.
----------------------------------------------------------------------
Ran 1 test in 0.006s

OK
$  

time-machine

time-machineも、freezegunと同じく時刻を固定できるライブラリです。
time-machine 2.8.2 | PyPI

ちゃんと比較はしてないですが、機能もfreezegunとそんなに変わらなそう。

テストコードを書くと

datetime_now_test.py
class TestSample(unittest.TestCase):
    @time_machine.travel(datetime.datetime(2022, 12, 25))
    def test_case_1(self):
        target_time = datetime.datetime(2022, 12, 31, 0, 0, 0)
        self.assertFalse(is_past(target_time))

こちらもテスト方法は変わりません。

テスト実行
$ python3 -m unittest datetime_now_test.py
.
----------------------------------------------------------------------
Ran 1 test in 0.000s

OK
$

設計を変える

テストツールは絶対に標準unittestがいい!
pip installするのもやだ!

そんなあなたのためにプログラムの設計を変えてなんとかする方法を2つ考えました。

ラッパー関数をつくる

ひとつ目はdatetime.datetime.now()をラップする関数を作るやり方です。
冒頭のコード

def is_past(time: datetime.datetime) -> bool:
    """
    与えられた時刻がシステム日時より前かどうかを返却
    """
    return time < datetime.datetime.now()

ここにdatetime.now()を返却するだけの関数を作って、直接モックしなくてもいいようにしましょう。

def is_past(time: datetime.datetime) -> bool:
    """
    与えられた時刻がシステム日時より前かどうかを返却
    """
    return time < get_current_datetime()

def get_current_datetime() -> datetime.datetime:
    return datetime.datetime.now()

なんのトリッキーなこともありません。モックする関数を自分で作ったやつに変えるだけです。

datetime_now_test.py
class TestSample(unittest.TestCase):
    def test_case_1(self):
        target_time = datetime.datetime(2022, 12, 31, 0, 0, 0)
        with patch('get_current_datetime', return_value=datetime.datetime(2022, 12, 25, 0, 0, 0)):
            self.assertFalse(is_past(target_time))

datetime.now()をモックする必要もないので、難しいテスト実装にはなりません。
デメリットは、もし元の関数が複数箇所で関数を呼び出していたりすると関数の分離コストが高くつく可能性があることです。

依存性を注入できるようにする

もうひとつは依存性を注入できる設計にしておくことです。
冒頭のコード

def is_past(time: datetime.datetime) -> bool:
    """
    与えられた時刻がシステム日時より前かどうかを返却
    """
    return time < datetime.datetime.now()

このシステム日時を取得する関数を外部から注入できるようにしてやります。

def is_past(time: datetime.datetime, get_current_datetime = datetime.datetime.now) -> bool:
    """
    与えられた時刻がシステム日時より前かどうかを返却
    """
    return time < get_current_datetime()

この実装だと引数で関数オブジェクトをとりますが、特に指定されなければdatetime.now()が入ります。

テストコードで指定した日時を返すようなラムダ式を注入してやれば、テストのときだけ指定した日にするような都合のいい扱いができます。

datetime_now_test.py
class TestSample(unittest.TestCase):
    def test_case_1(self):
        target_time = datetime.datetime(2022, 12, 31, 0, 0, 0)
        self.assertFalse(is_past(target_time, lambda: datetime.datetime(2022, 12, 25, 0, 0, 0)))

この方法だとそもそもdatetime.now()をモックしなくて良くなり楽になります。
デメリットは、引数にこれを入れ続けないといけないのでインターフェースが複雑になる場合があることです。

まとめ

今回はdatetime.datetime.now()をモックするとかいう超ニッチなケースに絞ってやりくりの方法をご紹介しました。

ポイントは

  • 標準テストツールが使えるか
  • 追加でライブラリのインストールが必要か
  • 実装に変更を入れる必要があるか

あたりでしょうか。

紹介した方法の長短まとめるとこんな感じです。

方法 標準テストツールで実行 追加でライブラリのインストール 実装の変更
pytestを使う できない あり なし
freezegunを使う できる あり なし
time-machineを使う できる あり なし
ラッパー関数を作る できる なし あり
依存性注入する できる なし あり

C拡張の関数をどうしてもモックしたい!!って思い立った時にはぜひご検討ください。

今回書いた以外にも「こんな方法あるんちゃう?」ってものがあったら、コメントいただけると勉強になります!

お読みいただきありがとうございます。
それではみなさん、メリークリスマス🎅🎄

8
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
8
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?