サイトアイコン たーちゃんの「ゼロよりはいくらかましな」

【python】【pytest】モックを使用したテストのやり方

どのような言語であっても、単体テストをする場合には

モックを使用してテストすることが多いはず。

 

 

今回はpytestにおけるモックを使用したテストのやり方について

お話したいと思います。

 

 

 

単体テストにおけるモックの役割

ご存知の方も多いと思いますが、単体テストにおいて

なぜモックを使用するのか?ということについて一旦まとめておきます。

 

 

単体テストは確かにそれ自体で対象の動作をテストするために重要です。

ですが、それだけにとどまらずリグレッション(回帰テスト)のためにも

非常に重要になってきます。

ある修正を加えた時に、

修正の影響のないところはこれまでと同じ動作となるか?

もしくは

修正した内容が反映されているか?このあたりを確認する必要があるからです。

 

 

リグレッションテストの手法としては、CI(継続的インテグレーション)の一貫として

jenkinsなどのツールにおいて自動的に行われることが多いです。

 

 

その場合にjenkinsサーバがデータベースに接続したり、ある特定のフォルダにある

ファイルを読み書きするようなテストでは、初期設定に多大なコストが生じます。

 

 

そこでモックを使用することでデータベース接続やファイルの読み書きなどの

処理を仮定義してあげるテストとすることでこれらのコストが不要になります。

 

 

また、単体テストとしてみたときにテスト対象が呼び出すメソッドなんかは、

テストしたい処理とは切り離されて考えられるべきで、そのあたりもモックにして

扱います。

 

 

まとめると、単体テストにおけるモックの役割は

データベース接続や、他サーバのwebサービス、特定のフォルダのファイルの読み書き、

テスト対象から呼ばれるメソッドなど、

テスト対象とするものが他の環境に依存する処理を仮定義して、

実際にそれらの動作をすることなく、

テスト対象の処理そのものにフォーカスできるようにするため。

 

となるかと思います。

 

 

pytestにおけるモックの種類

pytestでは、MockFixture、monkeypathを使用することができ、

どちらも同じように使用できるので使い分けとかはないと思います。

 

個人的には、MockFixtureの方が記述の簡易に思いましたので、

そちらをお勧めします。

 

 

サンプルファイル構成

ファイル構成は以下のような感じです。

hoge

┣ fuga

┃ ┗ mock_target.py(モック対象)

┗ test_target.py(テスト対象)

 

 

モック対象のコード

mock_target.py

class MockClass:

    @classmethod
    def class_mock_method(self) -> str:
        return 'piyo'

    @classmethod
    def class_mock_method2(self, param: str) -> str:
        if param == 'a':
            return 'piyo'
        else:
            return 'piyopiyo'


def function_mock_method() -> str:
    return 'hogehoge'


def function_mock_method2(param: str) -> str:
    if param == 'c':
        return '123'
    else:
        return '456'

 

 

テスト対象のコード

test_target.py

from hogefuga.mock_target import (
    MockClass, function_mock_method, function_mock_method2
)


# classインポート
def class_target_method() -> str:
    return MockClass.class_mock_method()


# classインポート(引数あり)
def class_target_method2(param: str) -> str:
    return MockClass.class_mock_method2(param)


# 関数インポート
def function_target_method() -> str:
    return function_mock_method()


# 関数インポート(引数あり)
def function_target_method2(param: str) -> str:
    return function_mock_method2(param)

 

 

 

MockFixtureによるモック化

サンプルコード(固定戻り値)

from pytest_mock.plugin import MockFixture

import hoge.test_target as target


class Test:

    def test_class_target_method(self, mocker: MockFixture) -> None:

     # モック対象のメソッドがクラスメソッドの場合
        mocker.patch('hoge.fuga.MockClass.class_mock_method').return_value = 'test1'

     # モック対象のメソッドが関数の場合
        mocker.patch('hoge.test_target.function_mock_method').return_value = 'test1'

        ret = target.class_target_method()

        assert ret == 'test1'

 

クラスインポートのモック化では、テストメソッドの引数にMockFixtureを渡し、

mockの設定は

 

mock対象がクラスメソッドの場合、

mocker.patch(

‘mock対象のメソッドが定義されているクラスの完全パス.メソッド名’).return_value=戻り値

 

mock対象が関数の場合、

mocker.patch(

‘mock対象のメソッドを呼び出しているモジュール名.メソッド名’).return_value=戻り値

 

とします。

 

注意すべきなのは、モック対象のメソッドの定義のされ方により、

mockするための記述に違いがあるということです。

 

このサンプルではモックが有効となっているので、元のメソッドが’piyo’と返すところが、

‘test1’と返ることが確認できます。

 

 

サンプルコード(Exception発生)

import pytest
from pytest_mock.plugin import MockFixture

import hoge.test_target as target


class Test:

    def test_class_target_method_exception(self, mocker: MockFixture) -> None:

        mocker.patch('hoge.fuga.MockClass.class_mock_method').side_effect = Exception('testException')

        with pytest.raises(Exception) as e:
            target.class_target_method()

        assert e.value.args[0] == 'testException'

 

メソッドの結果でExceptionを発生させたい場合には、

return_valueではなく、side_effectを使用します。

この値に発生させたいExceptionをインスタンス化して設定することで、

メソッドが呼び出された際に指定のExceptionが発生します。

 

 

アサーションとしては、テスト対象の呼び出しで

with pytest.raises(対象のException) as e:として記載することで

Exceptionが発生しなかった場合にfailで落とすことができ、

その後でeに設定された内容をアサーションすることができるようになります。

 

 

サンプルコード(引数の値で結果を振り分ける)

import pytest
from pytest_mock.plugin import MockFixture

import hoge.test_target as target


class Test:

    def test_class_target_method_param(self, mocker: MockFixture) -> None:

        def param_select(param: str):
            if param == 'hoge':
                return 'b'
            else:
                return 'c'</strong>

        mocker.patch('hogefuga.MockClass.class_mock_method2').side_effect = param_select

        ret = target.class_target_method2('hoge')

        assert ret == 'b'

        ret = target.class_target_method2('fuga')

        assert ret == 'c'

 

テスト対象によっては、モックに渡す引数によって返却する値を変更したい場合が

あると思います。

その場合にはサンプルのように、渡ってきた値ごとに振り分ける関数(param_select)を

用意し、side_effectに設定してあげることで実現可能です。

 

また、振り分け判断処理が複雑でなければ、

関数を作成しなくても、side_effectに無名関数として、

以下のように渡してあげることもできます。

        mocker.patch('app.fuga.MockClass.class_mock_method2').side_effect = (
            lambda x: 'b' if x == 'hoge' else 'c'
        )

 

モックに渡された引数のアサーション

引数で判断する場合、想定していた引数がモックに渡ったのか確認したい場合も

出てくると思います。

その場合には以下サンプルのようにして確認します。

    def test_class_target_method_param(self, mocker: MockFixture) -> None:

        def param_select(param: str):
            if param == 'hoge':
                return 'b'
            else:
                return 'c'

        mock_obj = mocker.patch('app.fuga.MockClass.class_mock_method2')
        mock_obj.side_effect = param_select

        ret = target.class_target_method2('hoge')

        assert ret == 'b'

        ret = target.class_target_method2('fuga')

        assert ret == 'c'

        mock_args = mock_obj.call_args_list
        assert mock_args[0][0] == ('hoge',)
        assert mock_args[1][0] == ('fuga',)

後で引数を取得するために、

まずmocker.patchしたものを格納する変数を用意します。

 

テスト実行後に、call_args、もしくはcall_args_listを使用して取得します。

 

・call_args

最後にメソッドが呼ばれた時の引数情報が格納されます。

複数回呼ばれた場合は最後の情報のみが格納されています。

 

・call_args_list

呼ばれた順にリスト化され引数情報が格納されます。

 

引数情報は第1要素にタプルで格納されていて、今回の場合では、引数が1つのなので、

(‘hoge’,)のように格納されています。

 

複数の引数がある場合は、(‘hoge’, ‘fuga’,)のようになります。

名前付き引数の場合は(‘hoge’, param=’fuga’,)のようになります。

 

 

 

 

monkeypatchによるモック化

サンプルコード(固定戻り値)

from pytest_mock.plugin import MockFixture

import hoge.test_target as target
from hoge.fuga import MockClass


class Test:


    def test_function_target_method(self, monkeypatch) -> None:

     # モック対象のメソッドがクラスメソッドの場合
     monkeypatch.setattr(MockClass, 'class_mock_method', lambda: 'test2')

        # モック対象のメソッドが関数の場合
        monkeypatch.setattr(target, 'function_mock_method', lambda: 'test2')

        ret = target.function_target_method()

        assert ret == 'test2'

 

関数インポートのモック化では、テストメソッドの引数にmonkeypatchを渡し、

mockの設定は

 

mock対象がクラスメソッドの場合、

monkeypatch.setattr(

<モックが定義されたクラス名>, <モック対象メソッド名>, lambda: <戻り値>)

 

mock対象が関数の場合、

monkeypatch.setattr(<テスト対象オブジェクト>, <モック対象関数>, lambda: <戻り値>)

 

とします。

モックが有効となっているので、元のメソッドが’hogehoge’と返すところが、

‘test2’と返ることが確認できます。

 

lambdaとして無名関数を定義していますが、

モック対象関数に引数が必要な場合は、引数の数を合わせて定義する必要があります。

例えば、引数が2つので内部で使用する場合は

lambda x, y: 'test2'

といった記述が必要になりますが、

ほとんどの場合は、

lambda *_: 'test2'

といった記述で良いと思います。

 

 

また、ここには無名関数ではなく、

別途定義した関数を設定することも可能です。

    def test_function_target_method2(self, monkeypatch) -> None:

        def mock_return():
            return 'test2'

        monkeypatch.setattr(target, 'function_mock_method', mock_return)

        ret = target.function_target_method()

        assert ret == 'test2'

 

 

サンプルコード(Exception発生)

import pytest
from pytest_mock.plugin import MockFixture

import hoge.test_target as target


class Test:

    def test_function_target_method_exception(self, monkeypatch) -> None:

        def mock_return():
            raise Exception('testException')

        monkeypatch.setattr(target, 'function_mock_method', mock_return)

        with pytest.raises(Exception) as e:
            target.function_target_method()

        assert e.value.args[0] == 'testException'

 

Exceptionを発生させる場合には、Exceptionをraiseするメソッドを用意します。

lambdaでも以下のようにジェネレータ式を使用して記載することができるようですが、

throwするために何もしないジェネレータ式を使用するのはどうかな??と考えたため、

こちらの書き方がよいのかなと思いました。

lambdaでの記載についてはこちらを参照。

        monkeypatch.setattr(target, 'function_mock_method', (
            lambda: (_ for _ in ()).throw(Exception('testException'))
        ))

 

 

サンプルコード(引数の値で結果を振り分ける)

from unittest.mock import Mock

import pytest
from pytest_mock.plugin import MockFixture

import hoge.test_target as target


class Test:

    def test_function_target_method_param(self, monkeypatch) -> None:

        def param_select(param: str):
            if param == 'hoge':
                return 'b'
            else:
                return 'c'

        monkeypatch.setattr(target, 'function_mock_method2', param_select)

        ret = target.function_target_method2('hoge')

        assert ret == 'b'

        ret = target.function_target_method2('fuga')

        assert ret == 'c'

 

MockFixtureの場合と同様に引数により返却する値を振り分けるメソッドを

設定することになります。

 

 

モックに渡された引数のアサーション

monkeypatchを使用した場合は、モックされたオブジェクトの取得ができないため、

引数のアサーションをする場合には、別途モックオブジェクトを作成してあげる

必要があります。

from unittest.mock import Mock

import pytest
from pytest_mock.plugin import MockFixture

import hoge.test_target as target


class Test:

    def test_function_target_method_param2(self, monkeypatch) -> None:

        def param_select(param: str):
            if param == 'hoge':
                return 'b'
            else:
                return 'c'

        mock_obj = Mock()
        mock_obj.side_effect = param_select

        monkeypatch.setattr(target, 'function_mock_method2', mock_obj)

        ret = target.function_target_method2('hoge')

        assert ret == 'b'

        ret = target.function_target_method2('fuga')

        assert ret == 'c'

        mock_args = mock_obj.call_args_list
        assert mock_args[0][0] == ('hoge',)
        assert mock_args[1][0] == ('fuga',)

 

空のモックオブジェクトを定義し、

MockFixtureのときと同様にside_effectとして振り分けメソッドを設定し、

モックオブジェクト自体をmonkeypatchのモックメソッドとして定義することで

引数のアサーションを行うことができます。

格納される引数情報の内容はMockFixtureの場合と同じです。

 

 

 

まとめ

ということで、2種類のモック化についてケース別のやり方のお話でした。

久々にロングエントリーになっちゃいましたね。

 

モック化はテストでは重要なファクターなので、

参考になれば幸いです。

 

 

それでは!!

 

 

 


にほんブログ村


人気ブログランキング

モバイルバージョンを終了