今日も見に来てくださって、ありがとうございます。石川さんです。
前回、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が使えるようになると、出来上がっていないライブラリをこちらの想定の下に自分のプログラムをテストすることができるようになるなど、仕事の幅が広がりそうですね。