見出し画像

Python テストコード : Mock の苦手意識を克服しよう


はじめに


はじめまして。
SHIFT DAAE(ダーエ)テクノロジーグループの鎌田です。
pytest における Mock の使い方についてまとめました。依存が多くなりがちなコントローラーやハンドラー系の関数をテストする時は Mock を上手く使うことで効率を上げることができます。
本記事では、Mock を利用して依存を調整しながらテストする方法について具体例を交えて説明します。

対象読者

本記事は以下の方を対象としています。

  • unittest や pytest の基本的な使い方を理解している方

  • ドキュメントや技術ブログの通りに実装してみたもののうまくMockが動いてくれず苦労している方

注意事項

  • 本記事では、Patch の当て方や Mock の設定方法にフォーカスします。Spec や Wraps などには触れません。

サンプルコードの紹介


本記事で紹介するサンプルコードのファイル構成は、以下の通りです。

src
└── lambda_function.py
  lib
  ├── __init__.py
  ├── constants.py
  ├── database_manager.py
  ├── example_class.py
  └── query_data.py
tests
└── lambda_test.py

各パターンの解説

本記事では、以下のパターンについて具体的なサンプルコードを使って説明します。
Mock の理解を深め、デバッグでの時間ロスを減らすための参考にしてください。

  • クラスのインスタンス化

  • インスタンスメソッド、クラスメソッド、スタティックメソッド

  • プロパティ

  • 定数

  • コンテキストマネージャーを利用している変数

  • Immutable なモジュール(例:datetime.datetime.now())

  • from a import b と import a の違い

解説


それでは、具体例を紹介しながら説明していきます。

クラスのインスタンス化とメソッド、プロパティの Mock

まずは、クラスのインスタンス化やメソッド、プロパティの Mock についてです。

テスト対象の関数やメソッド内でインスタンス化されている場合、メソッドからインスタンスを取るわけではないので、Mock をどう使うか悩みませんか?
また、インスタンスメソッド・クラスメソッド・スタティックメソッドとさまざまですが、Mock 対象としての対応方法は異なります。
さらに、プロパティもメソッドと同じようには Mock できません。

まずは、サンプルコードです。

# src/lib/example_class.py
class ExampleClass:
    def __init__(self, name):
        self._name = name

    @property
    def name(self):
        return self._name

    @classmethod
    def class_method_example(cls):
        return "class method called"

    @staticmethod
    def static_method_example():
        return "static method called"

    def instance_method_example(self):
        return f"instance method called by {self.name}"

# src/lambda_function.py (抜粋)
import json
from lib.example_class import ExampleClass

def lambda_handler(event, context):
    example_instance = ExampleClass(name="LambdaUser")

    response = {
        "instance_method": example_instance.instance_method_example(),
        "class_method": ExampleClass.class_method_example(),
        "static_method": ExampleClass.static_method_example(),
        "property": example_instance.name,
    }

    return {"statusCode": 200, "body": json.dumps(response)}

そして、テストコードです。

# tests/lambda_test.py (抜粋)
from lambda_function import lambda_handler

def test_lambda_handler(mocker):

    # インスタンス化
    mock_instance = mocker.MagicMock()
    mocker.patch("lambda_function.ExampleClass", return_value=mock_instance)

    # インスタンスメソッド
    mock_instance.instance_method_example.return_value = "instance_method_2"

    # クラスメソッド
    mocker.patch(
        "lambda_function.ExampleClass.class_method_example",
        return_value="class_method_2",
    )

    # スタティックメソッド
    mocker.patch(
        "lambda_function.ExampleClass.static_method_example",
        return_value="static_method_2",
    )

    # プロパティ
    type(mock_instance).name = mocker.PropertyMock(return_value="property_2")

ソースコードは、インスタンスメソッド・クラスメソッド・スタティックメソッド・プロパティを持ったクラスが 1 個と、それをインスタンス化し、それぞれのメソッド、プロパティを呼び出してレスポンスにセットしている lambda_handler のみ。
実際には、後述する他要素が関わるコードも入ってきますが、ここでは関係する部分だけを抜粋しています。

それぞれを見ていきます。

インスタンス化、インスタンスメソッド、プロパティ

インスタンスを Mock にするには、対象クラス自体に patch を当てています。
ここでreturn_valueにセットしたものが、クラスをインスタンス化する時に、返されます。
実際のところ、return_valueに MagicMock インスタンスをセットしなくても、デフォルトで MagicMock インスタンスは返されるのですが、インスタンスメソッド・プロパティの設定をしたいので、あえてインスタンスを確保しています。

次にインスタンスメソッドですが、こちらはよく見るスタイルだと思います。
インスタンスのメソッドの戻り値に対して、固定値をセットする手法です。

# インスタンスメソッド
mock_instance.instance_method_example.return_value = "instance_method_2"

インスタンスに対する操作として、最後になるのがプロパティの設定です。
プロパティは、メソッドとは違う点があります。
まず、メソッドと同じように直接プロパティを指定できません。Mock の型に対して設定する必要があります。そして、MagicMock ではなく、PropertyMock を使います。戻り値についてはメソッドと同じようにreturn_valueでセットできます。

# プロパティ
type(mock_instance).name = mocker.PropertyMock(return_value="property_2")

クラスメソッド、スタティックメソッド

クラスメソッドとスタティックメソッドを分けましたが、Mock の設定方法としては、基本的にインスタンスメソッドと変わりません。インスタンスメソッドは、クラスに対して patch を当てて、戻り値となるインスタンスにメソッドを設定しました。
それに対して、クラスメソッド・スタティックメソッドは patch するターゲットのパスが変わるだけです。

# クラスメソッド
mocker.patch(
    "lambda_function.ExampleClass.class_method_example",
    return_value="class_method_2",
)

# スタティックメソッド
mocker.patch(
    "lambda_function.ExampleClass.static_method_example",
    return_value="static_method_2",
)

定数

Mock を使うのは、メソッドを対象にすることが多いので、意外と定数を忘れていることがあるかもしれません。

サンプルコードでは、次のように lambda_handler で constants から TEISUU という定数をインポートして、レスポンスにセットしています。

# src/lib/constants.py
TEISUU = "定数"

# src/lambda_function.py (抜粋)
import json
from lib.constants import TEISUU

def lambda_handler(event, context):
    response = {
        "teisuu": TEISUU,
    }

    return {"statusCode": 200, "body": json.dumps(response)}

続いて、テストコードです。

# tests/lambda_test.py (抜粋)
from lambda_function import lambda_handler

def test_lambda_handler(mocker):
    mocker.patch("lambda_function.TEISUU", "定数-2")

定数はインポートしているモジュール+定数名で指定できます。クラスを Mock する時と同じです。
メソッドの時と違うのが、return_valueを使いません。
直接値をセットするか、newでセットできます。

# `new=`を使う場合
mocker.patch("lambda_function.TEISUU", new="定数-2")

コンテキストマネージャーを利用している変数

I/O 関連の操作などでコンテキストマネージャーを使用することがあると思います。
この場合は、withが管理している変数自体ではなく、マジックメソッドで設定します。

次のサンプルコードでは、psycopg2 の SimpleConnectionPool を利用したコネクション管理クラスをwithと共に使用し、クエリ結果を呼び元に返すヘルパー関数を作成しています。

サンプルコードはこちら

# src/lib/database_manager.py
import os

from psycopg2.pool import SimpleConnectionPool

# データベース接続プール
DB_USER = os.getenv("DB_USER")
DB_PASSWORD = os.getenv("DB_PASSWORD")
DB_HOST = os.getenv("DB_HOST")
DB_NAME = os.getenv("DB_NAME")

connection_pool = None

def get_connection_pool():
    global connection_pool
    if connection_pool is None:
        connection_pool = SimpleConnectionPool(
            1,
            20,
            user=DB_USER,
            password=DB_PASSWORD,
            host=DB_HOST,
            database=DB_NAME,
        )
    return connection_pool

class DatabaseManager:
    def __enter__(self):
        connection_pool = get_connection_pool()
        self.conn = connection_pool.getconn()
        self.cursor = self.conn.cursor()
        return self.cursor

    def __exit__(self, exc_type, exc_val, exc_tb):
        connection_pool = get_connection_pool()
        self.conn.commit()
        self.cursor.close()
        connection_pool.putconn(self.conn)


# src/lib/query_data.py
from .database_manager import DatabaseManager

def query_data(query):
    with DatabaseManager() as cursor:
        cursor.execute(query)
        result = cursor.fetchall()
    return result


# src/lambda_function.py (抜粋)
import json
from lib.query_data import query_data

def lambda_handler(event, context):
    result = query_data("SELECT NOW()")

    response = {
        "database_time": result,
    }

    return {"statusCode": 200, "body": json.dumps(response)}

続いて、テストコードです。

# tests/lambda_test.py (抜粋)
from lambda_function import lambda_handler

def test_lambda_handler(mocker):
    mock_cursor = mocker.MagicMock()
    mock_cursor.__enter__ = mocker.MagicMock(return_value=mock_cursor)
    mock_cursor.__exit__ = mocker.MagicMock()
    mock_cursor.fetchall.return_value = [("2024-01-01 00:00:00",)]
    mocker.patch("lib.query_data.DatabaseManager", return_value=mock_cursor)

コンテキストマネージャーを使用する場合は、__enter__、__exit__を利用します。
コンテキストマネージャーが__enter__を呼び出すので、その時に用意した Mock が返されるようにします。

それ以外は他のメソッドと変わりませんが、例外のテストをする時は注意が必要です。
先ほどの例では、__exit__に mocker.MagicMock() をセットしていますが、この状態でブロックの中で例外が発生した場合、呼び出し元まで例外は伝播されません。
例外の発生をテストしたい場合は、次のようにする必要があります。

    mock_cursor = mocker.MagicMock()
    mock_cursor.__enter__ = mocker.MagicMock(return_value=mock_cursor)
    # mock_cursor.__exit__ = mocker.MagicMock()
    mock_cursor.__exit__ = mocker.MagicMock(return_value=False)
    mock_cursor.fetchall.side_effect = psycopg2.OperationalError
    mocker.patch("lib.query_data.DatabaseManager", return_value=mock_cursor)

これは、__exit__が True(として判定される値)を返すと、ブロックの中で発生した例外の伝播を抑止できるようになっているためです。 そのため、ブロックの中で発生した例外をテストしたい場合は、False と判定される値を返す必要があります。

参考までに、上記コードで side_effect により raise させた例外は、次のようにチェックできます。

with pytest.raises(Exception) as e:
    # 例外が発生する処理
    _ = lambda_handler(event, context)
assert type(e.value) is psycopg2.OperationalError

Immutable なモジュールと、from/import と import の違い

テストコードを書く時に、日時に依存した値を確認する機会があると思います。
たとえば、当日の処理を行う部分で、次のように記述した場合、例外が発生します。

mock_now = mocker.patch("datetime.datetime.now")
#-> TypeError: cannot set 'now' attribute of immutable type 'datetime.datetime'

テストコードで日時を扱う一般的な方法として pytest-freezegun を利用することがありますが、今回は pytest-freezegun を使わずに解決する方法を説明します。

まず、サンプルコードです。

# src/lambda_function.py (抜粋)
import json
import datetime

def lambda_handler(event, context):
    now = datetime.datetime.now()
    current_time = now.isoformat()

    response = {
        "current_time": current_time,
    }

    return {"statusCode": 200, "body": json.dumps(response)}

続いて、テストコードです。

# tests/lambda_test.py
from datetime import datetime

from lambda_function import lambda_handler

def test_lambda_handler(mocker):
    mock_datetime = mocker.patch("datetime.datetime")
    mock_datetime.now.return_value = datetime(2024, 2, 2)
    mock_datetime.isoformat = datetime.isoformat

先ほどの例外が発生していたコードと違うのが、patch ターゲットを、datetime.datetimeに変更しています。
このケースでは、now メソッドを Mock にできないので、そのオブジェクト自体を Mock にする必要があります。
datetime を Mock にして、now メソッドを設定しています。こうすることで、問題を解決してるのですが、このサンプルにはまだ、おもしろい点があります。

now メソッドの戻り値に、datetime クラスのインスタンス。isoformat メソッドに対して、isoformat メソッド自体を指定しています。ここで興味を持っていただきたいのが、なぜdatetime.datetimeに patch を当てているのに、datetime オブジェクトが普通に使えるのか?ということです。

これを可能にしているのが、import 方法の違いです。
テスト対象の関数では、import datetimeとしていますが、テストコードでは、from datetime import datetimeとしています。この違いによって、参照するパスが違うので、テストコードではオリジナルの datetime を使うことができています。もし、テストコードでもimport datetimeとすると、datetime(yyyy, mm, dd)は Mock になってしまい、datetime オブジェクトにはならなくなってしまいます。

もう一点は、mock_datetime.isoformat = datetime.isoformatこの部分です。
これは何をしているのかというと datetime オブジェクトを Mock にしてしまっているので、Mock のメソッドは当然 Mock を返すのですが、ここはオリジナルの isoformat メソッドを使いたいところです。
そこで、参照パスを変えることでテストコードではオリジナルを参照させて、部分的に Mock ではなくオリジナルのメソッドへの差し替えを実現しています。
他にも実現方法はありますが、部分的な戻し方としてはこれが簡単です。

トラブルシューティング


Mock がうまく機能しない場合、以下のポイントを確認してください

  • patch 対象が正しく Mock になっているか?

  • 意図せず Mock にしてしまっている部分がないか?

patch が上手く当たらず、オリジナルのコードが動いてしまって外部への依存や I/O によるエラーが起きてしまっている場合は、IDE のデバッグモードを使用して、対象のクラスやメソッドが Mock になっているか確認するのがオススメです。

デバッグモードで対象のクラスやメソッドを確認すると、オリジナルの実装なのか、Mock になっているのか確認できます。

たとえば datetime の例を使いますが、

now = datetime.datetime.now()
current_time = now.isoformat()

このコードに対して、デバッグモードでnow()とisoformat()を確認すると、now()は MagicMock でisoformat()はオリジナルであることが分かります。

確認するポイントは、次の 2 点です。

  • 対象が MagicMock に差し替わっているかどうか?

  • MagicMock の name に設定されている値が想定通りか?

MagicMock に差し替えられていない場合、from/importかimportかの違いによることが多いです。
importの場合はオリジナルを参照しますが、from/importの場合は対象モジュールがパスの起点になるので、patch ターゲットを見直すと解決する可能性があります。

MagicMock に差し替えられているのに、戻り値が上手く制御できていない場合は、nameプロパティを確認してください。
nameに patch ターゲットに指定したパスとは違う値が入っていたり、function()()のように括弧が複数付いていることがあります。その場合は、patch ターゲットのパスを間違えていたり、return_valueを設定するポイントを間違えている可能性があるので、Mock を見直すと良いかもしれません。

終わりに


本記事では、pytest-mock を使用した Mock の設定方法について具体例とともに解説しました。Mock の重要性を理解し、適切に Mock を当てることで、テストコードの信頼性と可読性を向上させることができます。
Mock を上手く活用することで、効率的にテストを行うことができます。サーバーエラーなども手元で簡単に再現できるため、早い段階でエラーハンドリングの妥当性を確認できます。

Mock に苦手意識がある方も、今回の記事を参考に、ぜひ実際のプロジェクトで試してみてください。

\もっと身近にもっとリアルに!DAAE 公式 X/


執筆者プロフィール:鎌田 慶彦
DAAEテクノロジーグループ所属エンジニアです。
Pythonの面白さが少し分かってきました。

お問合せはお気軽に
https://service.shiftinc.jp/contact/

SHIFTについて(コーポレートサイト)
https://www.shiftinc.jp/

SHIFTのサービスについて(サービスサイト)
https://service.shiftinc.jp/

SHIFTの導入事例
https://service.shiftinc.jp/case/

お役立ち資料はこちら
https://service.shiftinc.jp/resources/

SHIFTの採用情報はこちら
https://recruit.shiftinc.jp/career/

PHOTO:UnsplashTai Bui