pytest-mockを調査してわかったこと ~モックの最適な使い方~
はじめに
pytest
でモックを使用する際に、毎回方法を調べ直すことが多いと感じたので、ここで自分なりのベストプラクティスをまとめ、振り返られるように整理しました。
この記事の内容には間違いや改善できる点があるかもしれません。もし気づいたことがあれば、ぜひフィードバックをいただけると嬉しいです。
この記事の対象者
-
pytest
でモック化する時に悩む人
TL;DR
- マジックメソッドを使用したい ->
MagicMock
を使用 - 1以外 ->
pytest-mock
プラグインのmocker
オブジェクトを使用
実装例(MagicMock)
__getitem__
(インデックスアクセス)のモック
1. from unittest.mock import MagicMock
def test_magic_getitem():
mock_obj = MagicMock()
mock_obj.__getitem__.return_value = 'mocked value'
# インデックスアクセスのテスト
assert mock_obj['key'] == 'mocked value'
__setitem__
(インデックス設定)のモック
2. from unittest.mock import MagicMock
def test_magic_setitem():
mock_obj = MagicMock()
mock_obj.__setitem__.side_effect = lambda key, value: print(f"{key} set to {value}")
# インデックス設定のテスト
mock_obj['key'] = 'value'
mock_obj.__setitem__.assert_called_once_with('key', 'value')
__call__
(関数呼び出し)のモック
3. from unittest.mock import MagicMock
def test_magic_call():
mock_func = MagicMock()
mock_func.return_value = 'called value'
# 関数呼び出しのテスト
assert mock_func(10) == 'called value'
mock_func.assert_called_once_with(10)
__iter__
(イテレータ)のモック
4. from unittest.mock import MagicMock
def test_magic_iter():
mock_obj = MagicMock()
mock_obj.__iter__.return_value = iter([1, 2, 3])
# イテレータとしての動作をテスト
assert list(mock_obj) == [1, 2, 3]
__enter__
と __exit__
(コンテキストマネージャ)のモック
5. from unittest.mock import MagicMock
def test_magic_context_manager():
mock_obj = MagicMock()
mock_obj.__enter__.return_value = 'entered'
mock_obj.__exit__.return_value = False
# with文でのテスト
with mock_obj as result:
assert result == 'entered'
mock_obj.__enter__.assert_called_once()
mock_obj.__exit__.assert_called_once()
__len__
(オブジェクトの長さ)のモック
6. from unittest.mock import MagicMock
def test_magic_len():
mock_obj = MagicMock()
mock_obj.__len__.return_value = 10
# 長さのテスト
assert len(mock_obj) == 10
mock_obj.__len__.assert_called_once()
__eq__
(等価比較)のモック
7. from unittest.mock import MagicMock
def test_magic_eq():
mock_obj = MagicMock()
mock_obj.__eq__.return_value = True
# 等価比較のテスト
assert mock_obj == 42
mock_obj.__eq__.assert_called_once_with(42)
__str__
(文字列化)のモック
8. from unittest.mock import MagicMock
def test_magic_str():
mock_obj = MagicMock()
mock_obj.__str__.return_value = 'mocked string'
# 文字列化のテスト
assert str(mock_obj) == 'mocked string'
実装例(pytest-mock:mocker)
1. 関数レベルでのモック
def test_function_call(mocker):
mock_func = mocker.patch('myapp.module.my_function')
mock_func.return_value = 42
result = another_function()
mock_func.assert_called_once()
assert result == 42
2. メソッドレベルでのモック
def test_class_method(mocker):
mock_method = mocker.patch('myapp.module.MyClass.my_method')
mock_method.return_value = 'mocked!'
instance = MyClass()
result = instance.my_method()
assert result == 'mocked!'
3. クラス全体のモック
def test_class_instance(mocker):
mock_class = mocker.patch('myapp.module.MyClass')
mock_class.return_value.my_method.return_value = 'mocked!'
result = some_function_using_class()
assert result == 'mocked!'
4. モジュール全体のモック
モジュールレベルでモックすると、そのモジュール内のすべての関数やクラスがモックされます。
def test_module_level(mocker):
mock_module = mocker.patch('myapp.module')
mock_module.some_function.return_value = 'mocked!'
result = another_function_using_module()
assert result == 'mocked!'
5. プロパティのモック
def test_class_property(mocker):
mocker.patch('myapp.module.MyClass.my_property', new_callable=mocker.PropertyMock, return_value='mocked!')
instance = MyClass()
assert instance.my_property == 'mocked!'
6. 辞書のモック
def test_dictionary_mock(mocker):
mock_dict = mocker.patch('myapp.module.config', {'key': 'mocked_value'})
assert my_function_using_config() == 'mocked_value'
7. コンストラクタのモック
クラスのインスタンス生成時の挙動をモックして、実際の処理を回避します。
def test_constructor_mock(mocker):
mocker.patch('myapp.module.MyClass.__init__', return_value=None)
instance = MyClass()
assert instance is not None
8. コンテキストマネージャのモック
with
ステートメントで使用するコンテキストマネージャをモックし、ファイル操作やトランザクション管理を再現します。
def test_context_manager(mocker):
mock_open = mocker.patch('builtins.open', mocker.mock_open(read_data='mocked content'))
with open('somefile.txt') as f:
content = f.read()
assert content == 'mocked content'
9. 例外のモック
特定の関数やメソッドで例外を発生させ、エラーハンドリングをテストします。
def test_exception_mock(mocker):
mock_func = mocker.patch('myapp.module.my_function')
mock_func.side_effect = Exception('mocked exception')
with pytest.raises(Exception, match='mocked exception'):
another_function()
10. 非同期関数のモック
非同期関数をモックして、即座に結果が返るように設定します。
@pytest.mark.asyncio
async def test_async_function(mocker):
mock_func = mocker.patch('myapp.module.async_function')
mock_func.return_value = 'mocked!'
result = await async_function()
assert result == 'mocked!'
モック化の手法
-
Mock/MagicMock
を使用 -
with patch
を使用 -
mocker
を使用
Mock/MagicMock 使用例
from unittest.mock import MagicMock
import pytest
class APIClient:
def get_data(self):
pass
def fetch_data(api_client):
return api_client.get_data()
def test_fetch_data():
api_client_mock = MagicMock()
api_client_mock.get_data.return_value = {'data': 'value'}
result = fetch_data(api_client_mock)
assert result == {'data': 'value'}
with patch 使用例
from unittest.mock import patch
class APIClient:
def get_data(self):
pass
def fetch_data(api_client):
return api_client.get_data()
def test_fetch_data():
with patch(__name__ + ".APIClient", autospec=True) as mock_client:
mock_client.get_data.return_value = {'data': 'value'}
result = fetch_data(mock_client)
assert result == {'data': 'value'}
mocker使用例
class APIClient:
def get_data(self):
pass
def fetch_data(api_client):
return api_client.get_data()
def test_with_mocker(mocker):
mock_api_client = mocker.patch('myapp.api.client.APIClient')
mock_api_client.return_value.get_data.return_value = {'data': 'result'}
result = fetch_data(mock_api_client)
assert result == {'data': 'result'}
比較表
ライブラリ | スコープ管理 | メソッドシグネチャの引数チェック | 記載方法が簡潔 | マジックメソッドの有無 |
---|---|---|---|---|
Mock/MagicMock | ✖︎ | ✖︎ | ○ | ○ |
with patch | ○ | ○ | △ | ✖︎ |
mocker | ○ | ○ | ○ | ✖︎ |
スコープ管理
スコープ管理が適切にできておらず失敗する例
import pytest
from unittest.mock import MagicMock, patch
class ExternalAPIClient:
def get(self, url):
pass
def get_user_info(user_id):
response = ExternalAPIClient().get(f"https://api.example.com/users/{user_id}")
if response.status_code == 200:
return response.json()
return None
@pytest.fixture
def mock_api():
mock_client = MagicMock()
patcher = patch('myapp.services.ExternalAPIClient', return_value=mock_client)
patcher.start()
yield mock_client
# patcher.stop() を呼び忘れている
def test_get_user_info_success(mock_api):
mock_api.get.return_value.status_code = 200
mock_api.get.return_value.json.return_value = {'user_id': 1, 'name': 'John Doe'}
result = get_user_info(1)
assert result == {'user_id': 1, 'name': 'John Doe'}
def test_get_user_info_failure(mock_api):
result = get_user_info(2)
assert result is None # ここで失敗するはずだが、前のモックが残っているため成功してしまう
mocker を使っておりテスト毎のスコープ管理ができている例
import pytest
class ExternalAPIClient:
def get(self, url):
pass
def get_user_info(user_id):
response = ExternalAPIClient().get(f"https://api.example.com/users/{user_id}")
if response.status_code == 200:
return response.json()
return None
@pytest.fixture
def mock_api(mocker):
return mocker.patch('myapp.services.ExternalAPIClient')
# テスト1: ユーザーが正常に取得できるケース
def test_get_user_info_success(mock_api):
mock_api.return_value.get.return_value.status_code = 200
mock_api.return_value.get.return_value.json.return_value = {'user_id': 1, 'name': 'John Doe'}
result = get_user_info(1)
assert result == {'user_id': 1, 'name': 'John Doe'}
def test_get_user_info_failure(mock_api):
mock_api.return_value.get.return_value.status_code = 404
result = get_user_info(2)
assert result is None
メソッドシグネチャの引数チェック(spec / autospec)
spec
from unittest.mock import MagicMock
class APIClient:
def get_data(self):
pass
mock_client = MagicMock(spec=APIClient)
mock_client.get_data.return_value = {'data': 'value'}
# 存在しないメソッドにアクセスしようとするとエラーになる
mock_client.non_existing_method() # AttributeError: Mock object has no attribute 'non_existing_method'
from unittest.mock import MagicMock
class APIClient:
def get_data(self):
pass
# spec を使用した例
mock_client = MagicMock(spec=APIClient)
mock_client.get_data.return_value = {'data': 'value'}
mock_client.get_data("test1", "test2") # 引数のシグネチャが模倣されていないので、エラーとならない
autospec
from unittest.mock import patch
class APIClient:
def get_data(self, param):
pass
def fetch_data(api_client):
return api_client.get_data('test')
def test_fetch_data():
with patch('__main__.APIClient', autospec=True) as mock_client:
mock_client.get_data.return_value = {'data': 'value'}
result = fetch_data(mock_client)
assert result == {'data': 'value'}
# 引数のシグネチャも模倣されているので、間違った引数で呼び出すとエラーになる
mock_client.get_data() # TypeError: missing 1 required positional argument: 'param'
from unittest.mock import patch
class APIClient:
def get_data(self, param):
pass
def fetch_data(api_client):
return api_client.get_data('test')
def test_fetch_data(mocker):
mock_client = mocker.patch('__main__.APIClient', autospec=True)
mock_client.get_data.return_value = {'data': 'value'}
# 呼び出しは正常に動作する
result = fetch_data(mock_client)
assert result == {'data': 'value'}
# 引数のシグネチャも模倣されているので、間違った引数で呼び出すとエラーになる
mock_client.get_data() # TypeError: missing 1 required positional argument: 'param'
番外編 mocker は裏側で unittest.mock.patch と unittest.mock.MagicMock を使用している単なるラッパー
mocker
が使われる時、実際には unittest.mock.patch
や MagicMock
が裏側で動いています。ここではその動作を追ってみます。
1. `_mocker` 関数での `MockerFixture` インスタンス生成
_mocker
関数で MockerFixture
のインスタンスが作成され、その結果が yield によってテスト関数に渡されます。
この yield で渡された MockerFixture
インスタンスが mocker
としてテスト関数内で使われることになります。
def _mocker(pytestconfig: Any) -> Generator[MockerFixture, None, None]:
"""
Return an object that has the same interface to the `mock` module, but
takes care of automatically undoing all patches after each test method.
"""
result = MockerFixture(pytestconfig)
yield result
result.stopall()
mocker = pytest.fixture()(_mocker) # default scope is function
class_mocker = pytest.fixture(scope="class")(_mocker)
module_mocker = pytest.fixture(scope="module")(_mocker)
package_mocker = pytest.fixture(scope="package")(_mocker)
session_mocker = pytest.fixture(scope="session")(_mocker)
2. `mocker.patch` の呼び出し
mocker
は MockerFixture
のインスタンスであり、mocker.patch
が呼び出されると、MockerFixture
クラス内の patch
メソッドが実行されます。このメソッドは、内部で _Patcher
クラスを使って実際のパッチ処理を行います。
class MockerFixture:
"""
Fixture that provides the same interface to functions in the mock module,
ensuring that they are uninstalled at the end of each test.
"""
def __init__(self, config: Any) -> None:
self._mock_cache: MockCache = MockCache()
self.mock_module = mock_module = get_mock_module(config)
self.patch = self._Patcher(self._mock_cache, mock_module) # type: MockerFixture._Patcher
self.mock_module
はデフォルトでは unittest.mock
となります。
def get_mock_module(config):
"""
Import and return the actual "mock" module. By default this is
"unittest.mock", but the user can force to always use "mock" using
the mock_use_standalone_module ini option.
"""
global _mock_module
if _mock_module is None:
use_standalone_module = parse_ini_boolean(
config.getini("mock_use_standalone_module")
)
if use_standalone_module:
import mock
_mock_module = mock
else:
import unittest.mock
_mock_module = unittest.mock
return _mock_module
def parse_ini_boolean(value: Union[bool, str]) -> bool:
if isinstance(value, bool):
return value
if value.lower() == "true":
return True
if value.lower() == "false":
return False
raise ValueError("unknown string for bool: %r" % value)
3. `_Patcher` クラスによるパッチ処理
_Patcher
クラスは、unittest.mock.patch
や unittest.mock.patch.object
のようなモック機能をラップするためのクラスです。mocker.patch
の呼び出しは、_Patcher.call
メソッドを通じて処理されます。
class _Patcher:
"""
Object to provide the same interface as mock.patch, mock.patch.object,
etc. We need this indirection to keep the same API of the mock package.
"""
DEFAULT = object()
def __init__(self, mock_cache, mock_module):
self.__mock_cache = mock_cache
self.mock_module = mock_module
.
.
.
def __call__(
self,
target: str,
new: builtins.object = DEFAULT,
spec: Optional[builtins.object] = None,
create: bool = False,
spec_set: Optional[builtins.object] = None,
autospec: Optional[builtins.object] = None,
new_callable: Optional[Callable[[], Any]] = None,
**kwargs: Any,
) -> Any:
"""API to mock.patch"""
if new is self.DEFAULT:
new = self.mock_module.DEFAULT
return self._start_patch(
self.mock_module.patch,
True,
target,
new=new,
spec=spec,
create=create,
spec_set=spec_set,
autospec=autospec,
new_callable=new_callable,
**kwargs,
)
4. `_start_patch` メソッドでのパッチの開始
ここでは、unittest.mock.patch
が実際に呼び出され、その結果としてモックされたオブジェクト(MagicMock
)が返されます。
p.start()
: patch()
によって作成された Mock
オブジェクトが開始され、モックされたオブジェクト(MagicMock
)が返されます。
self.__mock_cache.add()
: モックがキャッシュに追加され、テスト終了時にクリーンアップされるようになります。
def _start_patch(
self, mock_func: Any, warn_on_mock_enter: bool, *args: Any, **kwargs: Any
) -> MockType:
"""Patches something by calling the given function from the mock
module, registering the patch to stop it later and returns the
mock object resulting from the mock call.
"""
p = mock_func(*args, **kwargs)
mocked: MockType = p.start()
self.__mock_cache.add(mock=mocked, patch=p)
if hasattr(mocked, "reset_mock"):
# check if `mocked` is actually a mock object, as depending on autospec or target
# parameters `mocked` can be anything
if hasattr(mocked, "__enter__") and warn_on_mock_enter:
mocked.__enter__.side_effect = lambda: warnings.warn(
"Mocks returned by pytest-mock do not need to be used as context managers. "
"The mocker fixture automatically undoes mocking at the end of a test. "
"This warning can be ignored if it was triggered by mocking a context manager. "
"https://pytest-mock.readthedocs.io/en/latest/remarks.html#usage-as-context-manager",
PytestMockWarning,
stacklevel=5,
)
return mocked
5. **テスト関数でのモック使用**
テスト関数で mocker.patch
が呼び出されると、MagicMock
オブジェクトが返され、テスト内でそのモックが使用されます。
def test_example(mocker):
mock_func = mocker.patch('myapp.module.some_function')
mock_func.return_value = 'mocked_value'
result = some_function() # モックされた関数が呼ばれる
assert result == 'mocked_value'
6.テスト終了後のクリーンアップ
テストが終了すると、_mocker
関数の result.stopall()
によって、全てのモックが解除されます。stopall()
メソッドは、キャッシュされていたすべてのモックを解除します。
番外編 Mock と MagicMock の違い
MagicMock は Python の マジックメソッド(getitem、setitem、call など) に対応しており、これらをモックする際に便利。一方、Mock クラスにはこれらのマジックメソッドがデフォルトでは備わってない。
from unittest.mock import Mock, MagicMock
# Mock ではマジックメソッドはデフォルトでモックされない
mock_obj = Mock()
try:
mock_obj['key'] # TypeError: 'Mock' object is not subscriptable
except TypeError as e:
print(f"Mock: {e}")
# MagicMock ではマジックメソッドが自動でモックされる
magic_mock_obj = MagicMock()
magic_mock_obj['key'] = 'value'
print(magic_mock_obj['key']) # 'value'
# MagicMock では __call__ が自動モックされる
magic_mock_func = MagicMock()
magic_mock_func(1, 2)
magic_mock_func.assert_called_once_with(1, 2) # 正常に通る
# Mock では __call__ がサポートされないため TypeError が発生
mock_func = Mock()
try:
mock_func(1, 2) # TypeError: 'Mock' object is not callable
except TypeError as e:
print(f"Mock: {e}")
# MagicMock の __iter__ を使ってループ処理をモックできる。
magic_mock_iter = MagicMock()
magic_mock_iter.__iter__.return_value = iter([1, 2, 3])
for item in magic_mock_iter:
print(item) # 1, 2, 3 と出力される
番外編 Mocker を fixture で使用する
共通化と再利用を目的として mocker.patch
と fixture
を組み合わせることで、モックの設定を共通化し、複数のテストケースで再利用できます。
# myapp/services.py
from myapp.cache import CacheSystem
from myapp.database import db_client
from myapp.api.client import APIClient
# サービス関数: キャッシュ、DB、APIの利用
def complex_service():
cache = CacheSystem()
cached_data = cache.get('some_key')
if cached_data:
return cached_data
db_data = db_client().fetch_data()
api_data = APIClient().get_data()
result = {**db_data, **api_data}
return result
# テストコード: キャッシュ、DB、APIをモックしてテスト
import pytest
@pytest.fixture
def mock_cache_with_hit(mocker):
mock_cache = mocker.patch('myapp.cache.CacheSystem')
mock_cache.return_value.get.return_value = {'cached_data': 'hit'}
return mock_cache
@pytest.fixture
def mock_cache_without_hit(mocker):
mock_cache = mocker.patch('myapp.cache.CacheSystem')
mock_cache.return_value.get.return_value = None
return mock_cache
@pytest.fixture
def mock_db_client(mocker):
mock_client = mocker.patch('myapp.database.db_client')
mock_client.return_value.fetch_data.return_value = {'db_data': 'value'}
return mock_client
@pytest.fixture
def mock_api_client(mocker):
mock_client = mocker.patch('myapp.api.client.APIClient')
mock_client.return_value.get_data.return_value = {'api_data': 'value'}
return mock_client
# キャッシュがヒットする場合のテストケース
def test_complex_service_cache_hit(mock_cache_with_hit, mock_db_client, mock_api_client):
result = complex_service()
assert result == {'cached_data': 'hit'}
mock_db_client.return_value.fetch_data.assert_not_called()
mock_api_client.return_value.get_data.assert_not_called()
# キャッシュがヒットしない場合のテストケース
def test_complex_service_cache_miss(mock_cache_without_hit, mock_db_client, mock_api_client):
result = complex_service()
assert result == {'db_data': 'value', 'api_data': 'value'}
mock_db_client.return_value.fetch_data.assert_called_once()
mock_api_client.return_value.get_data.assert_called_once()
番外編 pytest-mock が pytest のプラグインとして取り込まれる仕組み
pytest-mock
が pytest
のプラグインとして機能するための仕組みについて調べてみます。
1. pytest-mock が pytest プラグインとして読み込まれる
pytest
には、プラグインを自動的に検出して読み込むメカニズムがあり、pytest-mock
は、この仕組みを利用している。
これは、setup.py
や pyproject.toml
の entry_points
セクションで pytest11
というエントリーポイントを定義することによって行われるようです。以下がおそらく pyproject.toml
の該当部分
[project.entry-points.pytest11]
pytest_mock = "pytest_mock"
このエントリーポイントにより、pytest
は pytest-mock
をプラグインとして認識されるらしい。
2. プラグインとしてのフック関数が認識される
pytest_configure
や pytest_addoption
といったフック関数を実装することで、pytest
の動作にフックして特定の動作を追加。
pytest_addoption
2.1 pytest_addoption
は、pytest
に追加のコマンドラインオプションや設定を追加するために使われるとのこと。pytest-mock
では、以下のように新しいオプションを追加してあります。
def pytest_addoption(parser: Any) -> None:
parser.addini(
"mock_traceback_monkeypatch",
"Monkeypatch the mock library to improve reporting of the "
"assert_called_... methods",
default=True,
)
parser.addini(
"mock_use_standalone_module",
'Use standalone "mock" (from PyPI) instead of builtin "unittest.mock" '
"on Python 3",
default=False,
)
-
parser.addini
を使って、pytest
にmock_traceback_monkeypatch
やmock_use_standalone_module
という設定を追加しています。これにより、ユーザーはpytest.ini
ファイルでこれらの設定を変更できます。
pytest_configure
2.2 pytest_configure
は、pytest
が初期化されるときに呼ばれ、プラグインの設定や初期化を行います。pytest-mock
では、pytest_configure
を使って、トレースバックのカスタマイズなどを行っているもよう。
def pytest_configure(config: Any) -> None:
tb = config.getoption("--tb", default="auto")
if (
parse_ini_boolean(config.getini("mock_traceback_monkeypatch"))
and tb != "native"
):
wrap_assert_methods(config)
番外編 スコープ粒度で mocker を使用する
import pytest
from unittest.mock import MagicMock
class ExternalService:
def fetch_data(self, identifier):
return f"Real data for {identifier}"
service = ExternalService()
# クラススコープでモック
@pytest.fixture(scope="class")
def class_mocker(class_mocker):
class_mocker.patch.object(service, 'fetch_data', return_value="Class Scoped Data")
return class_mocker
# モジュールスコープでモック
@pytest.fixture(scope="module")
def module_mocker(module_mocker):
module_mocker.patch.object(service, 'fetch_data', return_value="Module Scoped Data")
return module_mocker
# パッケージスコープでモック
@pytest.fixture(scope="package")
def package_mocker(package_mocker):
package_mocker.patch.object(service, 'fetch_data', return_value="Package Scoped Data")
return package_mocker
# セッションスコープでモック
@pytest.fixture(scope="session")
def session_mocker(session_mocker):
session_mocker.patch.object(service, 'fetch_data', return_value="Session Scoped Data")
return session_mocker
# テストケース1: クラススコープのモックを確認
@pytest.mark.usefixtures("class_mocker")
class TestClassScope:
def test_class_scope(self):
assert service.fetch_data("id1") == "Class Scoped Data"
def test_class_scope_again(self):
assert service.fetch_data("id2") == "Class Scoped Data"
# テストケース2: モジュールスコープのモックを確認
@pytest.mark.usefixtures("module_mocker")
class TestModuleScope:
def test_module_scope(self):
assert service.fetch_data("id1") == "Module Scoped Data"
def test_module_scope_again(self):
assert service.fetch_data("id2") == "Module Scoped Data"
# テストケース3: パッケージスコープのモックを確認
@pytest.mark.usefixtures("package_mocker")
class TestPackageScope:
def test_package_scope(self):
assert service.fetch_data("id1") == "Package Scoped Data"
def test_package_scope_again(self):
assert service.fetch_data("id2") == "Package Scoped Data"
# テストケース4: セッションスコープのモックを確認
@pytest.mark.usefixtures("session_mocker")
class TestSessionScope:
def test_session_scope(self):
assert service.fetch_data("id1") == "Session Scoped Data"
def test_session_scope_again(self):
assert service.fetch_data("id2") == "Session Scoped Data"
Discussion