Python

モックとスタブに触れてみる

概要

この文章では

  • MagicMock の不思議な動き
  • それがどう役に立つのか

を説明します。Python3 では MagicMock は標準モジュール unittest.mock に含まれています。
Python2 では pip install mock で mock パッケージをインストールすれば使えるようになります。

MagicMock で遊んでみる

Python のインタプリタを起動し、MagicMock オブジェクトを生成します

$ python3.6
Python 3.6.0 (default, Feb 27 2017, 00:03:01)
[GCC 4.2.1 Compatible Apple LLVM 8.0.0 (clang-800.0.42.1)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
>>> from unittest.mock import MagicMock
>>> f = MagicMock()

このfが関数であると思い込んで、呼び出してみます

>>> f(1)
<MagicMock name='mock()' id='4381867704'>

関数として定義したわけでもないのに呼び出しができています。不思議ですね。
戻り値もまたMagicMockになってますがそのことは気にせず、さらに呼び出してみます

>>> f(2)
<MagicMock name='mock()' id='4381867704'>
>>> f(1, 2, 3)
<MagicMock name='mock()' id='4381867704'>

引数の値や引数の数を変えても自由に呼び出せました。驚きです。ここでおもむろに f.call_args_list を評価してみると、

>>> f.call_args_list
[call(1), call(2), call(1, 2, 3)]

MagicMock を生成してからこれまでに3回呼び出していました。そのことが引数とともに記録されているのが見てとれます。面白いですね。

MagicMock の機能はこれだけではありません。再度 MagicMock インスタンスを生成して、f.return_value に値を設定した後にfを呼び出してみましょう

>>> f = MagicMock()
>>> f.return_value = 5
>>> f(1, 2)
5
>>> f(1)
5
>>> f()
5

どんな引数を与えても、設定した値が返ってきます。このようにして関数の戻り値を好きな値に置き換えることができます。なお、この場合にもどう呼び出しがされたかは記録されています

>>> f.call_args_list
[call(1, 2), call(1), call()]

他にも機能があるのですが今回はこれくらいにしておいて、これらがどう役立つかを見ていきましょう。

呼び出しの記録

整数を受け取り、偶数なら push() を呼び、奇数なら何もしない関数 push_if_even() があったとします。
具体的には以下のようなコードになります。

def push():
    pass

def push_if_even(x):
    """整数を受け取り、偶数ならpush()を呼ぶ"""
    if x % 2 == 0:
        push()

ここで、このコードが正しく実装されているかのテストを書きたいとします。先ほど見たように、MagicMock は関数として呼び出しがされた記録を保持しますので、そのことを利用します。次のようにすればテストできます。

from unittest.mock import MagicMock

push = MagicMock()
push_if_even(1)
assert len(push.call_args_list) == 0

push = MagicMock()
push_if_even(0)
assert len(push.call_args_list) == 1

print("success")

以下のようにも書けます

push = MagicMock()
push_if_even(1)
push.asset_not_called()

push = MagicMock()
push_if_even(0)
push.assert_called_once()

せっかくなので assert に失敗したときの動作も見ておきましょう。

>>> from unittest.mock import MagicMock
>>> push = MagicMock()
>>> push.assert_called_once()
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "/usr/local/Cellar/python3/3.6.0/Frameworks/Python.framework/Versions/3.6/lib/python3.6/unittest/mock.py", line 795, in assert_called_once
    raise AssertionError(msg)
AssertionError: Expected 'mock' to have been called once. Called 0 times.
>>> push()
<MagicMock name='mock()' id='4356157392'>
>>> push.assert_not_called()
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "/usr/local/Cellar/python3/3.6.0/Frameworks/Python.framework/Versions/3.6/lib/python3.6/unittest/mock.py", line 777, in assert_not_called
    raise AssertionError(msg)
AssertionError: Expected 'mock' to not have been called. Called 1 times.

戻り値の制御

今日が日曜日なら True、そうでなければ False を返す関数 is_sunday

from datetime import date

def today():
    return date.today()

def is_sunday():
    return today().weekday() == 6

が正しく動いているかテストしたいとします。今日が日曜日でないとすると、一度呼び出して False が返ることを確認して、次の日曜を待ってもう一度呼び出して True が返ることを確認すればよいですね。ただし、テストが終わるまでに何日もかかってしまいますが。そんなの待ち切れないという人は MagicMock の戻り値を制御する機能を使って

from unittest.mock import MagicMock

today = MagicMock()
today.return_value = date(2020, 2, 1)
assert is_sunday() == False

today = MagicMock()
today.return_value = date(2020, 2, 2)
assert is_sunday() == True

print("success")

とすれば一瞬でテストできてしまいます。便利ですね。

まとめ

MagicMock の性質を見たあと、その

  • 関数の呼び出しの記録、検証
  • 関数の戻り値の制御

の機能を活用してテストコードを実装しました。環境に影響を与える関数や環境の影響を受ける関数のテストは、単純な関数のテストと比べて書くのが難しいですが、環境に与える影響をモックで検証したり、環境からの影響をスタブで置き換えることで、再現可能な形のテストコードを簡単に作成できることを見ました。

今回の例では単純に関数を上書きしましたが、mock モジュールには patch という便利なものが用意されていて、テストの間だけ関数を置きかえて終わったら元に戻すといったことが簡単にできます。テストを書く際には活用するとよいでしょう。