pytest, mock, patchについて整理したい
pytest、unittest.mock.patchらへんでハマっていたのでメモ。
整理したかったこと
- 同じ機能で、書き方が何種類もあること
- 引数の使い分け
注: このページでは同じものを別の名前で指しているかもしれないです(例:package, module, library という単語は同じものを指すことがある)
テストモジュールについて
- unittest ... Pythonビルトインのテストpackage
- pytest ... 3rd partyのテストpackage。unittestよりよく使われていて主流らしい
- unittestをそのまま使える
- importしなくても使える(pytest.xxxを使わないとき)
mock について
オブジェクト(何でも)を自由に置き換えるもの
- unittest.mock ... unittestのモジュール
- pytest-mock ... pytestで
mocker
フィクスチャが使える - moto ... AWSリソースのためのmock (ここでは扱わない)
参考にしたもの
Python公式のMock
RealPython コードはここから拝借しています(MITライセンス)
ゼロから学ぶ Python のpytestのページ
pytest-mockについてはこちらが丁寧でよかったです
同じような比較があります。こちらも参考になりました。
ディレクトリ
このページでは、慣例とは違いますが以下のように同じディレクトリmy_test/
に入れます。すべてこのディレクトリ上で実行しています。
my_test/
├── my_calendar.py
└── test_my_calendar.py
以下に出てくるコードはRealPythonから取ってきたものです。
-
get_holidays()
は、ローカルのAPIhttp://localhost/api/holidays
にリクエストする関数。テストでは、このAPIを使わないでいいようにmockする。requestsをインポートして使っているが、これを置き換える。
from datetime import datetime
import requests
def get_holidays():
r = requests.get('http://localhost/api/holidays')
if r.status_code == 200:
return r.json()
return None
テスト関数
- 以下はunittestでのコードで、これをpytest、with文、mockerなどを使って書き換えて比較する
- my_calendar.pyの中のrequestsモジュールをmock.patchで置き換える(後述)
import unittest
from unittest.mock import patch
from requests.exceptions import Timeout
from my_calendar import get_holidays
class TestCalendar(unittest.TestCase):
@patch('my_calendar.requests')
def test_get_holidays_timeout(self, mock_requests):
mock_requests.get.side_effect = Timeout
with self.assertRaises(Timeout):
get_holidays()
mock_requests.get.assert_called_once()
if __name__ == '__main__':
unittest.main()
unittest と pytest
unittestで実行する
python test_my_calendar.py
出力
.
----------------------------------------------------------------------
Ran 1 test in 0.001s
OK
pytestで実行する
クラスのunittest.TestCase
とif __name__ == "__main__":
は消してしまってもよい。
pytestではstdoutが見えないとか、トレースバックが長過ぎるので、オプションをつけています。
pytest -s --no-header --tb=short test_my_calendar.py
出力
=========...========== test session starts =============
collected 1 item
test_my_calendar.py .
=========...========== 1 passed in 0.07s =============
mock
「パッチ」と「モンキーパッチ」という言葉は同じ意味で使われる
起源などはwikipediaに書いてある。
MagicMockはMockのサブクラス
実際にはMagicMock が呼び出されている。
違いについては[python] まだmockで消耗してるの?mockを理解するための3つのポイントに詳しく書いてありました。mockにmagicメソッドを追加したものだということでしょうか。
上記のtest_my_calendar.py でprint出力してみると、
@patch('my_calendar.requests')
def test_get_holidays_timeout(self, mock_requests):
mock_requests.get.side_effect = Timeout
print(mock_requests)
print(type(mock_requests))
こういうのが出力される。typeはMagicMock。
<MagicMock name='requests' id='140005106486336'>
<class 'unittest.mock.MagicMock'>
mock.patch()
と patch()
これはインポートの仕方が違うだけ。たまに混ざっているかも。
from unittest import mock
from unittest.mock import patch
mock、patchの引数
(内容薄め)
from unittest.mock import patchでの
patch.object
でのreturn_valueをnewの違いはAppendixの項目参照
unittest.mock.Mock
class unittest.mock.Mock(spec=None, side_effect=None, return_value=DEFAULT, wraps=None, name=None, spec_set=None, unsafe=False, **kwargs)
前述のようにMock(MagicMock)のクラスができていて、引数を渡して差し替えることになります。
return_value
モックが呼び出された際に返す値を設定
side_effect
モックが呼ばれた際に呼び出される関数、イテラブル、もしくは発生させる例外 (クラスまたはインスタンス) を設定
unittest.mock.patch
unittest.mock.patch(target, new=DEFAULT, spec=None, create=False, spec_set=None, autospec=None, new_callable=None, **kwargs)
patchの書き方
@patch('my_calendar.requests')
def test_get_holidays_timeout(self, mock_requests):
mock_requests.get.side_effect = Timeout
with self.assertRaises(Timeout):
get_holidays()
mock_requests.get.assert_called_once()
- patchするターゲットのパス'my_calendar.requests'をデコレータに渡す
- この引数は文字列
- このターゲットはtest関数の中ではmock_requestsという名前で使うと定義
- side_effectで、mockが呼ばれたときの動作を設定
デコレータとwith 文
書き方が違うが機能は同じ。
test_my_calendar.pyのpatchの部分だけを取り出すとこうなる。
# decorator
@patch('my_calendar.requests')
def test_get_holidays_timeout(self, mock_requests):
mock_requests.get.side_effect = Timeout
これをwith を使って書くとこうなる。
# with
def test_get_holidays_timeout(self):
with patch('my_calendar.requests') as mock_requests:
mock_requests.get.side_effect = Timeout
patch.object
patch.object(target, attribute, new=DEFAULT, spec=None, create=False, spec_set=None, autospec=None, new_callable=None, **kwargs)
オブジェクトの1つのメソッドだけを差し替えたいとき、patch.objectが使える。
import unittest
from my_calendar import requests, get_holidays
from unittest.mock import patch
class TestCalendar(unittest.TestCase):
@patch.object(requests, 'get', side_effect=requests.exceptions.Timeout)
def test_get_holidays_timeout(self, mock_requests):
with self.assertRaises(requests.exceptions.Timeout):
get_holidays()
if __name__ == '__main__':
unittest.main()
- ターゲットのパスではなくて、オブジェクトそのものを第1引数にする。第2引数がmockしたい属性
- 第1引数が、文字列ではないことに注意(patchとpatch.objectの違い)
- 例えば、上記で、'requests'すると、エラーになる。
@patch.object('requests', 'get', side_effect=requests.exceptions.Timeout)
E TypeError: 'requests' must be the actual object to be patched, not a str
pytest-mock
pytest-mockを別途インストールすると、mockerフィクスチャが使えます。mockerを使って書くと、少し省略されて短く書けます。
import pytest
from requests.exceptions import Timeout
from my_calendar import get_holidays
class TestCalendar:
def test_get_holidays_timeout(self, mocker):
mocker.patch('my_calendar.requests.get', side_effect=Timeout)
with pytest.raises(Timeout):
get_holidays()
mocker.assert_called_once()
pytest monkeypatch
pytestにデフォルトでついているフィクスチャ機能です。属性、辞書、環境変数を変えたりできます。
こちらは上記で同じ書き方をしているタイムアウトのエラーは出せなかったので、正常系の書き方です(できるのかもしれないですが)。MockResponse
でMockの返すクラスを定義しておいて、mock_get()
で返しています。monkeypatch.setattr(requests, "get", mock_get)
とかくと、requestのgetがmock_getに置き換わる、ということになります。
import requests
import my_calendar
class MockResponse:
def __init__(self):
self.status_code = 200
@staticmethod
def json():
return {"status_code": 200}
def test_get_json(monkeypatch):
def mock_get(*args, **kwargs):
return MockResponse()
monkeypatch.setattr(requests, "get", mock_get)
result = my_calendar.get_holidays()
assert result["status_code"] == 200
まとめ
- pytestのやり方を比較してみました。ややこしいですね。
- 他の人のコードを見るときに、「別の書き方ができる」ということを思い出して注意すれば大丈夫かも?
Appendix
定数をmockしたい
こちらが参考になります。
ちょっとハマったのでメモ。定数のインポートのやり方に気をつける必要がありそうです。
xxx/my_const.py に 定数が入っているとします。
MY_CONST = 100
テストファイル
from xxx import my_const
@patch.object(my_const, 'MY_CONST', new=1) # MYCONSTは ''必要
def test_my_const(self):
some_code()
Mockが失敗するがエラーにはならない
from xxx.my_const import MY_CONST
...
print(MY_CONST) # 100。差し替えられていない
Mock成功
from xxx import my_const
...
print(my_const.MY_CONST) # 差し替えOK
ちなみに、この2つの書き方は同じです。
@patch.object(my_const, 'MY_CONST', new=1)
@patch('xxx.my_const.MY_CONST', new=1)
メソッドに引数が正しく渡されたかを確認したい
my_mock
のcall_args
の中身をassertして、mockに渡された引数を確認できます。return=
などがあっても同じです。
with patch.object(MYCLASS, 'my_method') as my_mock:
# some code ...
mock_args = my_mock.call_args[0]
assert mock_args[0] == "arg0"
assert mock_args[1] == "arg1"
...
他の例:
call_args は次のように構成されています:
call_args.args: 非キーワード引数のタプル
call_args.kwargs: キーワード引数の辞書
kwargs = my_mock.call_args.kwargs
assert kwargs.get('some_arg') == xxxxx
patch.objectのreturn_valueとnewの違い
-
patch.object(MyClient, "notify", ...)
はMyClientのnotifyを置き換えるという意味 - return_valueでは関数の実行を飛ばして結果を置き換える。
hello from notify()
は出力されない - newでは関数を置き換える。置き換えた関数は実行される。
hello from mock_notify_method()
が出力される
from unittest.mock import patch
# モック化するためのサンプルクラスとメソッド
class MyClient:
def notify(self, message="Original Notify message"):
print("hello from notify()")
return message
mock_return_value_object = "Mocked Return Value"
# return_valueを使用して戻り値を変更する例
def example_with_return_value():
with patch.object(MyClient, "notify", return_value=mock_return_value_object):
client = MyClient()
return client.notify() # "Mocked Return Value"を返す
# newを使用してメソッド自体をmockに置き換える例
def mock_notify_method(self, message="Mocked Notify Method"):
print("hello from mock_notify_method()")
return "mock_notify_method: " + message
def example_with_new():
with patch.object(MyClient, "notify", new=mock_notify_method):
client = MyClient()
return client.notify("bbb") # "mock_notify_method: bbb"を返す
original_message = MyClient().notify()
print(original_message)
print('--')
result_with_return_value = example_with_return_value()
print(result_with_return_value)
print('--')
result_with_new = example_with_new()
print(result_with_new)
#### OUTPUT ####
# hello from notify()
# Original Notify message
# --
# Mocked Return Value
# --
# hello from mock_notify_method()
# mock_notify_method: bbb
エラー:patchのside_effect指定なしだとAttributeError
mocker.patch('my_calendar.requests.get', Timeout)
AttributeError: 'Timeout' object has no attribute 'status_code'
エラー: TestCalendarクラスに入れるとAttributeError
AttributeError: 'TestCalendar' object has no attribute 'patch'
--> selfを入れ忘れでした
class TestCalendar:
def test_get_holidays_timeout(self, mocker):
Discussion