はじめに
データアナリティクス事業本部の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
パッケージ内のdb
とauth
モジュールをimportしています。ログイン処理の流れはdb
のuser_info
関数でユーザー情報を取得しパスワードを検証し、パスワードが一致していたらauth
のgenerate_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_info
とauth.generate_access_token
をmocker.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_value
、side_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_value
、side_effect
、new
の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テストを書くことができるようになるかと思います。
最後まで読んで頂いてありがとうございました。