[Dd]enzow(ill)? with DB and Python

DBとか資格とかPythonとかの話をつらつらと

Pythonのunittestのmock.patchでハマった話(結局何をpatchすればいいのか)

最近のクローラ周りのunittestを書いていたのですが、テスト速度の改善や安定化のためにmockpatchを使おうとして色々とハマったのでまとめておきます。

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.pyget_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_funcapp.pyで以下のように呼びだされています。

# coding: utf-8
:
import modules.lib1 as lib1
:
    return x + lib1.lib1_func()

importされたlib1app.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)

requestsapp.py内でグローバル変数として展開されていますのでapp.requests.getpatchに指定します。また、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等もしっかりつかってテストコードを充実させて行きたいと思います。