ようへいの日々精進XP

よかろうもん

直近で Python の unittest で試行錯誤していて得られた知見の幾つか (1)

環境

以下のような環境でやってます.

$ sw_vers
ProductName:    Mac OS X
ProductVersion: 10.11.6
BuildVersion:   15G19009

$ python --version
Python 3.6.4

知見 (1) ランダムな情報を返す関数をテストする

関数

以下のような関数を sample_1.py というファイルに作成しました.

import random

def select_random_key(n):
    k = random.choice(range(n))
    return "{0:02d}".format(k)

この関数は, 引数 n を渡すと, 0 から n - 1 までの数値を選んで, 0 パディングして返すだけの関数で, 実際に試してみると以下のような感じになります.

>>> def select_random_key(n):
...     k = random.choice(range(n))
...     return "{0:02d}".format(k)
...
>>> select_random_key(3)
'01'
>>> select_random_key(3)
'02'
>>> select_random_key(3)
'00'

さて, これをテストする場合, mock を使って, 以下のようにテストを書きました.

テスト

以下のようなテストを tests/test_sample_1.py というファイルに作成しました.

import unittest
import mock
import sample_1

class SampleTest(unittest.TestCase):
    @mock.patch('random.choice')
    def test_select_random_key(self, random_call):
        random_call.return_value = 1
        self.assertEqual(sample_1.select_random_key(3), '01')

mock.patch を利用して, random.choice() 関数の戻り値を指定した値 (1) で固定しました(random_call.return_value = 1).

テストを実行すると, 以下のように意図した結果を返すことを確認しました.

$ python -m unittest tests.test_sample_1 -v
test_select_random_key (tests.test_sample_1.SampleTest) ... ok

----------------------------------------------------------------------
Ran 1 test in 0.001s

OK

知見 (2) とある日付の前月を返す関数をテストする

関数

以下のような関数を sample_2.py というファイルに作成しました.

from datetime import datetime, timedelta

def get_last_month():
    # 本日を取得
    today = datetime.now()
    # 当月の初日 (1 日) を取得
    this_month_first_day = datetime(today.year, today.month, 1)
    # 当月初日の 1 日前の日付 (前月) を取得
    last_month_last_day = this_month_first_day + timedelta(days=-1)
    # 前月の情報から %Y (年) と %m (月) を取得
    return last_month_last_day.strftime('%Y%m')

datetime 関数だけで前月を取得するというのが意外に面倒くさいということで, Pythonで先月を取得する - Qiita を参考にさせて頂きました. 前日なら datetime.timedelta() で取得出来るんですがね...

この関数を実行すると, 以下のように前月を YYYYMM という文字列で返します.

>>> from datetime import datetime, timedelta
>>> def get_last_month():
...     today = datetime.now()
...     this_month_first_day = datetime(today.year, today.month, 1)
...     last_month_last_day = this_month_first_day + timedelta(days=-1)
...     return last_month_last_day.strftime('%Y%m')
...
>>> get_last_month()
'201802'

さて, これをどのようにテストするのか. 以下のように書けば良さそう.

mport unittest
import sample_2

class SampleTest(unittest.TestCase):
    def test_get_last_month(self):
        self.assertEqual(sample_2.get_last_month(), '201802')

そう, 今月一杯 (2018 年 03 月) ならね... このテストを 4 月に実行すると 201803 が返ってきてしまうのでテストはコケてしとらす.

テスト

では, どうすれば良いのか. 先程と同様に mock を使って, datetime.now() が返す日付を固定してしまえば良さそう.

import unittest
import mock
import datetime
import sample_2

...
    def test_get_last_month_2(self):
        with mock.patch('datetime.datetime.now') as now:
            now.return_value = datetime(2018, 3, 1)
        self.assertEqual(sample_2.get_last_month(), '201802')
...

こんな風に書きたいんだけど, ビルトインエクステンションの datetime.datetime にはアトリビュートを設定することが出来ない為, 以下のようなエラーとなってしまいました.

$ python -m unittest tests.test_sample_2 -v
test_get_last_month_2 (tests.test_sample_2.SampleTest) ... ERROR

======================================================================
ERROR: test_get_last_month_2 (tests.test_sample_2.SampleTest)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/patht/to/tests/test_sample_2.py", line 13, in test_get_last_month_2
    with mock.patch('datetime.datetime.now') as now:
  File "/patht/to/.pyenv/versions/3.6.4/lib/python3.6/site-packages/mock/mock.py", line 1460, in __enter__
    setattr(self.target, self.attribute, new_attr)
TypeError: can't set attributes of built-in/extension type 'datetime.datetime'

----------------------------------------------------------------------
Ran 1 test in 0.003s

FAILED (errors=1)

ということで, GitHub - spulec/freezegun: Let your Python tests travel through time というモジュールを利用することで datetime をモック化することが出来ました.

github.com

以下のように @freeze_time() デコレータに固定したい日付を定義するだけ.

import unittest
from freezegun import freeze_time
import sample_2

class SampleTest(unittest.TestCase):
    @freeze_time("2018-03-01")
    def test_get_last_month(self):
        self.assertEqual(sample_2.get_last_month(), '201802')

    @freeze_time("2018-04-01")
    def test_get_last_month(self):
        self.assertEqual(sample_2.get_last_month(), '201803')

    @freeze_time("2018-04-01")
    def test_get_last_month(self):
        self.assertNotEqual(sample_2.get_last_month(), '201802')

テストを実行してみると...

$ python -m unittest tests.test_sample_2 -v
test_get_last_month (tests.test_sample_2.SampleTest) ... ok

----------------------------------------------------------------------
Ran 1 test in 0.295s

OK

LGTM.

知見 (3) テスト結果を XML 形式で出力する

メモ

テストの実行結果を JUnitXML 形式で出力してくれる GitHub - xmlrunner/unittest-xml-reporting: unittest-based test runner with Ant/JUnit like XML reporting. を利用するだけ.

以下のように pip install するだけ.

$ pip install unittest-xml-reporting

テストを実行する際にコマンドラインで以下のように実行するだけです.

$ python -m xmlrunner tests.test_sample_2

以下のように出力されます.

$ python -m xmlrunner tests.test_sample_2

Running tests...
----------------------------------------------------------------------
.
----------------------------------------------------------------------
Ran 1 test in 0.318s

OK

Generating XML reports...

カレントディレクトリに TEST-tests.test_sample_2.SampleTest-%Y%m%d%H%M%S.xml というファイルが生成されています.

$ cat TEST-tests.test_sample_2.SampleTest-20180330233543.xml
<?xml version="1.0" encoding="UTF-8"?>
<testsuite errors="0" failures="0" name="tests.test_sample_2.SampleTest-20180330233543" skipped="0" tests="1" time="0.318" timestamp="2018-03-30T23:35:43">
        <testcase classname="tests.test_sample_2.SampleTest" name="test_get_last_month" time="0.318" timestamp="2018-03-30T23:35:43"/>
        <system-out>
<![CDATA[]]>    </system-out>
        <system-err>
<![CDATA[]]>    </system-err>
</testsuite>

以上

知見でした.