Pythonプログラミング 憧れのテストファースト2

 今日も見に来てくださって、ありがとうございます。石川さんです。

 前回unittestモジュールの概要をお伝えしたのですが、Mockについてお伝えしていませんでした。Mockは、モックとかスタブとかパッチとか、いわゆる、仮想のプログラムを簡単に実現するための仕組みです。

 通常は、誰かが作っているライブラリを呼び出して、その結果をもとに振る舞いが変わるようなプログラムを作っているようなときに、自分のプログラムをテストするために必要になるプログラムです。自分の作成部分のみをテストしたいわけですから、ライブラリがこのような値を返してくるはず、という想定があって、その想定をもとに処理を書いていますので、テストでは、その想定値を返してくるプログラムがあればよいわけですね。

 では、テスト駆動開発の実践をしながら、Mockを使ってみたいと思います。テスト駆動開発は、レッド、グリーン、リファクタリングが1サイクルですね。unittestだと色はつきませんけど、ちょっとやってみます。今回は現在時刻を返してくるプログラムを想定してみます。

サンプルプログラム

 いつもは出来上がったソースコードを載せるのですが、今回は、テストファーストを試みてみます、ということでまずは、想定するテストを書いてみます。

# テストコード
import unittest

class MyWatchTest(unittest.TestCase):
    def setUp(self):
        self.mywatch = MyWatch()
    def tearDown(self):
        del(self.mywatch)
    def test_time(self):
        self.assertEqual(self.mywatch.time() == "12:05:20")

 はい、実行してみます。

 おや?何も起きません。。。お、そうそう、メインプログラムを書き忘れていました。最後に追記します。

if __name__ == "__main__":
    unittest.main()

 はい、再度、実行します。

 エラーは発生したのですけど、、、前回動かしたテストもいっしょに動いています。なぜでしょうか。。。?

 dir()を実行してみると、前回作成したテスト用のクラス「MyFirstTestCase」が残っていました。

In [36]: dir()
Out[36]: 
['In',
 'MyFirstTestCase',
 'MyWatchTest',
 'Out',
 '_',
 '_10',
 '_16',
... 以下略

と、いうことで、「MyFirstTestCase」を削除して、もう一度実行してみます。

In [37]: del(MyFirstTestCase)

In [38]: runfile('C:/work/tkinter_example/mywatchtest.py', wdir='C:/work/tkinter_example')
E
======================================================================
ERROR: test_time (__main__.MyWatchTest)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "C:/work/tkinter_example/mywatchtest.py", line 11, in setUp
    self.mywatch = MyWatch()
NameError: name 'MyWatch' is not defined

----------------------------------------------------------------------
Ran 1 test in 0.002s

FAILED (errors=1)

In [39]: 

 はい、望み通り、エラーが発生しました。クラスが作られていない、ということですね。当然です。クラスをつくって、エラーを取り除きます。それにしても、unittestは自動的にテストを探し出して実行してくれているのですね。

MyWatch.py

# 時刻を返すプログラム
class MyWatch():
    pass

 とりあえず、クラスをつくって最初のエラーを回避。テストを実行。エラー内容が変わりません。。。作ったのにモジュールが存在していない、ということは、テストケースを実行するときに、importが必要ですね。import unittestの下に一行追加。

import MyWatch

 再度、実行。お、エラーメッセージが変わりました。「NameError: name 'MyWatch' is not defined」と主張していたところが、以下のように変化しました。エラーは回避できていませんが、メッセージが変わったので、一歩前進です。

TypeError: 'module' object is not callable

 ええと、どうしてでしょうねぇ。。。そうそう、わかりました。import の書き方が違いました。変更します。

from MyWatch import MyWatch

 はい、実行しましょう。モジュール名とクラス名が同じだと、意味がわかりずらいですね。次回からは気を付けましょう。そういえば、Pythonにはおすすめの命名規則があったような。。。調べたら、モジュール名は、snake_caseスタイルに準拠するのが望ましいそうです。ま、今日のところは、これでいきましょう。

EReloaded modules: MyWatch

======================================================================
ERROR: test_time (__main__.MyWatchTest)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "C:/work/tkinter_example/mywatchtest.py", line 16, in test_time
    self.assertEqual(self.mywatch.time() == "12:05:20")
AttributeError: 'MyWatch' object has no attribute 'time'

----------------------------------------------------------------------
Ran 1 test in 0.002s

FAILED (errors=1)

 ちょっと進みました。やっと、timeという属性はありません、というところまでたどり着きました。では、timeメソッドを追加してみましょう。これ、最速でテストを通すためには、以下のようにすればよいですね。

    def time(self):
        return "12:05:20"

 実行してみます。おお、AttributeErrorのところが以下のように変わりました。

TypeError: assertEqual() missing 1 required positional argument: 'second'

 お恥ずかしい、assertEqual()の使い方を間違っていました。

        self.assertEqual(self.mywatch.time(), "12:05:20", "time()が間違っています。")

 これで、どうでしょう。またテストを実行してみます。

 はい、成功しました。

.Reloaded modules: MyWatch

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

OK

 これで、やっと1サイクルできました。ま、リファクタリング、してませんけどね。次は、ちゃんと時刻を返すようにしましょう。datetimeモジュールをインポートして、return文を以下のように修正しましょう。

        return datetime.strftime(datetime.now(),"%H:%M:%S")

 これで実行すると、以下のようにエラーが発生します。

FReloaded modules: MyWatch

======================================================================
FAIL: test_time (__main__.MyWatchTest)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "C:/work/tkinter_example/mywatchtest.py", line 16, in test_time
    self.assertEqual(self.mywatch.time(), "12:05:20", "time()が間違っています。")
AssertionError: '20:16:16' != '12:05:20'
- 20:16:16
+ 12:05:20
 : time()が間違っています。

----------------------------------------------------------------------
Ran 1 test in 0.002s

FAILED (failures=1)

 はい、実装できましたが、エラーになりました。テストを通すためには、datetime.now()の戻り値が「12:05:20」になる必要があります。こういうときに、Mockが役に立ちます。具体的には、以下のような感じになります。まずは、from unittest.mock import Mockと、Mockをインポートして、test_time()メソッドを以下のように修正します。

    def test_time(self):
        original_time = self.mywatch.time
        self.mywatch.time = Mock(return_value="12:05:20")
        self.assertEqual(self.mywatch.time(), "12:05:20", "time()が間違っています。")
        self.mywatch.time = original_time

 実行すると、成功です。このようにMockを使うことで、他への影響なくtime()のふるまいを変更することができました。ただ、毎回このように元のライブラリを保持しておくのはかなり悩ましい問題ですよね。より簡潔なアプローチとして、patchが用意されています。コンテキストマネージャの形式だと以下のようになります。

    def test_time(self):
        with patch('MyWatch.datetime') as faketime:
            faketime.now.return_value = datetime.datetime(2020,5,8,12,5,20)
            faketime.strftime.side_effect = datetime.datetime.strftime
            self.assertEqual(self.mywatch.time(), "12:05:20", "time()が間違っています。")

 また、デコレーター形式で記述する以下のようになります。

    @patch('MyWatch.datetime')
    def test_time(self, faketime):
        faketime.now.return_value = datetime.datetime(2020,5,8,12,5,20)
        faketime.strftime.side_effect = datetime.datetime.strftime
        self.assertEqual(self.mywatch.time(), "12:05:20", "time()が間違っています。")

 どれも大して変わりませんね。

まとめ

 unittest.Mockを使って、テスト時のふるまいに変更を加えてみました。Mockが使えるようになると、出来上がっていないライブラリをこちらの想定の下に自分のプログラムをテストすることができるようになるなど、仕事の幅が広がりそうですね。

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です


reCaptcha の認証期間が終了しました。ページを再読み込みしてください。