Pythonで時刻周りのテストをコード化する

# 1. はじめに
時刻に関するテストは手動で実施していると`sysdate`を変更したりと何かと面倒臭いうえに、バグが混入しやすい。
そこでテストをコード化してもっと簡単にテスト実行し、品質もあげられないか試してみた。

# 2. 実行環境、ツール
実行環境は以下

```
$ sw_vers
ProductName:	Mac OS X
ProductVersion:	10.15.1
BuildVersion:	19B88

$ python --version
Python 3.7.4
```

また、時刻周りを簡単にテストするにあたり[freezegun](https://github.com/spulec/freezegun)というパッケージを使ってみる。
この`freezegun`というパッケージを使うと`Python`の標準ライブラリの`datetime`から得られる現在時刻を指定したものに差し替えることができる。

# 3. freezegunを使う
時刻周りの処理をテストをするときは、当然ながら色々な時刻を使ってテストがしたい。 とはいえ、そのためだけにシステムの時刻を変更しながらテストを走らせるのも面倒臭い。
そこで`freezegun`を使うと、そういった手間がかからなくなる。 
パッケージのインストールは pip コマンドから。

```
$ pip install freezegun
```

# 4. 特定の時刻を返すようにする
`freezegun`では`freezegun.freeze_time()`で`datetime`モジュールの関数が特定の時刻を返すように置き換えることができる。
サンプルコードでは`datetime.now()`の内容を見ている。

```app_freezegun.py
#!/usr/bin/env python
# -*- coding: utf-8 -*-

import freezegun
from datetime import datetime


def main():
    # freezegunを使用して現在時刻を置換して表示
    freezer = freezegun.freeze_time('2015-10-21')
    freezer.start()
    try:
        print("freezegun:" + str(datetime.now()))
    finally:
        freezer.stop()
    
    # 現在時刻を表示
    print("nowtime:" + str(datetime.now()))


if __name__ == '__main__':
    main()
```
上記を実行する。

```
$ python app_freezegun.py
freezegun:2015-10-21 00:00:00
nowtime:2019-12-14 10:16:49.847317
```
freezegunを使用している間の現在時刻が置換された事がわかる。


# 5. デコレータとして使用する
次にAPIをデコレータとして使うパターン。 デコレータで修飾された関数の中でのみ時刻が置き換わる。 

```app_freezegun.py 
#!/usr/bin/env python
# -*- coding: utf-8 -*-

import freezegun
from datetime import datetime

# freezegunを使用して現在時刻を置換して表示
@freezegun.freeze_time('2015-10-21')
def main():
    print("freezegun:" + str(datetime.now()))

# 現在時刻を表示
def main_2():
    print("nowtime:" + str(datetime.now()))


if __name__ == '__main__':
    main()
    main_2()
```

上記を実行する。

```
$ python app_freezegun.py
freezegun:2015-10-21 00:00:00
nowtime:2019-12-14 10:16:49.847317
```
デコレータで修飾された関数の中の現在時刻が置換された事がわかる。
もちろん時刻まで指定することもできる。

```app_freezegun.py 
#!/usr/bin/env python
# -*- coding: utf-8 -*-

import freezegun
from datetime import datetime

# freezegunを使用して現在時刻を置換
@freezegun.freeze_time('2015-10-21 12:34:56')
def main():
    print("freezegun:" + str(datetime.now()))


if __name__ == '__main__':
    main()
```
結果は以下の通り。

```
$ python app_freezegun.py 
freezegun:2015-10-21 12:34:56
```

# 6. コンテキストマネージャとして使用する
先ほどはデコレータを使って時刻を指定した。 同じAPIはコンテキストマネージャとしても使うことができる。
以下はコンテキストマネージャとして使ったパターン。 この場合は、コンテキストマネージャのブロック内でだけ時刻が置き換わる。

```app_freezegun.py
#!/usr/bin/env python
# -*- coding: utf-8 -*-

import freezegun
from datetime import datetime


def main():
    # freezegunを使用して現在時刻を置換して表示
    with freezegun.freeze_time('2015-10-21'):
        print("freezegun:" + str(datetime.now()))

    # 現在時刻を表示
    print("nowtime:" + str(datetime.now()))


if __name__ == '__main__':
    main()

```

結果は【4.】と同じなので省略

# 7. 特定の時刻から時間をずらしていく
テストをするときは一旦基準となる時刻に規正してから、特定の処理をした後に時刻をずらしたいというニーズもあると思う。`freezegun`を使い試してみる。

次のサンプルコードでは`tick()`を使って時刻を`timedelta`オブジェクトの分だけずらしたり`move_to()`を使って時刻を切り替えている。

```app_freezegun.py
#!/usr/bin/env python
# -*- coding: utf-8 -*-

import freezegun
from datetime import datetime
from datetime import timedelta


def main():
    with freezegun.freeze_time('2015-10-21 00:00:00') as freeze_datetime:
        print(datetime.now())

        # 時間を 1 秒進める
        freeze_datetime.tick()
        print(datetime.now())

        # 時間を 1 分進める
        freeze_datetime.tick(delta=timedelta(minutes=1))
        print(datetime.now())

        # 特定の時間に移す
        freeze_datetime.move_to('2019-01-01 00:00:00')
        print(datetime.now())


if __name__ == '__main__':
    main()
```
上記を実行する。

```
$ python app_freezegun.py 
2015-10-21 00:00:00
2015-10-21 00:00:01
2015-10-21 00:01:01
2019-01-01 00:00:00
```

# 8. ユニットテストに組み込んでみる
色々検証したがここからが本番。
`freezegun`を`unittest`で使用する。
自動で`datetime`を使用して作成するものをテストしたいので、AWSのEC2をmockする。
まずはアプリを用意する。
作成されたEC2を取得してアプリの実行日時よりも古いものを取得し、インスタンス名を返却する。

```app_freezegun.py

#!/usr/bin/env python
# -*- coding: utf-8 -*-
import boto3
from datetime import datetime, timedelta, timezone


def main():
    # 現在時刻を取得
    now = datetime.now(timezone.utc)
    # インスタンス名を配列で初期化
    instace_names = []

    client = boto3.client('ec2')

    # 全てのインスタンス情報を取得する。
    instances = client.describe_instances()

    # 実行日以前に作成されたインスタンスを取得する。
    for instance_list in instances.get('Reservations'):
        for instance in instance_list.get('Instances'):
            if now > instance.get('LaunchTime'):

                # インスタンス名を抽出する。
                instace_names.append(instance.get('KeyName'))

    return instace_names
```

次にテストコード。
`freezegun`を使用して作成日時の違うEC2を4台mockする。

```test_freezegun.py
#!/usr/bin/env python
# -*- coding: utf-8 -*-
import unittest
import boto3
from moto import mock_ec2
import freezegun
from datetime import datetime, timedelta
from app import app_freezegun as app


class MyTestCase(unittest.TestCase):
    @mock_ec2
    def test_case_1(self):
        with freezegun.freeze_time('2017-01-01 00:00:00') as freeze_datetime:
            # 作成日付2017-01-01でEC2を作成
            self.__common('test_ec2_name_1')

            freeze_datetime.move_to('2018-01-01 00:00:00')
            # 作成日付2018-01-01でEC2を作成
            self.__common('test_ec2_name_2')

            freeze_datetime.move_to('2019-01-01 00:00:00')
            # 作成日付2019-01-01でEC2を作成
            self.__common('test_ec2_name_3')

            freeze_datetime.move_to('2020-01-01 00:00:00')
            # 作成日付2020-01-01でEC2を作成
            self.__common('test_ec2_name_4')

        # アプリを実行
        instance_names = app.main()

        # 結果確認
        self.assertEqual(instance_names, ['test_ec2_name_1', 'test_ec2_name_2', 'test_ec2_name_3'])

    def __common(self, name):
        client = boto3.client('ec2')
        # 作成するEC2の条件を設定
        ec2objects = [
            {'KeyName': name}
        ]
        # EC2を作成
        for o in ec2objects:
            client.run_instances(
                ImageId='ami-03cf127a',
                MinCount=1,
                MaxCount=1,
                KeyName=o.get('KeyName'))


if __name__ == '__main__':
    unittest.main()
```
実行してみる。

```
$ python -m unittest tests.test_freezegun -v
test_case_1 (tests.test_freezegun.MyTestCase) ... ok

----------------------------------------------------------------------
Ran 1 test in 0.387s

OK
```
うまくいった。