pytest-mockでモックを使ってみる

2024.03.31

はじめに

データアナリティクス事業本部のkobayashiです。

Smallテストを行う場合には1つのプロセス中で実行されることが原則であるため外部リソースに依存するような処理を使った場合にはその部分をモック化してテストを実行する必要があります。Pytestではpytest-mockを使うことでテスト対象のシステムの部分をモックオブジェクトに置き換えてSmallテストを実行できます。今回はpytest-mockでmocker.patchを使ってみたのでその内容まとめます。

pytest-mockとは

pytest-mockは、pytestにおいてモック(Mock)やスタブ(Stub)などのテストダブルを簡単に利用できるようにする拡張機能です。Smallテストを行う際にテスト対象のコードが使用している外部コンポーネントや環境を仮想化したり制御可能な状態にしたりする必要がありますが、 pytest-mockではpytestを実行する際に自動的にモックが提供されテスト関数内で利用できるようになります。

pytest-mockを使用することで、テストコードをより効果的に書くことができます。テストダブルを使って外部リソースに依存せずにテストを行うことでSmallテストの信頼性を高め、バグを早期に発見することができまようになります。

pytest-mockを使ってみる

環境

  • Python: 3.11.4
  • pytest: 7.4.3
  • pytest-mock: 3.14.0

テスト対象の関数

テストしたい関数は以下のログインを行う処理になります。

sample.py

from datetime import datetime, timedelta

from util import db, auth

def login(username: str, password: str):
    # ログイン処理をして成功したらユーザー情報を取得する
    user = db.user_info(username)
    if user["password"] == password:
        # ユーザー情報からAccessトークンを作成する
        payload = {
            "exp": datetime.utcnow() + timedelta(days=0),
            "iat": datetime.utcnow(),
            "user_id": user_id,
        }
        access_token = auth.generate_access_token(payload, "SECRET_KEY_123456789")
        return "login ok", user, access_token

    return "login ng", None, None

何箇所か現実的にはこんな実装はしないだろうという処理がありますが、あくまでモック化を行う対象関数のサンプルであるのでご容赦下さい。

特徴としては自作のutilパッケージ内のdbauthモジュールをimportしています。ログイン処理の流れはdbuser_info関数でユーザー情報を取得しパスワードを検証し、パスワードが一致していたらauthgenerate_access_token関数でアクセストークンを取得して、最終的に成功のメッセージ、ユーザー情報、アクセストークンをタプルで返しています。

この中で利用しているdb.user_infoでは外部のDatabaseへの接続があり、auth.generate_access_tokenではトークン発行情報をDatabaseへ保存しているためSmallテストを行う際にはこれらの関数をモック化する必要があるのでいろいろな方法でモック化してみます。

pytest-mockを使ったモック化

pytest-mockでモック化を行う際にはreturn_valueで固定値を戻り値として返す方法とside_effectで戻り値の振る舞いを制御することができます。

return_valueで固定値を返す

では、はじめにreturn_valueで固定値を戻り値として返す用にテストを作成してみます。

import pytest
from pytest_mock import MockFixture

import sample


def test_retval(mocker: MockFixture):
    # given
    mock_login = mocker.patch(
        "sample.db.user_info",
        return_value={
            "user_id": "user-id-123456789",
            "user_name": "taro",
            "role": "admin",
            "password": "password1111",
        },
    )
    mock_genat = mocker.patch(
        "sample.auth.generate_access_token", return_value="access_token_string"
    )

    # when
    msg, user, access_token = sample.login("login_id_01", "password1111")

    # then
    assert mock_login.call_count == 1
    assert mock_genat.call_count == 1
    assert msg == "login ok"
    assert user == {
        "user_id": "user-id-123456789",
        "user_name": "taro",
        "role": "admin",
        "password": "password1111",
    }

テスト内容としてはdb.user_infoauth.generate_access_tokenmocker.patchでモック化しています。mocker.patchの記述方法はsample.pyでimportされているdbモジュールのuser_infoをモック化するため

mock_login = mocker.patch(
        "sample.db.user_info",
        return_value={
            "user_id": "user-id-123456789",
            "user_name": "taro",
            "role": "admin",
            "password": "password1111",
        },
    )

といった記述になります。またモック化した関数が確実に呼ばれているかのテストも行うためassert mock_login.call_count == 1で関数が使われた回数をチェックしています。

side_effectで戻り値を制御する

次にside_effectを使って戻り値の振る舞いを制御してみます。

import pytest
from pytest_mock import MockFixture

import jwt

import sample

def test_side_effect(mocker: MockFixture):
    # given
    mock_login = mocker.patch(
        "sample.db.user_info",
        return_value={
            "user_id": "user-id-123456789",
            "user_name": "taro",
            "role": "admin",
            "password": "password1111",
        },
    )
    mock_genat = mocker.patch(
        "sample.auth.generate_access_token", side_effect=lambda payload, secret_key: jwt.encode(payload, secret_key, algorithm="HS256")
    )

    # when
    msg, user, access_token = sample.login("login_id_01", "password1111")

    # then
    assert mock_login.call_count == 1
    assert mock_genat.call_count == 1
    assert msg == "login ok"
    assert user == {
        "user_id": "user-id-123456789",
        "user_name": "taro",
        "role": "admin",
        "password": "password1111",
    }

db.user_infoは前項とおなじreturn_valueで固定値を返していますが、auth.generate_access_tokenではトークン発行情報をDatabaseへ保存しているためこの保存処理は省略しつつ、アクセストークンの発行自体は実際の関数と同じ方法で戻り値を返すようなモック化を行っています。

    mock_genat = mocker.patch(
        "sample.auth.generate_access_token", side_effect=lambda  payload, secret_key: jwt.encode(payload, secret_key, algorithm="HS256")
    )

side_effectを任意の処理に差し替える場合のポイントとしてはside_effectに差し替える関数と同じ数だけの引数を持ったlambda式を使うことで置き換えることができます。lambda式を使わない場合は以下のように関数を作成してからside_effectに指定することもできます。

    def moc_func(payload, secret_key):
        return jwt.encode(payload, secret_key, algorithm="HS256")

    mock_genat = mocker.patch(
        "sample.auth.generate_access_token", side_effect=moc_func: 
    )

side_effectで例外を送出する

またside_effectではモックの呼び出し時に例外を発生させることができます。

def test_side_effect_value_error(mocker: MockFixture):
    # given
    mock_login = mocker.patch(
        "sample.db.user_info", side_effect=ValueError("バリューエラーです")
    )

    # when
    with pytest.raises(ValueError) as e:
        msg, user, access_token = sample.login("login_id_01", "password1111")

    # then
    assert mock_login.call_count == 1
    assert e.value.args[0] == "バリューエラーです"

side_effectに例外を指定することでside_effect呼び出し時に強制的に例外処理を発生させることができるのでエラー時の処理をテストすることかできます。

pytest-mockの使い方パターンを考えてみる

上記のテストの記述方法を踏まえて自分なりのpytest-mockの使い方パターンをまとめてみたいと思います。今回は関数(関数)をモック化することを考えてreturn_valueside_effectで戻り値を制御していましたが、newを使ってクラスをモック化することもできます。

sample2.py

# テスト対象関数
def hogehoge():
    ret_a = util.sample_class.func_a("aaaa")
    ret_b = util.sample_class.func_b("bbbb")
    
    return ret_a, ret_b
def test_hogehoge(mocker: MockFixture):
    class mock_class():
        def func_a(self,val):
            ...
        def func_b(self,val):
            ...
    mock_sample_class = mocker.patch("sample2.util",new=mock_class())
    ...

これを踏まえreturn_valueside_effectnewの3パターンの記述方法を以下のように使い分けるのがベストだと思います。

  • return_valueで戻り値を固定化するモック化:固定値を返す場合に使う
  • side_effectで戻り値を制御するモック化::関数をモック化したいが返す値が固定値にできない場合や例外を送出したい場合に使う
  • newでクラスをモック化:クラスの関数が多数呼び出される場合や、インスタンス化をする際に外部リソースに依存する処理がある場合に使う

したがって使う優先順位としては

戻り値をモック化 ≥ 関数をモック化 >> クラスをモック化

が使いやすく、したがってクラスのモック化は以下の基準で使うと良いのではないでしょうか。

  • テスト対象の関数で多数数の関数を使っている
  • インスタンス化をする(__init__ で)際に外部リソースに依存してからインスタンス関数を呼び出す

また、モック化を行った場合はcall_countでテスト内でモックが適切に使われているかを確認することを推奨します。

# 戻り値をモック化
mock_value = mocker.patch("aaaa.get_hello", retrun_value="hello")
assert mock_value.call_count == 1

# 関数をモック化
mock_func = mocker.patch("aaaa.update_greeting",side_effect=lambda x: x+100)
assert mock_func.call_count == 1

# クラスをモック化
mock_class = mocker.patch("aaaa.ClassGreeting", new=MokcClassGreeting())
mock_spy_func1 = mocker.spy(mock_class, "say_hello")
mock_spy_func2 = mocker.spy(mock_class, "say_bye")
mock_spy_func3 = mocker.spy(mock_class, "say_happy")
mock_spy_func4 = mocker.spy(mock_class, "say_sad")
assert mock_spy_func1.call_count == 1
assert mock_spy_func2.call_count == 1
assert mock_spy_func3.call_count == 1
assert mock_spy_func4.call_count == 1

まとめ

pytest-mockを使うことでテスト対象のシステムの部分をモックオブジェクトに置き換えてSmallテストを実行する方法を検証しました。何パターンか使い方がありますがそれぞれ適切な使い方をすることで効率的なSmallテストを書くことができるようになるかと思います。

最後まで読んで頂いてありがとうございました。