mock(Pyhtonモックライブラリ)についてのメモ

  • 68
    Like
  • 0
    Comment
More than 1 year has passed since last update.

今までちゃんとモックライブラリを使ったことがなかったため、少し調べてみた。

mockのインストール

Pythonのモックライブラリもいくつかあるが、今回はmockを使用する。
Python3.3以降では標準ライブラリになっており、3.3以降のみサポートするプログラムを書く場合は外部ライブラリをインストールする必要がない。
実際には3.3以降のみサポート、とはいかないケースが多いと思われるため、pipでインストールするのが一般的になるか。

mockのインストール
$ 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単体をテストしたい場合はどうすれば良いか?

B#b_testとA#a_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引数にモックしたいクラスを指定する。

モックへの差し替え(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を使用する。ここに例外をセットすることで、モックを呼び出した際に例外を投げることができる。

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の後に指定した名前でモックを取り扱うことができる。

patchをコンテキストマネージャとして扱う
>>> 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を関数に対するデコレータとして扱う
>>> @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をクラスに対するデコレータとして扱う
# このプレフィックスを持つメソッドに対してのみ、モックが渡される。
>>> 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'