最近のクローラ周りのunittestを書いていたのですが、テスト速度の改善や安定化のためにmock
のpatch
を使おうとして色々とハマったのでまとめておきます。
mock?patch?
Pythonのunittest
にはテスト時に一部の関数を置き換えることを目的としたMockを生成するクラスが用意されています。
26.5. unittest.mock — モックオブジェクトライブラリ
以下のように作成したMock
オブジェクトは何も定義していなくてもどんな呼び出し方にも対応し、またその呼び出され方を記憶しています。
In [1]: from unittest.mock import Mock In [2]: mock_obj = Mock() In [3]: mock_obj() # 引数なしでの呼び出し Out[3]: <Mock name='mock()' id='4584656792'> In [4]: mock_obj(1,2,3) # 引数ありの呼び出し Out[4]: <Mock name='mock()' id='4584656792'> In [5]: mock_obj.mock_calls # 呼び出され方の履歴 Out[5]: [call(), call(1, 2, 3)]
さらに、以下の様に呼び出した際の戻り値をreturn_value
で指定することもできます。
In [10]: mock_obj.return_value = 100 In [11]: mock_obj(1,2,3) Out[11]: 100 In [12]: mock_obj(1) Out[12]: 100
このように単体テストに於いてモックを作るのに十分な機能が提供されています。
さて、処理によってはその関数の中で更に別の関数を呼び出していることも多いです。その関数内の関数を置き換えてテストを実施したい場合にはpatch
デコレータを使うと楽です。@patch('置き換えたいモジュール名')
と指定して実行すると、そのテストケース内では指定したモジュールはモックに置き換えられた状態で実行されます。
しかし、'置き換えたいモジュール名'で指定すべき内容が少しわかりづらいと思います。
環境構成
今回はPython 3.6.3の環境で、以下の構成で確認しました。
denzownoMacBook-Pro:misc denzow$ tree . . ├── app.py # 実際に実行したいスクリプト ├── modules # app.pyから呼び出すモジュール配置先 │ ├── __init__.py │ └── lib1.py # app.pyから呼び出されて利用されるモジュール └── tests └── test_app.py # app.pyのテストコード
それぞれの内容は以下です。
# app.py # coding: utf-8 import requests import modules.lib1 as lib1 def init(): """ 外部APIにアクセスする。 ※今回は適当にGoogleにアクセスしているだけ :return: """ res = requests.get('http://google.co.jp') return res.status_code def get_token(x): """ トークンを取得する ※今回は引数にlib1.lib1_func()から取得した乱数を加算しているだけ :param x: :return: トークンコード """ return x + lib1.lib1_func() def main(): ret_code = init() if ret_code != 200: return 'ERROR' tmp1 = get_token(2) return tmp1 if __name__ == '__main__': result = main() print(result)
# lib1.py # coding: utf-8 import random def lib1_func(): return random.randrange(0, 100)
テストを書いてみる
とりあえずapp.py
のget_token
テストコードを書いてみます。
# test_app.py # coding: utf-8 import sys import os import unittest from unittest.mock import patch, call # app.pyを呼び出せるようにsyspathを調整 sys.path.append(os.path.dirname(__file__) + os.sep + '..' + os.sep) import app class TestApp(unittest.TestCase): def test_app_part(self): # tokenとして100を戻しているかをテスト self.assertEqual(app.get_token(1), 100) if __name__ == '__main__': # 直接呼び刺された場合にテストを実行する unittest.main(verbosity=1)
実行してみます。
denzownoMacBook-Pro:misc denzow$ python tests/test_app.py F ====================================================================== FAIL: test_app_part (__main__.TestApp) ---------------------------------------------------------------------- Traceback (most recent call last): File "tests/test_app.py", line 30, in test_app_part self.assertEqual(app.get_token(1), 100) AssertionError: 91 != 100 ---------------------------------------------------------------------- Ran 1 test in 0.000s FAILED (failures=1)
失敗しました。get_token
が呼び出しているlib1_func
が単にランダムに1-100を戻しているので当然です。これをMockに置き換えてみます。lib1_func
はapp.py
で以下のように呼びだされています。
# coding: utf-8 : import modules.lib1 as lib1 : return x + lib1.lib1_func()
import
されたlib1
はapp.py
内のグローバルオ変数として展開されています。そのため、@patch('app.lib1.lib1_func')
と指定することでMockオブジェクトに置き換えることが可能です。私はここでlib1.lib1_func
やらlib1_func
やらmodules.lib1.lib1_func
を指定していずれも動作せずにハマりました。
@patch('app.lib1.lib1_func') def test_app_part(self, mock_func): # モックで置き換えたlib1_funcの戻り値を99に設定 mock_func.return_value = 99 # tokenとして100を戻しているかをテスト self.assertEqual(app.get_token(1), 100)
このようにすることでテストが通るようになります。
denzownoMacBook-Pro:misc denzow$ python tests/test_app.py . ---------------------------------------------------------------------- Ran 1 test in 0.000s OK
また、たとえばimport modules.lib1 as lib1
ではなくimport modules.lib1
としてimport
されている場合は@patch('app.modules.lib1.lib1_func')
といった形式で指定することでMockに置き換えることができます。
@patch
で置き換えたあとのMockオブジェクトはテストメソッドの引数に渡ってくるのでdef test_app_part(self, mock_func)
のような形で受取り、必要に応じてテストコード内で処理の追加などを行うことができます。
なお、Mockを使うと当然置き換えた処理はテストされない状態になります。本例であればlib1_func
はテストされない状態になっていますので、別途lib1_func
自体の単体テスト等を設けることが望ましいです。
Mockの呼び出し状況を追跡
Mockを使用したテストの場合、そのMockが期待した引数で呼び出されているかもチェックする必要があるケースもあります。app.pyのinit
のテストコードで説明します。
# app.py : import requests : def init(): """ 外部APIにアクセスする。 ※今回は適当にGoogleにアクセスしているだけ :return: """ res = requests.get('http://google.co.jp') return res.status_code
init()
は外部のAPIにアクセスしそのレスポンスのステータスコードを取得するメソッドです。今回は手を抜いてgoogleにアクセスしていますが、ここが状況に応じてAPIのエンドポイントになると思ってください。このコードのテストは以下のようにしました。
@patch('app.requests.get') def test_init(self, mock_get): # モックに戻させるダミーのレスポンスクラス class MockResponse: def __init__(self): self.status_code = 200 mock_get.return_value = MockResponse() self.assertEqual(app.init(), 200)
requests
はapp.py
内でグローバル変数として展開されていますのでapp.requests.get
をpatch
に指定します。また、Mockの戻り値としてstatus_code
だけをもったMockResponse
クラスを定義し、return_value
に指定しています。これでテストは通りますが、本当にMockが期待した呼び出され方をしたのかチェックする必要があります。
これについては、mock_calls
属性から取得できます。本属性には呼び出された順にその情報をリストに格納しています。今回であれば、1回目の呼び出しがhttp://google.co.jp
であることを以下の様に確認します。事前にcall
オブジェクトをimport
しておき、call('http://google.co.jp')
がmock_calls
に含まれているかをテストできます。
from unittest.mock import patch, call : # googleのURLを指定して呼び出されたかを確認 self.assertEqual(mock_get.mock_calls[0], call('http://google.co.jp'))
特にクローラ関連では外部のAPIやサイトにアクセスしますので、その部分はモックにするほうが良いことも多いです。そのため、本例のようにモックを利用し期待したリソースへのアクセスをしているかをテストするほうが良いでしょう。
まとめ
テストをしっかり書いておくと、コードの修正やリファクタをしたときに影響範囲・誤りに気が付きやすいので精神的に楽になります。Mock等もしっかりつかってテストコードを充実させて行きたいと思います。