今までちゃんとモックライブラリを使ったことがなかったため、少し調べてみた。
mockのインストール
Pythonのモックライブラリもいくつかあるが、今回はmockを使用する。
Python3.3以降では標準ライブラリになっており、3.3以降のみサポートするプログラムを書く場合は外部ライブラリをインストールする必要がない。
実際には3.3以降のみサポート、とはいかないケースが多いと思われるため、pipでインストールするのが一般的になるか。
$ pip install mock
モックと差し替える
ポイントはmock.Mockクラス。Mockクラスのインスタンスはcallableであり、callした際の戻り値を設定できる。
>>> from mock import Mock
>>> m = Mock()
# return_value属性に戻り値をセット
>>> m.return_value = 5
>>> m()
5
このようにして戻り値を設定したMockインスタンスを、実際の処理をしているクラス・メソッドと差し替える。やることの大部分はこれである。
以下のコードを使って例を示す。以下のコードでは、B#b_testが、A#a_testの処理に依存している。
B#b_test単体をテストしたい場合はどうすれば良いか?
class A(object):
def a_test(self):
print('test')
class B(object):
def __init__(self, a_ins):
self.a_ins = a_ins
def b_test(self):
return self.a_ins.a_test()
この場合、A#a_testをMockインスタンスに差し替えてやることで、依存関係を切り離すことができる。
>>> a = A()
>>> a.a_test = Mock()
>>> a.a_test.return_value = 'mocked'
>>> b = B(a)
>>> b.b_test()
'mocked'
上記の例では、メソッドを差し替えたが実際にはメソッド単位ではなく、インスタンス単位で丸ごと差し替えたいというケースも多いだろう。
その場合は、Mockインスタンスを作成する際、spec
引数にモックしたいクラスを指定する。
>>> a = Mock(spec=A)
>>> a.a_test.return_value = 'mocked_spec'
>>> b = B(a)
>>> b.b_test()
'mocked_spec'
モックの呼び出しは、Mockインスタンスに記録される。この情報を用いて、インスタンス間の関係(=呼び出しが正しく行われているか)をチェックできる。
>>> a = Mock(spec=A)
>>> a.a_test.return_value = 'mocked_spec'
>>> b = B(a)
# Mock#call_args_list:該当するMockインスタンスの呼び出しを保存するリスト
>>> a.a_test.call_args_list
[]
# Mock#assert_any_call:該当するMockインスタンス呼び出しが過去にあったかをassertする。
# 今回の例では引数なしだが、実際には任意の引数を与えることが可能(その引数を与えたMockインスタンス呼び出しがあったかをassertする)
# (ref. http://www.voidspace.org.uk/python/mock/mock.html)
>>> a.a_test.assert_any_call()
Traceback (most recent call last):
File "<ipython-input-81-ed526bd5ddf7>", line 1, in <module>
a.a_test.assert_any_call()
File "/Users/tatsuro/.venv/py3.3/lib/python3.3/site-packages/mock.py", line 891, in assert_any_call
'%s call not found' % expected_string
AssertionError: a_test() call not found
>>> b.b_test()
'mocked_spec'
# B#b_test()経由で呼び出されている
>>> a.a_test.call_args_list
[call()]
>>> a.a_test.assert_any_call()
>>>
例外を返す処理をモックする
戻り値を返す場合は、return_value
にセットしてやれば良いが、以下のように例外を投げる処理をモックする場合はどうしたらよいか。
class A(object):
def a_test(self):
raise ValueError
class B(object):
def __init__(self, a_ins):
self.a_ins = a_ins
def b_test(self):
try:
return self.a_ins.a_test()
except ValueError:
print('error handling')
その場合、Mock#side_effect
を使用する。ここに例外をセットすることで、モックを呼び出した際に例外を投げることができる。
>>> a = Mock(spec=A)
>>> a.a_test.side_effect = ValueError
>>> b = B(a)
>>> b.b_test()
error handling
ここでは、よく出るであろうユースケースとして例外処理を上げたが、side_effectは例外処理に特化したものではない。
「Mockインスタンス呼び出し時に必要なロジックを付与する」ための仕組みであり、以下のように特定の引数のみに何らかの処理を加えて戻り値とする、といったことも可能。
>>> class B(object):
def __init__(self, a_ins):
self.a_ins = a_ins
def b_test(self, value):
try:
return self.a_ins.a_test(value)
except ValueError:
print('error handling')
>>> def handler(value):
if (value % 2) == 0:
return value * 2
return value
>>> a.a_test.side_effect = handler
>>> b = B(a)
>>> b.b_test(1)
1
>>> b.b_test(2)
4
>>> b.b_test(3)
3
ただside_effectにあまり複雑なロジックを組み込むのは、保守の観点から好ましいとは思えない。
ロジックが複雑になる場合は、テストケースを分割するといった対策が必要かも知れない。
特定のスコープでモックを有効にする
mockは、特定のスコープでのみモックを有効にするpatch
という仕組みを持っている。
patch
は関数呼び出しで使用することもできるが、コンテキストマネージャやデコレータとして扱うこともできる。
というか多くの場合、こちらで扱うと思われる。
コンテキストマネージャとして扱う例は以下の通り。
asの後に指定した名前でモックを取り扱うことができる。
>>> class C(object):
def hoge(self):
return 'hoge'
# withステートメント下のスコープのみで、クラスCのモック(CMock)を使用可能
>>> with patch('__main__.C') as CMock:
c = CMock()
c.hoge.return_value = 'test'
print(c.hoge())
...
test
デコレータとして扱う場合は以下の通り。
デコレートした関数の最後の引数として、モックが渡される。
>>> @patch('__main__.C')
def patched_func(CMock):
c = CMock()
c.hoge.return_value = 'test'
print(c.hoge())
...
>>> patched_func()
test
また関数に対するデコレータだけでなく、クラスに対するデコレータとして扱うこともできる。
多くのテストツールでは、テストケースをクラス単位でまとめる機能(=1テストケース/1メソッドとみなす)を有しているため、共通のモックを全てのテストに適用することができる。
この際、少し注意しておかなければならないのは、patch.TEST_PREFIX
の存在。クラスに対してデコレータを指定した場合、patch.TEST_PREFIX
で始まるメソッドに対してのみモックが渡される。
そのため、一定の命名規則に従ってテストメソッドを実装する必要がある。(とはいえデフォルトの値が'test'であるため、それほど気にすることでもないか?)
# このプレフィックスを持つメソッドに対してのみ、モックが渡される。
>>> patch.TEST_PREFIX
'test'
>>> @patch('__main__.C')
class CTest(object):
def test_c(self, CMock):
c = CMock()
c.hoge.return_value = 'hoge_test'
print(c.hoge())
def notmock(self, CMock):
pass
...
# 'test'で始まるメソッド。モックが渡される。
>>> CTest().test_c()
hoge_test
# 'test'で始まらないメソッド。モックが渡されないため、引数不足でエラーになる。
>>> CTest().notmock()
Traceback (most recent call last):
File "<ipython-input-189-cee8cb83c7b4>", line 1, in <module>
CTest().notmock()
TypeError: notmock() missing 1 required positional argument: 'CMock'