Python
unittest
Mock
patch

Pythonで書きたい! (3) モックを活用する

こんにちは。leo1109です。
今回も、前回に引き続き、テストの話になります。

記事で利用したコードは、GitHub上にアップロードされています。
※対話IFのコードは除きます。

今回紹介する話

テストを書く話です。patchを利用します。

テストをするためのモジュールを作成する

前回のスクリプトを改良して、以下の機能を追加しました。

  • フィボナッチ数を取得
  • 連続値数列の総和を取得
  • フィボナッチ数、連続地数列の総和の差分を取得
  • 現在時刻(UTC)の、入力値での剰余を取った値を取得

また、フィボナッチ数の取得、総和の取得は、定義通りの実装に変更しています。

# python 3.5.2

import time
from functools import wraps


def required_natural_number(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        k = args[0]
        if isinstance(k, bool):
            raise TypeError
        if not isinstance(k, int) or k < 1:
            raise TypeError
        return func(*args, **kwargs)
    return wrapper


def _f(k):
    if k == 1 or k == 2:
        return 1
    else:
        return _f(k-1) + _f(k-2)


def _sum(k):
    if k == 1:
        return 1
    else:
        return k + _sum(k-1)


@required_natural_number
def fibonacci(k):
    return _f(k)


@required_natural_number
def sum_all(k):
    return _sum(k)


@required_natural_number
def delta_of_sum_all_and_fibonacci(k):
    x = sum_all(k) - fibonacci(k)
    return x if x >= 0 else -x


@required_natural_number
def surplus_time_by(k):
    return int(time.time() % k)

早速テストを書く

総和とフィボナッチ数の差を取得するsum_all_minus_fibonacci(x)のテストを追加します。
テストケースの値は適当に選びました。

my_math_2_test.py
# python 3.5.2

from unittest.mock import patch

import pytest

import my_math


class TestFibonacci:
    def test(self):
        assert my_math.fibonacci(1) == 1
        assert my_math.fibonacci(5) == 5
        assert my_math.fibonacci(10) == 55
        assert my_math.fibonacci(20) == 6765
        assert my_math.fibonacci(30) == 832040
        assert my_math.fibonacci(35) == 9227465


class TestSumAll:
    def test(self):
        assert my_math.sum_all(1) == 1
        assert my_math.sum_all(5) == 15
        assert my_math.sum_all(10) == 55
        assert my_math.sum_all(20) == 210
        assert my_math.sum_all(30) == 465
        assert my_math.sum_all(35) == 630


class TestDeltaOfSumAllAndFibonacci:

    def test(self):
        assert my_math.delta_of_sum_all_and_fibonacci(1) == 1 - 1
        assert my_math.delta_of_sum_all_and_fibonacci(5) == 15 - 5
        assert my_math.delta_of_sum_all_and_fibonacci(10) == 55 - 55
        assert my_math.delta_of_sum_all_and_fibonacci(20) == -1 * (210 - 6765)
        assert my_math.delta_of_sum_all_and_fibonacci(30) == -1 * (465 - 832040)
        assert my_math.delta_of_sum_all_and_fibonacci(35) == -1 * (630 - 9227465)

テストを実行してみます。

$ pytest -v  my_math_2_test.py
================================================================== test session starts ==================================================================
collected 3 items

my_math_2_test.py::TestFibonacci::test PASSED
my_math_2_test.py::TestSumAll::test PASSED
my_math_2_test.py::TestDeltaOfSumAllAndFibonacci::test PASSED

=============================================================== 3 passed in 21.01 seconds ===============================================================

20秒かかりました。
フィボナッチ数のテスト、そして差分のテストに10秒ずつくらい要しているようです。

原因は、フィボナッチ数を取得するメソッドを再帰で書き換えたことにあります。
(今回の記事の目的はテストなので、計算時間についての説明は割愛しますが、再帰を使った実装は、計算時間量が非常に大きくなるケースがあります)

しかし、このテストは実装の問題以外にも、実は無駄な処理が含まれています。
その点を修正することで、テストの高速化が実現できます。

関数をパッチする

上から貼り付けるイメージです。

遅い関数をパッチする

テストが遅くなった原因は、フィボナッチ数を取得するメソッドなので、なるだけ実行しないようにしたいです。
テスト内容を見ると、fibonacci(x)のテストと、delta_of_sum_all_and_fibonacci(x)で、共に同じ引数でfibonacci(x)が呼ばれていることがわかります。...これはちょっともったいないです。
fibonacci(x)は単体でテストされているので、delta_of_sum_all_and_fibonacci(x)をテストする際には、できれば実行したくありません。(時間がかかるからです)
今回は、patchを利用して、fibonacci(x)を実行しないようにしてみましょう。

既存のテストをskipした上で、fibonacci(x)patchしたテストを追加しました。

class TestDeltaOfSumAllAndFibonacci:
    @pytest.mark.skip
    def test_slow(self):
        assert my_math.delta_of_sum_all_and_fibonacci(1) == 1 - 1
        assert my_math.delta_of_sum_all_and_fibonacci(5) == 15 - 5
        assert my_math.delta_of_sum_all_and_fibonacci(10) == 55 - 55
        assert my_math.delta_of_sum_all_and_fibonacci(20) \
            == -1 * (210 - 6765)
        assert my_math.delta_of_sum_all_and_fibonacci(30) \
            == -1 * (465 - 832040)
        assert my_math.delta_of_sum_all_and_fibonacci(35) \
            == -1 * (630 - 9227465)

    def test_patch(self):
        with patch('my_math.fibonacci') as mock_fib:
            mock_fib.return_value = 1
            assert my_math.delta_of_sum_all_and_fibonacci(1) == 1 - 1
            assert my_math.delta_of_sum_all_and_fibonacci(5) == 15 - 5
            assert my_math.delta_of_sum_all_and_fibonacci(10) == 55 - 55
            assert my_math.delta_of_sum_all_and_fibonacci(20) \
                == -1 * (210 - 6765)
            assert my_math.delta_of_sum_all_and_fibonacci(30) \
                == -1 * (465 - 832040)
            assert my_math.delta_of_sum_all_and_fibonacci(35) \
                == -1 * (630 - 9227465)

patchはwith文やデコレータを利用して、指定したモジュールのメソッドの実行内容を書き換えます。
動作確認として、fibonacci(x)は常に1を返すようにしています。
実行してみましょう。

$ pytest -v my_math_2_test.py::TestDeltaOfSumAllAndFibonacci
================================================================== test session starts ==================================================================
collected 2 items

my_math_2_test.py::TestDeltaOfSumAllAndFibonacci::test_slow SKIPPED
my_math_2_test.py::TestDeltaOfSumAllAndFibonacci::test_patch FAILED

======================================================================= FAILURES ========================================================================
_______________________________________________________ TestDeltaOfSumAllAndFibonacci.test_patch ________________________________________________________


self = <my_math_2_test.TestDeltaOfSumAllAndFibonacci object at 0x103abcf28>

    def test_patch(self):
        with patch('my_math.fibonacci') as mock_fib:
            mock_fib.return_value = 1
            assert my_math.delta_of_sum_all_and_fibonacci(1) == 1 - 1
>           assert my_math.delta_of_sum_all_and_fibonacci(5) == 15 - 5
E           assert 14 == (15 - 5)
E            +  where 14 = <function delta_of_sum_all_and_fibonacci at 0x103ac0840>(5)
E            +    where <function delta_of_sum_all_and_fibonacci at 0x103ac0840> = my_math.delta_of_sum_all_and_fibonacci

my_math_2_test.py:45: AssertionError
========================================================== 1 failed, 1 skipped in 0.21 seconds ==========================================================

テストが失敗するようになりました。
delta_of_sum_all_and_fibonacci(5)のテストケースで、本来5を取得するはずのfibonacci(5)が、1を返すようになったため、14 == (15 - 5)としてテストが失敗しています。

想定通りにパッチできていることが確認できたため、正しい値を返すようにテストを改良します。

    def test_patch(self):
        with patch('my_math.fibonacci') as mock_fib:
            mock_fib.return_value = 1
            assert my_math.delta_of_sum_all_and_fibonacci(1) == 1 - 1
            mock_fib.return_value = 5
            assert my_math.delta_of_sum_all_and_fibonacci(5) == 15 - 5
            mock_fib.return_value = 55
            assert my_math.delta_of_sum_all_and_fibonacci(10) == 55 - 55
            mock_fib.return_value = 6765
            assert my_math.delta_of_sum_all_and_fibonacci(20) \
                == -1 * (210 - 6765)
            mock_fib.return_value = 832040
            assert my_math.delta_of_sum_all_and_fibonacci(30) \
                == -1 * (465 - 832040)
            mock_fib.return_value = 9227465
            assert my_math.delta_of_sum_all_and_fibonacci(35) \
                == -1 * (630 - 9227465)

再度実行します。

$ pytest -v my_math_2_test.py::TestDeltaOfSumAllAndFibonacci
================================================================== test session starts ==================================================================
collected 2 items

my_math_2_test.py::TestDeltaOfSumAllAndFibonacci::test_slow SKIPPED
my_math_2_test.py::TestDeltaOfSumAllAndFibonacci::test_patch PASSED

========================================================== 1 passed, 1 skipped in 0.05 seconds ==========================================================

テストが成功しました。
実行時間は0.05(sec)と、先程に比べるとかなり速くなりました。
もちろん全実行した場合は最初のfibonacci(x)の実行分だけ時間がかかってしまいますが、それでも20秒->10秒と、50%削減することができました。

戻り値が定まらない関数をパッチする

前項では遅い関数をモック化することで。テストを効率化することに成功しました。
さて、surplus_time_by(k)は、現在時刻(UTC)のunixtimeを取得して、その余りを返す関数ですが、現在時刻は実行するたびに変化するため、先程のように決め打ちの値を書いたテストができません。

例えば、以下のテストは、time.time()の値が5の倍数の場合には成功しますが、それ以外の場合は成功しません。

   assert my_math.surplus_time_by(5) == 5

こういうケースの場合、time.time()をパッチすることを検討します。
timeは、Pythonの標準ライブラリですので、すでに十分にテストされていると仮定すると、テストすべきは、剰余の計算の部分になるためです。

さて、my_mathで利用されているtime.time()をパッチするには、以下のように書きます。

class TestSurplusTimeBy:
    def test(self):
        with patch('my_math.time') as mock_time:
            mock_time.time.return_value = 1000
            assert my_math.surplus_time_by(3) == 1
            assert my_math.surplus_time_by(5) == 0
            mock_time.time.return_value = 1001
            assert my_math.surplus_time_by(3) == 2
            assert my_math.surplus_time_by(5) == 1

以下のように、time.timeまでをpatchすることも可能です。

    def test2(self):
        with patch('my_math.time.time') as mock_time:
            mock_time.return_value = 1000
            assert my_math.surplus_time_by(3) == 1
            assert my_math.surplus_time_by(5) == 0
            mock_time.return_value = 1001
            assert my_math.surplus_time_by(3) == 2
            assert my_math.surplus_time_by(5) == 1

上記の違いは、どこまでmock化するかという点にあります。
以下の実行結果を見ると、timeをパッチした場合、timeモジュール自体がモックオブジェクトに書き換わっていることがわかります。
そのため、例えばtime以下のtime()以外を利用している場合は、time.timeと明示的に指定するか、利用しているメソッドをすべてパッチする必要があります。

# time.timeをパッチ
>>> with patch('my_math.time.time') as m:
...   print(my_math.time)
...   print(my_math.time.time)
...
<module 'time' (built-in)>
<MagicMock name='time' id='4329989904'>

# timeをパッチ
>>> with patch('my_math.time') as m:
...   print(my_math.time)
...   print(my_math.time.time)
...
<MagicMock name='time' id='4330034680'>
<MagicMock name='time.time' id='4330019136'>

patchに指定する文字列について

今回の例では、モジュールtimeがimportされているのはmy_mathですので、my_math.timeと書きます。
そのため、my_mathを利用する別のモジュールmy_moduleが現れた場合は、my_module.my_math.timeと書く必要があります。

クラスのインスタンスメソッドをパッチする場合は、インスタンスまで指定する必要があります。

my_class.py
# Python 3.5.2


class MyClass:
    def __init__(self, prefix='My'):
        self._prefix = prefix

    def my_method(self, x):
        return '{} {}'.format(self._prefix, x)
my_main.py
# Python 3.5.2

from my_class import MyClass


def main(name):
    c = MyClass()
    return c.my_method(name)


if __name__ == '__main__':
    print(main('Python'))

上記を実行すると、My Python`と表示されます。
ここで、
MyClass.my_method`をパッチする場合は、以下のように書けます。

my_main_test.py
# python 3.5.2

from unittest.mock import patch

import my_main


class TestFunction:
    def test(self):
        assert my_main.function('Python') == 'My Python'

    def test_patch_return_value(self):
        with patch('my_class.MyClass.my_method') as mock:
            mock.return_value = 'Hi! Perl'
            assert my_main.function('Python') == 'Hi! Perl'

    def test_patch_side_effect(self):
        with patch('my_class.MyClass.my_method') as mock:
            mock.side_effect = lambda x: 'OLA! {}'.format(x)
            assert my_main.function('Python') == 'OLA! Python'

外部アクセスするメソッドをパッチする

外部アクセスするモジュールをテストする時も、パッチが便利です。
requestsモジュールを利用して、指定したURLをGETした時のステータスコードを返すメソッドを作成しました。
これをテストすることを考えます。

# Python 3.5.2

import requests


def get_status_code(url):
    r = requests.get(url)

    return r.status_code

テストを書いてみます。

# python 3.5.2

import pytest

import my_http


class TestGetStatusCode:
    @pytest.fixture
    def url(self):
        return 'http://example.com'


    def test(self, url):
        assert my_http.get_status_code(url) == 200

このテストは問題なくパスします。
しかし、オフラインのときはどうでしょう?
詳細なスタックトレースは割愛しますが、テストは失敗します。

...
>           raise ConnectionError(e, request=request)
E           requests.exceptions.ConnectionError: HTTPConnectionPool(host='example.com', port=80): Max retries exceeded with url: / (Caused by NewConnectionError('<urllib3.connection.HTTPConnection object at 0x1039a4cf8>: Failed to establish a new connection: [Errno 8] nodename nor servname provided, or not known',))

../../../.pyenv/versions/3.5.2/lib/python3.5/site-packages/requests/adapters.py:504: ConnectionError                                               =============================================================== 1 failed in 1.06 seconds ================================================================

このテストを、オフラインでも通るようにするには、request.getをパッチします。
戻り値はオブジェクトですので、return_valueにMagicMock()を明示的に指定してあげる必要があります。

# python 3.5.2

from unittest.mock import MagicMock, patch

import pytest

import my_http


class TestGetStatusCode:
    @pytest.fixture
    def url(self):
        return 'http://example.com'

    @pytest.mark.skip
    def test_online(self, url):
        assert my_http.get_status_code(url) == 200

    def test_offline(self, url):
        with patch('my_http.requests') as mock_requests:
            mock_response = MagicMock(status_code=200)
            mock_requests.get.return_value = mock_response

            assert my_http.get_status_code(url) == 200

上記のテストはオフラインでもパスします。
MagicMockのstatus_codeを400, 500などを指定すれば、URLは同じにしつつ、エラーケースのテストも追加できます。
データベースがない環境でのDBアクセスや、外部サービスからのトークン取得等をモック化する際に活用できるでしょう。

パッチTips

パッチするときのコツです。

複数のメソッドをパッチしたい場合

Python3系では、ExitStack()が便利です。

>>> with ExitStack() as stack:
...   x = stack.enter_context(patch('my_math.fibonacci'))
...   y = stack.enter_context(patch('my_math.sum_all'))
...   x.return_value = 100
...   y.return_value = 200
...   z = my_math.delta_of_sum_all_and_fibonacci(99999)
...   print(z)
...
100

両方の関数の戻り値が書き換えられているため、my_math.delta_of_sum_all_and_fibonacci(99999)は、200と100の差分を返します。

例外を返したい

モックを利用していると、例外を返したくなるケースが出てくることがあります。
その場合は、mock.side_effectを利用します。

>>> with patch('my_math.fibonacci') as m:
...   m.side_effect = ValueError
...   my_math.fibonacci(1)
...
Traceback (most recent call last):
  File "<stdin>", line 3, in <module>
  File "/Users/sho/.pyenv/versions/3.5.2/lib/python3.5/unittest/mock.py", line 917, in __call__
    return _mock_self._mock_call(*args, **kwargs)
  File "/Users/sho/.pyenv/versions/3.5.2/lib/python3.5/unittest/mock.py", line 973, in _mock_call
    raise effect
ValueError

モックが実行されたかどうかを確認する

mockオブジェクトにcallオブジェクトが格納されるので、それを確認する方法が便利です。
assert_で始まるメソッドは、条件を満たさない場合には例外が上がるため、assertを書く必要はありません。
mock.call_argsにモックが実行された際の引数がタプルで格納されています。
詳細は、やはり公式ドキュメントを参照してください。

代表的なもののみ紹介します。

  • mock.called 実行されたかどうか(bool)
  • mock.call_count 実行回数
  • mock.assert_called_with() 引数を伴って実行されたかどうか
   def test_called(self):
        with patch('my_class.MyClass.my_method') as mock:
            my_main.function('Python')

        assert mock.called
        assert mock.call_count == 1
        mock.assert_called_with('Python')

次回は

お盆休み明けまで未定です!