【pytest】モックの使い方まとめ
はじめに
下記のような処理の場合、そのまま実行させてテストするのが難しかったりします。
- 外部システムへアクセスする
⇒ 外部APIやスクレイピングしてたりする場合 - ライブラリに依存する処理
⇒ ライブラリの処理結果ごとのテストをしたい(戻り値、例外の種類での分岐など) - テスト実行の度に返す値が異なる処理を利用する
⇒ 現在日時、ランダム値は、実行の度に変化するのでテストの時だけ固定値にしたい
こういう場合、モックを使うと処理を差し替えられるので便利です。
使い方
前提
pytest
とpytest-mock
のライブラリを利用します。
pytest-mock
はunittest.mock
のラッパーです。書き方は異なるが、だいたいunittest.mock
と同じような感じです。
with
使ってpatch
する方法も検索すると出てきたが、1つのテストでここまでは実際の動きで、ここからはモックで動かす状況というのが個人的には想像できなかったので、モックのスコープがテストケースごとになるように書いてます。
基本
def func_main():
return func_hoge()
def func_hoge():
return [x for x in range(10)]
func_hoge()
をモックにして、func_main()
をテストする
def test_func_main(mocker):
mocker.patch("main.func_hoge", return_value=[1, 2])
print(main.func_main()) # [1, 2]
pytest-mock
をインストールすれば、fixtureでmocker
が使えるようになります。
差し替えたい処理をpatch
で指定してモックにします。
上記例では、戻り値としてreturn_value
を指定しています。戻り値がない場合は、指定しなくてもいいです。
注意:正しい名前空間で指定
import ~
と from ~ import ~
でインポートした場合とで、指定方法が異なります。
import os
def func_main():
return os.getcwd()
from os import getcwd
def func_main():
return getcwd()
getcwd
を差し替えるために指定するパスが変わります。
def test_func_main(mocker):
mocker.patch("os.getcwd", return_value="/var/app") # (1)の場合
mocker.patch("main.getcwd", return_value="/var/app") # (2)の場合
print(main.func_main()) # /var/app
で動作します
ここら辺の細かい説明は
を参照
関数/各種メソッド/プロパティの置き換え
import os
def func_main():
s = Sample()
print("")
print("ライブラリ呼び出し:" + os.getcwd())
print("関数呼び出し:" + str(func_sample()))
print("インスタンスメソッド呼び出し:" + str(s.half_value()))
print("プロパティ呼び出し:" + str(s.value))
print("クラスメソッド呼び出し:" + str(Sample.get_cvalue()))
print("スタティックメソッド呼び出し:" + str(Sample.get_svalue()))
print("")
def func_sample():
return 0
class Sample:
__cvalue = 30
def __init__(self):
self.__value = 20
def half_value(self):
return int(self.value / 2)
@property
def value(self):
return self.__value
@classmethod
def get_cvalue(cls):
return cls.__cvalue
@staticmethod
def get_svalue():
return 40
import main
def test_func_main(mocker):
m1 = mocker.patch("os.getcwd", return_value="/var/app")
m2 = mocker.patch("main.func_sample", return_value=1)
m3 = mocker.patch("main.Sample.half_value", return_value=11)
m4 = mocker.patch("main.Sample.value", return_value=21, new_callable=mocker.PropertyMock)
m5 = mocker.patch("main.Sample.get_cvalue", return_value=31)
m6 = mocker.patch("main.Sample.get_svalue", return_value=41)
main.func_main()
patch
する方法としては、関数でもメソッドでも同じです。
プロパティに関してはnew_callable=mocker.PropertyMock
を指定する必要があります。
後述するが、patch
の戻り値を利用すれと呼び出した回数/引数の検証などができる。
ダミークラスへ差し替え
各メソッドを個別に設定してもいいが、テスト共通で同じ挙動に差し替える場合とかであれば、ダミークラスを定義して差し替えてもよい
class DummyClass:
# 省略
pass
mocker.patch("main.Sample", new=DummyClass)
メソッドの呼び出しが正常に通過すればよいというだけであれば、何も指定しなくてもよい。
mocker.patch("main.Sample")
戻り値を設定する
常に同じ値
mocker.patch("main.func_sample", return_value=1)
実行回数によって変える
mocker.patch("main.func_sample", side_effect=[10, 20, 30])
何回目の実行かで、返す値を変えています。
1回目実行=10、2回目実行=20、3回目実行=30を返すように設定しています。
引数の値によって変える
mocker.patch("main.func_sample", side_effect=lambda x: False if x % 2 else True)
side_effect
は関数やラムダ式も指定できるので。引数に受け取った値で返す値を変えることもできます。
上記は、引数=1ならFalse、引数=2ならTrue、引数=3ならFalseを返すように設定しています。
例外を発生させる
mocker.patch("main.func_sample", side_effect=Exception("new exception"))
mockの実行結果の検証(実行回数)
import main
def test_func_main(mocker):
m = mocker.patch("main.func_sample", return_value=1)
main.func_main()
assert m.call_count == 3 # call_countで呼ばれた回数を取得
m.assert_called() # 少なくても1度は呼ばれた事の検証
m.assert_called_once() # 1回だけ呼ばれた事の検証
m.assert_not_called() # 1回も呼ばれていない事の検証
mockの実行結果の検証(引数)
import main
def test_func_main(mocker):
m = mocker.patch("main.func_sample", return_value=1)
main.func_main()
m.assert_called_with(1, 2, 3) # 最後に実行したモックの引数を検証
m.assert_called_with(1, 2, x=3, y=4) # 引数で位置、キーワード混在の場合
m.assert_called_once_with(1) # 指定した引数で一度だけ呼ばれたか検証
m.assert_any_call(2) # 指定した引数で一度でも呼ばれたか検証
# 複数回実行時に、それぞれの引数で順番に実行したか検証
# 1回目実行:引数=1, 2回目実行:引数=2, 3回目実行:引数=3を受け取っているか?
# ※ 順番に引数をチェックしているだけなので、実行回数はチェックしていない
m.assert_has_calls(
[mocker.call(1),
mocker.call(2),
mocker.call(3)]
)
# any_order=Trueを指定すると、順番を問わずとなる
# 指定した引数で呼び出したことがあるかを検証する
m.assert_has_calls(
[mocker.call(1),
mocker.call(2),
mocker.call(3)],
any_order=True
)
# 最後に実行した引数を取得(位置引数、キーワード引数それぞれのタプルで取得)
args, kwargs = m.call_args
# 実行した順に引数リストを取得
args_list = m.call_args_list
for args, kwargs in args_list:
pass
mocker.patchとmocker.patch.object の違い
詳しくは↑を参照。
基本的に、mocker.patch
を使ってれば事足りる気がする。
辞書の差し替え
mocker.patch.dict
を使う
d = {"a": 10, "b": 20, "c": 30}
def func_main():
print(d)
import main
def test_func_main(mocker):
# 一部上書き
mocker.patch.dict("main.d", {"c": 40})
main.func_main() # {"a": 10, "b": 20, "c": 40}
# 既存データをすべてクリアする場合
mocker.patch.dict("main.d", {"c": 40}, clear=True)
main.func_main() # {"c": 40}
環境変数の差し替え
mocker.patch.dict
を使って同じように、環境変数をパッチできる。
import os
def func_main():
return os.environ
def test_func_main(mocker):
mocker.patch.dict('os.environ', {'NEW_KEY': 'newvalue'})
ret = func_main()
assert ret["NEW_KEY"] == "newvalue"
日時の差し替え
日時取得しているような処理をテストしようとして、上記までと同じようにdatetime.datetime.now
をpatch
しようとすると…
TypeError: can't set attributes of built-in/extension type 'datetime.datetime'
とエラーが出てpatch
できないので、テストする方法をいろいろ調べた。
[1] freezegunを使う
pytest-freezegun
をインストールして、下記のように書けば日時を固定化できます。
import datetime
import pytest
def func_main():
return datetime.datetime.now()
@pytest.mark.freeze_time(datetime.datetime(2000, 1, 2, 3, 4))
def test_func_main(mocker):
assert func_main() == datetime.datetime(2000, 1, 2, 3, 4)
[2] datetimeオブジェクト自体を差し替え、各オブジェクトを設定していく
import datetime
def func_main():
return datetime.datetime.now()
def test_func_main(mocker):
expect = datetime.datetime(2000, 1, 2, 3, 4, 5, 6) # 差し替える前に予測値を取得しとく
mocker.patch("datetime.datetime", **{
"now.return_value": datetime.datetime(2000, 1, 2, 3, 4, 5, 6)
})
assert func_main() == expect
[3] 日時返却用のラッパー関数を作成する
[1][2]でもいいのだが、これだけのためにpytest-freezegun
を入れたくない、だからと言って[2]のような記述をいろんなところで書きたくない…
ってことで、datetime
オブジェクトを返すだけのラッパー関数を作成して使うようにするという方法もある。
import datetime
def get_now():
return datetime.datetime.now()
def func_main():
return get_now()
import main
import datetime
def test_func_main(mocker):
mocker.patch("main.get_now", return_value=datetime.datetime(2000, 1, 2, 3, 4, 5, 6))
assert main.func_main() == datetime.datetime(2000, 1, 2, 3, 4, 5, 6)
現在日付を取得する処理は、常にget_now
を使うようにする。そうすると、他の関数と同様の記述レベルでpatch
ができるようになる。
get_now
のテストケースだけ、[2]とかの方法で書けばいいと思う。
requestsライブラリのmock化
テストするたびに外部のサイトへバチバチアクセスしたら、迷惑すぎるので通信部分をモック化してみます。
import requests
def func_main(url):
res = requests.get(url)
res.raise_for_status()
return res.text
単純にリクエストして、レスポンスを返すだけの関数です。requests.get()をモック用のレスポンスを返す関数を作成して差し替えます。
import main
def mocked_requests_get(*args, **kwargs):
class MockResponse:
def __init__(self, text, status_code):
self.__text = text
self.__status_code = status_code
@property
def text(self):
return self.__text
def raise_for_status(self):
if self.__status_code != 200:
raise Exception("requests error")
if args[0] == 'url1':
return MockResponse("Hello World!", 200)
elif args[0] == 'url2':
return MockResponse("goodbye World!", 200)
return MockResponse(None, 404)
def test_func_main(mocker):
mocker.patch("requests.get", side_effect=mocked_requests_get)
print(main.func_main("url1")) # Hello World!
print(main.func_main("url2")) # goodbye World!
print(main.func_main("")) # 例外が発生
必要に応じて、使うメソッドをモックレスポンスに追加していけばいいと思います。
実際作る時は、通信部分をラップするクラスなどを作成して使うようにしたほうがテストが複雑にならずに済むのでいいと思う。
参考:
おわりに
日時の差し替え方法だけ調べてたはずが、なんやかんやあっていろいろ調べた…
ライブラリのモック化については、複雑なテストケースになりそうだったら使いやすくラップしたものを作ることも検討したほうがよさそうです。
間違いとかあったら教えてくださいm(_ _"m)
Discussion