【Python】死ぬほどモックで沼ってたのでその過程でClaude君が出してくれた対策を並べた
はじまり


なんでこんなに沼っているんだ
何時まで経っても解決できない僕とClaude君。 最初はClineにソースを読ませて解決しようとしたんですけど、読ませるにはファイルがすくすくとデカく育っていたもので、初回のデバッグの失敗を機に、Claude君と直接チャットしながらコードペタペタして解決に挑みました。 しかし・・・、大体4時間ぐらい沼っていましたかね。それでも全然解決しなくて、まあ結局解決しなくて、最終的にはメソッドをモック対象のメソッドと同じファイル内に置いて終わらせたんですけど。うーん、結局何が原因だったんだろう? こいつも原因の一つだったのだろうか・・・。デコレータァァァ!!
@wrap_with_debug_logdef fetch_notion(self, endpoint: str, method: str, payload: dict) -> requests.Response: """
""" func_name = "notion_util.fetch_notion" args = [endpoint, method, payload] result_to_print_1 = f" with args: {args}" print(f"[DEBUG] {func_name}: Entering function with args: {args}")
if endpoint is None: raise ValueError("'endpoint' must not be None.") if method is None: raise ValueError("'method' must not be None.") if payload is None: raise ValueError("'payload' must not be None.") if endpoint == "": raise ValueError("'endpoint' must not be empty.") if method == "": raise ValueError("'method' must not be empty.") if payload.__class__.__name__ != "dict": raise TypeError("'payload' must be dict type.") pprint("information to request Notion API...") pprint(endpoint) pprint(method) pprint(payload)
headers = { "Notion-Version": "2022-06-28", "Authorization": f"Bearer {self.get_notion_integration_token()}", "Content-Type": "application/json" } pprint(f"requesting to Notion API...") if method == "GET": response = requests.request( method=method, url=endpoint, headers=headers, timeout=60 ) else: response = requests.request( method=method, url=endpoint, headers=headers, data=json.dumps(payload), timeout=60 ) if not response.ok: print(f"Error: with HTTP status code: {response.status_code} - {response.text}") response.raise_for_status() # response.encoding = response.apparent_encoding # response.encoding = "Shift_JIS" response.encoding = "utf-8" pprint(f"received response.") # pprint(response.text) pprint(response.status_code) pprint(response.encoding) print(f"[DEBUG] {func_name}: Exiting function{result_to_print_1}")
return responseメソッドの名前の上にチョコンと付いているグルグルが座っていた所為で4時間も意味のない時間が過ぎ去っていたのかもしれません・・・。まあ、ログを出力するために書いたんですけどね。APIリクエストの結果のログって大事ですから。 ということで今回は、そんな意味がなかった4時間を少しでも意味のあるものにするために、Claude君がその4時間の中でプリプリ出してくれたモックの手法を並べてみることにしました。 まあ、途中で出してくれた手法によって、デコレータがモックの邪魔をしていたことが分かりましたからね。あっ、ちなみに本記事には、デコレータを付けたメソッドのモックの仕方は載っていません。試しに一回、Claude君にプリッと出してもらいましたが、そのプリッでは出来ませんでした。4時間も沼っていてもう疲れたのでそれ以上の探求はしませんでした。 それでは並べていきましょう。
今回の沼の元
今回、沼っていたソースコードを貼り付けます。 概要としては、とあるファイルから特定のNotionのデータベースにAPIリクエストをする別のファイルに書いてあるメソッドを呼び出して(沼の元:その1)、そのメソッドが別のファイルに書いてあるクラスのメソッドを呼び出して(沼の元:その2)、そのメソッドにデコレータが付いていたわけです。(沼の元:その3) 沼の元:その1です。クラスは名前空間としての役割を作るためだけに作りました。
class NotionDailyPropertyGenerator(): def __init__(self): pass
def retrieve_pages_on_notion_tasks(self, requests_once: bool = True, retrieves_from_cache: bool = False) -> Union[list[dict], None]: notion_model = NotionModels.TASK.value na = NotionAPI() pages = na.retrieve_pages_of_notion_db(notion_model, requests_once, retrieves_from_cache) return pages沼の元:その2です。このクラスも名前空間としての役割を作るためだけに作りました。
class NotionAPI: def __init__(self): pass
@wrap_with_debug_log def fetch_notion(self, endpoint: str, method: str, payload: dict) -> requests.Response: if endpoint is None: raise ValueError("'endpoint' must not be None.") if method is None: raise ValueError("'method' must not be None.") if payload is None: raise ValueError("'payload' must not be None.") if endpoint == "": raise ValueError("'endpoint' must not be empty.") if method == "": raise ValueError("'method' must not be empty.") if payload.__class__.__name__ != "dict": raise TypeError("'payload' must be dict type.") pprint("information to request Notion API...") pprint(endpoint) pprint(method) pprint(payload)
headers = { "Notion-Version": "2022-06-28", "Authorization": f"Bearer {self.get_notion_integration_token()}", "Content-Type": "application/json" } pprint(f"requesting to Notion API...") if method == "GET": response = requests.request( method=method, url=endpoint, headers=headers, timeout=60 ) else: response = requests.request( method=method, url=endpoint, headers=headers, data=json.dumps(payload), timeout=60 ) if not response.ok: print(f"Error: with HTTP status code: {response.status_code} - {response.text}") response.raise_for_status() response.encoding = "utf-8" pprint(f"received response.") pprint(response.status_code) pprint(response.encoding)
return response沼の元:その3です。これが今回の根本の原因だったデコレータです。
def wrap_with_debug_log(func) -> callable: def wrapper(*args, **kwargs): # Get the caller function's name (which is now the wrapped function) func_name = func.__name__ result_to_print_1 = f" with args: {args}" result_to_print_2 = f" and kwargs: {kwargs}" if len(str(args)) > 500: result_to_print_1 = " with args: ..." if len(str(kwargs)) > 500: result_to_print_2 = " and kwargs: ..." print(f"[DEBUG] {func_name}: Entering function with args: {args} and kwargs: {kwargs}")
# Execute the wrapped function and capture the result result = func(*args, **kwargs) result_to_print_1 = f" with result: {result}" if len(str(result)) > 500: result_to_print_1 = " with result: ..." print(f"[DEBUG] {func_name}: Exiting function{result_to_print_1}") return result return wrapper沼の元:その4です。その1のためのテストコードになります。
ディレクトリ構成はこんな感じの抜粋です。
my_project/||
...
| ||--src| || |--notion_daily_property.py # 沼の元:その1| |--notion_template_page.py| |--notion_util.py # 沼の元:その2| |--util.py # 沼の元:その3|--tests| || |--test_notion_daily_property.py # 沼の元:その4| |--test_notion_template_page.py| |--test_notion_util.py| |--test_util.pyClaudeのプリプリッ。
Claudeがプリプリッと出した対策を並べていきます。下記のプロンプトから全てが始まりました。こちらのコードを参考にさせていただきました。
以下のpythonのテストコードのmockerはどう宣言すればいい?---*# tests/lambda_test.py (抜粋)* from lambda_function import lambda_handler def test_lambda_handler(mocker): *# インスタンス化* mock_instance = mocker.MagicMock() mocker.patch("lambda_function.ExampleClass", return_value=mock_instance) *# インスタンスメソッド* mock_instance.instance_method_example.return_value = "instance_method_2" *# クラスメソッド* mocker.patch( "lambda_function.ExampleClass.class_method_example", return_value="class_method_2", ) *# スタティックメソッド* mocker.patch( "lambda_function.ExampleClass.static_method_example", return_value="static_method_2", ) *# プロパティ* type(mock_instance).name = mocker.PropertyMock(return_value="property_2")Claudeのプリッ:その1
Pytestでモックを使用するためには、pytest-mockプラグインを利用する必要があります。このテストコードではmockerをテスト関数の引数として使用していますが、これを正しく動作させるためには以下の方法があります:
pytest.fixtureとしてmockerを宣言する必要はありません。pytest-mockプラグインをインストールすると、mockerフィクスチャが自動的に利用可能になります。- テストファイルの先頭に以下のインポートが必要です:
import pytestテストを実行する前に、pytest-mockプラグインをインストールしておく必要があります:
pip install pytest-mock完全なテストファイル例:
import pytestfrom lambda_function import lambda_handler
def test_lambda_handler(mocker): # インスタンス化 mock_instance = mocker.MagicMock() mocker.patch("lambda_function.ExampleClass", return_value=mock_instance)
# インスタンスメソッド mock_instance.instance_method_example.return_value = "instance_method_2"
# クラスメソッド mocker.patch( "lambda_function.ExampleClass.class_method_example", return_value="class_method_2", )
# スタティックメソッド mocker.patch( "lambda_function.ExampleClass.static_method_example", return_value="static_method_2", )
# プロパティ type(mock_instance).name = mocker.PropertyMock(return_value="property_2")
# テスト対象の関数を呼び出し result = lambda_handler(event={}, context={})
# アサーションをここに追加 # assert result == expected_resultpytest-mockプラグインをインストールしていれば、特別な宣言なしでmocker引数を使用できます。これはpytestが自動的に提供するフィクスチャなので、別途宣言する必要はありません。
なるほど、pytest-mockというパッケージをインストールしておかなければならんのか。うんまあ、入ってたからヨシ。先に進もう。
Claudeのプリッ:その2
ダメだ。動かん。
テストクラスの中で使いたい。テストクラスの中で mocker フィクスチャを使用する場合、クラスベースのテストスタイルに適した形で記述する必要があります。以下の方法があります:
import pytest
class TestLambdaFunction: def test_lambda_handler(self, mocker): # インスタンス化 mock_instance = mocker.MagicMock() mocker.patch("lambda_function.ExampleClass", return_value=mock_instance)
# インスタンスメソッド mock_instance.instance_method_example.return_value = "instance_method_2"
# クラスメソッド mocker.patch( "lambda_function.ExampleClass.class_method_example", return_value="class_method_2", )
# スタティックメソッド mocker.patch( "lambda_function.ExampleClass.static_method_example", return_value="static_method_2", )
# プロパティ type(mock_instance).name = mocker.PropertyMock(return_value="property_2")
# テスト対象の関数を呼び出し from lambda_function import lambda_handler result = lambda_handler(event={}, context={})
# アサーション # assert result == expected_resultクラスベースのテストでも、テストメソッドの引数として mocker を渡すことで、pytest-mock プラグインが提供する機能をそのまま使用できます。特別な初期化や宣言は不要です。pytest がテスト実行時に自動的に mocker フィクスチャを注入します。
ふむふむ、先に進もう。
Claudeのプリッ:その3
動きません。「assert mocker.call」はモックがテスト中に呼び出されていればTrueになります。
モックされない。「assert mocker.call」ではアサーションエラーにならないのに。モックが正しく機能していない問題について調査しましょう。assert mocker.callでアサーションエラーにならないというのは、通常、モックが呼び出されていないか、正しく設定されていないことを示しています。
テストクラス内でモックを正しく動作させるための一般的な問題と解決策をいくつか挙げます:
- pytestのスコープ問題:クラスメソッド内でのモックは、そのメソッドのスコープ内でのみ有効です。
- autouse=Trueのフィクスチャを使用する:
import pytestfrom unittest.mock import MagicMock, patch
class TestLambdaFunction: @pytest.fixture(autouse=True) def setup_mocks(self, mocker): # クラス全体で使えるモックをセットアップ self.mock_instance = mocker.MagicMock() self.patcher = mocker.patch("lambda_function.ExampleClass", return_value=self.mock_instance)
# インスタンスメソッド self.mock_instance.instance_method_example.return_value = "instance_method_2"
# クラスメソッド self.class_method_patcher = mocker.patch( "lambda_function.ExampleClass.class_method_example", return_value="class_method_2" )
# スタティックメソッド self.static_method_patcher = mocker.patch( "lambda_function.ExampleClass.static_method_example", return_value="static_method_2" )
# プロパティ type(self.mock_instance).name = mocker.PropertyMock(return_value="property_2")
yield # テスト後のクリーンアップ self.patcher.stop() self.class_method_patcher.stop() self.static_method_patcher.stop()
def test_lambda_handler(self): from lambda_function import lambda_handler result = lambda_handler(event={}, context={})
# モックが呼ばれたことを確認 assert "lambda_function.ExampleClass" in str(self.patcher.call_args_list) # または明示的に self.mock_instance.instance_method_example.assert_called_once()- importの問題:
lambda_functionモジュールをテスト内でインポートする時点が重要です。モックパッチを適用した後にインポートする必要があります:
import pytest
class TestLambdaFunction: def test_lambda_handler(self, mocker): # パッチを適用 mock_instance = mocker.MagicMock() mocker.patch("lambda_function.ExampleClass", return_value=mock_instance)
# ここでインポート(パッチ適用後) from lambda_function import lambda_handler
# テスト実行 result = lambda_handler(event={}, context={})
# アサーション mock_instance.instance_method_example.assert_called_once()- モジュールパスの問題: モックするパスが正確であることを確認してください。
lambda_function.pyが別のディレクトリにある場合、パスを調整する必要があります。 - pytest-mockのバージョン確認: 最新版の
pytest-mockを使用していることを確認:
pip install --upgrade pytest-mock上記の解決策を試しても問題が解決しない場合は、lambda_function.pyの内容と実際のテストコードを共有していただけると、より具体的なアドバイスができます。
インポートを途中から呼び出すやつだな。やりますか。
Claudeのプリッ:その4
またダメだ。モックが呼び出されませんでした。
モック出来ない。「assert "notion_util.NotionAPI" in str(self.patcher.call_args_list)」でアサーションエラーになる。問題を解決するために、モックが正しく適用されていない原因を分析しましょう。
エラーの原因として、モックのパス指定が間違っている可能性が高いです。"src.notion_util.NotionAPI" というパスでモックを適用していますが、実際のインポートパスと一致していない可能性があります。
以下の解決策を試してみてください:
- インポートパスの確認: モックされるべきクラスが実際にどのようにインポートされているか確認します。コードの中でどのように
NotionAPIをインポートしているかを確認してください。 - モックパスの修正:
setup_testメソッドのモックの設定を修正します:
@pytest.fixture(autouse=True)def setup_test(self, mocker: MockFixture, monkeypatch): # 現在のパス self.mocker_for_notion_api = mocker.MagicMock() # このパスが正しいか確認 self.patcher = mocker.patch("src.notion_util.NotionAPI", return_value=self.mocker_for_notion_api)
# 他の可能性のあるパスも試してみる # self.patcher = mocker.patch("notion_util.NotionAPI", return_value=self.mocker_for_notion_api) # または # from src.notion_util import NotionAPI # self.patcher = mocker.patch.object(NotionAPI, "__new__", return_value=self.mocker_for_notion_api)
self.mocker_for_notion_api.fetch_notion.return_value = self.get_test_data_for_test_retrieve_pages_on_notion_tasks_01_01() # ...- インポート順序の問題: モジュールのインポートがモックの設定前に行われている可能性があります。以下の方法を試してみてください:
def test_retrieve_pages_on_notion_tasks_01_01(self, mocker): # テスト前にモジュールを再ロードしてみる import importlib import src.notion_util importlib.reload(src.notion_util)
# または、テスト内で明示的にインポート from src.notion_daily_property import NotionDailyPropertyGenerator
instance = self.property_generator() notion_database_id = "mock_database_id" requests_once = True
pages = instance.retrieve_pages_on_notion_tasks(notion_database_id, requests_once)
# アサーション assert "NotionAPI" in str(self.patcher.call_args_list) # または self.mocker_for_notion_api.fetch_notion.assert_called_once()- モック方法の変更: クラスではなく関数として直接モックを設定する方法も試してみてください:
@pytest.fixture(autouse=True)def setup_test(self, mocker: MockFixture, monkeypatch): # 直接関数をモック self.mocker_for_notion_api = mocker.MagicMock() self.mocker_for_notion_api.fetch_notion.return_value = self.get_test_data_for_test_retrieve_pages_on_notion_tasks_01_01()
# retrieve_pages_on_notion_tasks内で使用される関数を直接モック self.patcher = mocker.patch("src.notion_daily_property.NotionAPI", return_value=self.mocker_for_notion_api) # ...- NotionDailyPropertyGeneratorのコード確認: もし可能であれば、
NotionDailyPropertyGeneratorクラスの実装、特にretrieve_pages_on_notion_tasksメソッドの実装を確認して、実際にNotionAPIがどのように使われているかを見ることができれば、より具体的な解決策を提供できます。
これらの方法を試しても解決しない場合は、src.notion_daily_property モジュールのコード(特に retrieve_pages_on_notion_tasks メソッド)を確認して、NotionAPI がどのように使用されているかの詳細を教えていただければ幸いです。
うーん・・・。
Claudeのプリッ:その5
全然出来ないので、業を煮やした僕はコードを貼り付ける。
「self.patcher = mocker.patch.object(NotionAPI, "__new__", return_value=self.mocker_for_notion_api)」を試したら、アサーションエラーにはなったが実際の値が変わった。変える前は2100(モックが当たらない時に取れる実際の値)となっていたが、変えたら0になっていた。NotionDailyPropertyGeneratorのコードを添付する。---class NotionDailyPropertyGenerator(): def **init**(self): pass def get_title_of_notion_daily_report_page(self, date: Union[datetime, None]=None): """ Generate the title of the Notion daily report page for a specific date. Args: date (datetime): The date to generate the title for. Returns: str: The formatted page title. """ if date is None: date = get_datetime_now(is_jst=True) cfg = notion_config() prefix = cfg.DAILY_PAGE_PREFIX date_str = date.strftime("%Y-%m-%d(%a)") return f"{prefix}_{date_str}"def retrieve_pages_on_notion_tasks(self, requests_once: bool = True, retrieves_from_cache: bool = False) -> Union[list[dict], None]: notion_model = "task" na = NotionAPI() pages = na.retrieve_pages_of_notion_db(notion_model, requests_once, retrieves_from_cache) return pages問題の原因がわかりました。モックのパスに問題があったと考えられます。mocker.patch.object(NotionAPI, "__new__", return_value=self.mocker_for_notion_api) を試したときに値が変わった(2100から0になった)という結果は進展を示しています。
NotionDailyPropertyGenerator の retrieve_pages_on_notion_tasks メソッドを見ると、以下のコードがあります:
def retrieve_pages_on_notion_tasks(self, requests_once: bool = True, retrieves_from_cache: bool = False) -> Union[list[dict], None]: notion_model = "task" na = NotionAPI() pages = na.retrieve_pages_of_notion_db(notion_model, requests_once, retrieves_from_cache) return pagesここで NotionAPI() をインスタンス化しています。これをモックするためには、以下のように修正してみましょう:
@pytest.fixture(autouse=True)def setup_test(self, mocker: MockFixture, monkeypatch): # モックのセットアップ self.mocker_for_notion_api = mocker.MagicMock()
# NotionAPIをインポート from src.notion_util import NotionAPI
# NotionAPIクラスそのものをモック self.patcher = mocker.patch("src.notion_daily_property.NotionAPI", return_value=self.mocker_for_notion_api)
# モックの戻り値を設定 self.mocker_for_notion_api.retrieve_pages_of_notion_db.return_value = self.get_test_data_for_test_retrieve_pages_on_notion_tasks_01_01()["results"]
# 他の設定 self.magic_mock = MagicMock() self.monkey_patch = monkeypatch
self.set_env_vars(self.get_test_data()) yield # teardown self.remove_env_vars(self.get_test_data()) self.patcher.stop()変更点のポイント:
NotionAPIのパスをsrc.notion_daily_property.NotionAPIに変更しています。これは、retrieve_pages_on_notion_tasksメソッド内でインポートしているクラスをモックするために必要です。- モックの戻り値を
retrieve_pages_of_notion_dbメソッドに設定しています。コードを見ると、実際に呼び出されているメソッドはfetch_notionではなくretrieve_pages_of_notion_dbです。 - 戻り値として
self.get_test_data_for_test_retrieve_pages_on_notion_tasks_01_01()["results"]を設定しています。これは実際のJSONレスポンスに合わせて、resultsキーの値を返すようにするためです。
これらの変更を行った後、テストを再度実行してみてください。モックが正しく適用され、assert "NotionAPI" in str(self.patcher.call_args_list) が成功するはずです。
また、テストメソッド内でのアサーションも以下のように変更するとより明確になります:
def test_retrieve_pages_on_notion_tasks_01_01(self, mocker): instance = self.property_generator() notion_database_id = "mock_database_id" requests_once = True
pages = instance.retrieve_pages_on_notion_tasks(notion_database_id, requests_once)
# モックが呼ばれたことを確認 self.patcher.assert_called_once()
# retrieve_pages_of_notion_dbメソッドが呼ばれたことを確認 self.mocker_for_notion_api.retrieve_pages_of_notion_db.assert_called_once_with("task", notion_database_id, requests_once)
# 結果の確認 assert len(pages) == 2この方法でモックが正しく適用されるはずです。 おいおい原因が分かったって!? それは本当かぃぃ!?
Claudeのプリッ:その6
ダメだ・・・。
「self.mocker_for_notion_api.fetch_notion.assert_called_once_with(“task”, notion_database_id, requests_once)」の処理で、「AssertionError: Expected ‘fetch_notion’ to be called once. Called 0 times.」というアサーションエラーが表示されました。
fetch_notion メソッドが呼ばれていないというエラーが出ていますね。これは、実際のコードで使用されているメソッド名と、モックで設定したメソッド名が一致していないことを示しています。
コードを見ると、NotionDailyPropertyGenerator.retrieve_pages_on_notion_tasks メソッドでは:
def retrieve_pages_on_notion_tasks(self, requests_once: bool = True, retrieves_from_cache: bool = False) -> Union[list[dict], None]: notion_model = "task" na = NotionAPI() pages = na.retrieve_pages_of_notion_db(notion_model, requests_once, retrieves_from_cache) return pagesna.retrieve_pages_of_notion_db メソッドを呼び出していることがわかります。しかし、テストでは fetch_notion メソッドが呼ばれるとアサートしています。
修正方法としては、以下のようにアサーションを変更してください:
def test_retrieve_pages_on_notion_tasks_01_01(self, mocker): instance = self.property_generator() notion_database_id = "mock_database_id" requests_once = True
pages = instance.retrieve_pages_on_notion_tasks(requests_once)
# モックが呼ばれたことを確認 self.patcher.assert_called_once()
# retrieve_pages_of_notion_dbメソッドが呼ばれたことを確認 self.mocker_for_notion_api.retrieve_pages_of_notion_db.assert_called_once_with("task", requests_once, False)
# 結果の確認 assert len(pages) == 2また、setup_test メソッドでのモックの設定も次のように変更してください:
@pytest.fixture(autouse=True)def setup_test(self, mocker: MockFixture, monkeypatch): # モックのセットアップ self.mocker_for_notion_api = mocker.MagicMock()
# NotionAPIクラスをモック self.patcher = mocker.patch("src.notion_daily_property.NotionAPI", return_value=self.mocker_for_notion_api)
# retrieve_pages_of_notion_dbメソッドの戻り値を設定 self.mocker_for_notion_api.retrieve_pages_of_notion_db.return_value = self.get_test_data_for_test_retrieve_pages_on_notion_tasks_01_01()["results"]
# 他の設定 self.magic_mock = MagicMock() self.monkey_patch = monkeypatch
self.set_env_vars(self.get_test_data()) yield # teardown self.remove_env_vars(self.get_test_data()) self.patcher.stop()重要なポイント:
fetch_notionではなくretrieve_pages_of_notion_dbメソッドをモックして戻り値を設定- アサーションも
retrieve_pages_of_notion_dbメソッドが呼ばれることを確認するように変更 - パラメータの順序と名前を正確に合わせる(
notion_model,requests_once,retrieves_from_cache)
また、オリジナルのコードでは notion_database_id を引数として渡していますが、実際のメソッド定義ではこのパラメータは使用されていないようです。そのため、テストでも notion_database_id を引数として渡さないように修正しました。
ふむふむ、モックする対象を変えると来たか・・・・・・うん?
Claudeのプリッ:その7
通った!! ・・・けど、実際に使いたい関数自体をモックしてしまっているから、全くもってテストとしての役割を果たしていない。
その実装でモック出来るようにはなったんですけど、テストカバレッジを上げたいです。なので、fetch_notionをモックしたい。fetch_notion をモックしたいということですね。NotionAPI クラス内の retrieve_pages_of_notion_db メソッドが内部で fetch_notion を呼び出しているのであれば、そこまで深くモックすることでカバレッジを向上させることができます。
以下のようにモックを設定してみましょう:
@pytest.fixture(autouse=True)def setup_test(self, mocker: MockFixture, monkeypatch): # NotionAPIのインスタンスのモック self.mocker_for_notion_api = mocker.MagicMock()
# NotionAPIクラス自体をモック self.patcher = mocker.patch("src.notion_daily_property.NotionAPI", return_value=self.mocker_for_notion_api)
# fetch_notionメソッドのモック設定 self.mocker_for_notion_api.fetch_notion.return_value = self.get_test_data_for_test_retrieve_pages_on_notion_tasks_01_01()
# retrieve_pages_of_notion_dbメソッドも引き続きモック # 実際のNotionAPIクラスの実装に従って結果を返す self.mocker_for_notion_api.retrieve_pages_of_notion_db.return_value = self.get_test_data_for_test_retrieve_pages_on_notion_tasks_01_01()["results"]
# 他の設定 self.magic_mock = MagicMock() self.monkey_patch = monkeypatch
self.set_env_vars(self.get_test_data()) yield # teardown self.remove_env_vars(self.get_test_data()) self.patcher.stop()そして、テストメソッドでは両方のメソッドが呼ばれたことを確認します:
def test_retrieve_pages_on_notion_tasks_01_01(self, mocker): instance = self.property_generator() requests_once = True
pages = instance.retrieve_pages_on_notion_tasks(requests_once)
# NotionAPIクラスがインスタンス化されたことを確認 self.patcher.assert_called_once()
# retrieve_pages_of_notion_dbメソッドが呼ばれたことを確認 self.mocker_for_notion_api.retrieve_pages_of_notion_db.assert_called_once_with("task", requests_once, False)
# 内部でfetch_notionが呼ばれたかを確認したい場合 # ただし、この部分はretrieve_pages_of_notion_dbの内部実装に依存するため、 # 実際の挙動に合わせて調整が必要かもしれません # self.mocker_for_notion_api.fetch_notion.assert_called()
assert len(pages) == 2もし NotionAPI クラスの内部実装をより深くテストしたい場合は、NotionAPIクラスそのものをモックするのではなく、そのメソッドのみをモックする方法も検討できます:
@pytest.fixture(autouse=True)def setup_test(self, mocker: MockFixture, monkeypatch): # NotionAPIのfetch_notionメソッドのみをモック self.fetch_notion_mock = mocker.patch("src.notion_util.NotionAPI.fetch_notion") self.fetch_notion_mock.return_value = self.get_test_data_for_test_retrieve_pages_on_notion_tasks_01_01()
# 実際のretrieve_pages_of_notion_dbメソッドを実行してfetch_notionを呼び出すようにする
# 他の設定 self.magic_mock = MagicMock() self.monkey_patch = monkeypatch
self.set_env_vars(self.get_test_data()) yield # teardown self.remove_env_vars(self.get_test_data())この方法では、NotionAPI クラス自体はモックせず、そのメソッド fetch_notion のみをモックするため、retrieve_pages_of_notion_db の実際のコードが実行され、内部で fetch_notion が呼び出されたときにモックの結果が返されます。
ただし、この方法が機能するかどうかは、NotionAPI クラスの実際の実装に依存します。NotionAPI クラスのコードを確認し、retrieve_pages_of_notion_db メソッドが内部でどのように fetch_notion を呼び出しているかを把握することが重要です。
よしよし、深くモックするかぁぁ・・・・・・retrieve_pages_of_notion_db?
Claudeのプリッ:その8
だから、その実装ではテストする意味が無いのだ・・・。
`NotionAPI` クラスの内部実装を介した返り値をテストしたいです。その場合、以下の実装ではモック出来ませんでした。---@pytest.fixture(autouse=True)def setup_test(self, mocker: MockFixture, monkeypatch): # NotionAPIのfetch_notionメソッドのみをモック self.fetch_notion_mock = mocker.patch("src.notion_util.NotionAPI.fetch_notion") self.fetch_notion_mock.return_value = self.get_test_data_for_test_retrieve_pages_on_notion_tasks_01_01()
# 実際のretrieve_pages_of_notion_dbメソッドを実行してfetch_notionを呼び出すようにする
# 他の設定 self.magic_mock = MagicMock() self.monkey_patch = monkeypatch self.set_env_vars(self.get_test_data()) yield # teardown self.remove_env_vars(self.get_test_data())内部実装を介した返り値をテストする場合、インスタンス化の順序や、モックの適用先が重要になります。NotionAPI.fetch_notion をモックする場合、以下の点に注意する必要があります。
まず、モックの設定方法を修正してみましょう:
@pytest.fixture(autouse=True)def setup_test(self, mocker: MockFixture, monkeypatch): # NotionAPIクラスのfetch_notionメソッドのみをモック # インポートを明示的に行う from src.notion_util import NotionAPI
# パッチを作成する前に、モジュールをリロードして確実に最新の状態を使用 import importlib import src.notion_util importlib.reload(src.notion_util)
# fetch_notionメソッドをパッチ self.fetch_notion_mock = mocker.patch.object(NotionAPI, 'fetch_notion') self.fetch_notion_mock.return_value = self.get_test_data_for_test_retrieve_pages_on_notion_tasks_01_01()
# 環境変数の設定 self.magic_mock = MagicMock() self.monkey_patch = monkeypatch self.set_env_vars(self.get_test_data()) yield # teardown self.remove_env_vars(self.get_test_data())次に、テストメソッドを以下のように修正します:
def test_retrieve_pages_on_notion_tasks_01_01(self, mocker): # インポートを明示的に行う from src.notion_util import NotionAPI
# インスタンスを生成 instance = self.property_generator() requests_once = True
# テスト実行 pages = instance.retrieve_pages_on_notion_tasks(requests_once)
# fetch_notionが呼ばれたことを確認 self.fetch_notion_mock.assert_called()
# 結果の確認 assert len(pages) == 2さらに、モジュールの依存関係により、以下のようなアプローチも試してみる価値があります: モック対象のパスを確認: NotionAPIがどこからインポートされているか確認し、正確なパスでモックします。
# NotionDailyPropertyGeneratorのコードをデバッグプリントして確認def test_debug_imports(self): from src.notion_daily_property import NotionDailyPropertyGenerator import inspect code = inspect.getsource(NotionDailyPropertyGenerator.retrieve_pages_on_notion_tasks) print(f"コード:\n{code}")
# 実際に使用されているNotionAPIのインポート元を確認 import src.notion_util print(f"NotionAPIのパス: {src.notion_util.NotionAPI}")強制的にモックを適用: patch.multiple を使用して、複数のパスに同時にモックを適用します。
@pytest.fixture(autouse=True)def setup_test(self, mocker: MockFixture, monkeypatch): # 複数の可能性のあるパスに同時にモックを適用 mock_data = self.get_test_data_for_test_retrieve_pages_on_notion_tasks_01_01() patcher = mocker.patch.multiple( "src.notion_util.NotionAPI", fetch_notion=mocker.MagicMock(return_value=mock_data) )
# 別の可能性のあるパスにもモックを適用 mocker.patch.multiple( "src.notion_daily_property.NotionAPI", fetch_notion=mocker.MagicMock(return_value=mock_data) )
self.magic_mock = MagicMock() self.monkey_patch = monkeypatch self.set_env_vars(self.get_test_data()) yield self.remove_env_vars(self.get_test_data())インスタンス生成を確認: NotionAPI のコンストラクタをモックして、インスタンス生成時に特定のモックインスタンスを返すようにします。
@pytest.fixture(autouse=True)def setup_test(self, mocker: MockFixture, monkeypatch): # モックインスタンスを作成 mock_instance = MagicMock() mock_instance.fetch_notion.return_value = self.get_test_data_for_test_retrieve_pages_on_notion_tasks_01_01()
# NotionAPIのコンストラクタをモック patcher = mocker.patch("src.notion_daily_property.NotionAPI", return_value=mock_instance) self.mock_instance = mock_instance
# 環境変数設定 self.magic_mock = MagicMock() self.monkey_patch = monkeypatch self.set_env_vars(self.get_test_data()) yield self.remove_env_vars(self.get_test_data()) patcher.stop()これらの方法を順に試して、どれが実際の実装に適しているか確認してみてください。NotionAPIクラスの正確なインポートパスと使用方法を特定することが重要です。
またちょっと毛色が変わったな・・・。おっ・・・?
Claudeのプ・・・なんだこれ??
先ほどClaude君が出してくれたinspectパッケージを使った手法ですね。このパッケージを使うとですね、ソースコードをターミナルに出力することが出来ます。
そして、fetch_notionに対して実行すると、こんなコードが出てきた。
コード: def wrapper(*args, **kwargs): # Get the caller function's name (which is now the wrapped function) func_name = func.__name__ result_to_print_1 = f" with args: {args}" result_to_print_2 = f" and kwargs: {kwargs}" if len(str(args)) > 500: result_to_print_1 = " with args: ..." if len(str(kwargs)) > 500: result_to_print_2 = " and kwargs: ..." print(f"[DEBUG] {func_name}: Entering function with args: {args} and kwargs: {kwargs}")
# Execute the wrapped function and capture the result result = func(*args, **kwargs) result_to_print_1 = f" with result: {result}" if len(str(result)) > 500: result_to_print_1 = " with result: ..." print(f"[DEBUG] {func_name}: Exiting function{result_to_print_1}") return result
NotionAPIのパス: <class 'src.notion_util.NotionAPI'>あああ~・・・。
Claudeのプリッ:その9
報告します。(ちなみにフィクスチャは関数の上に付けるグルグルしたアットマークのヤツのこと。デコレータは任意の関数の前後で処理をするヤツのこと。今回はフィクスチャを使ってデコレータを呼んでいました。)
NotionAPI.fetch_notionメソッドは、以下の関数を呼ぶフィクスチャが付いていました。---def wrapper(*args, **kwargs): # Get the caller function's name (which is now the wrapped function) func_name = func.__name__ result_to_print_1 = f" with args: {args}" result_to_print_2 = f" and kwargs: {kwargs}" if len(str(args)) > 500: result_to_print_1 = " with args: ..." if len(str(kwargs)) > 500: result_to_print_2 = " and kwargs: ..." print(f"[DEBUG] {func_name}: Entering function with args: {args} and kwargs: {kwargs}") # Execute the wrapped function and capture the result result = func(*args, **kwargs) result_to_print_1 = f" with result: {result}" if len(str(result)) > 500: result_to_print_1 = " with result: ..." print(f"[DEBUG] {func_name}: Exiting function{result_to_print_1}") return resultデバッグ用のラッパー関数が NotionAPI.fetch_notion メソッドに付いていることがわかりました。これはメソッドをデコレートして、呼び出し時の引数と戻り値を出力するためのものですね。
このようなラッパー関数がある場合、モックの設定には追加の考慮が必要です。デコレーターを適用されたメソッドをモックする方法として、以下のアプローチを試してみましょう:
@pytest.fixture(autouse=True)def setup_test(self, mocker: MockFixture, monkeypatch): # モックデータを取得 mock_data = self.get_test_data_for_test_retrieve_pages_on_notion_tasks_01_01()
# NotionAPIをインポート from src.notion_util import NotionAPI
# 実際のインスタンスを作成し、そのメソッドをモック # これにより、デコレーターが適用された後のメソッドをモックできる self.real_instance = NotionAPI()
# monkeypatchを使用して、インスタンスメソッドを直接置き換え def mock_fetch_notion(*args, **kwargs): print(f"[MOCK] fetch_notion called with args: {args}, kwargs: {kwargs}") return mock_data
# インスタンスのメソッドを直接モック monkeypatch.setattr(self.real_instance, 'fetch_notion', mock_fetch_notion)
# NotionAPIのコンストラクタをモックして、モック済みのインスタンスを返す self.patcher = mocker.patch('src.notion_daily_property.NotionAPI', return_value=self.real_instance)
# 環境変数の設定 self.magic_mock = MagicMock() self.monkey_patch = monkeypatch self.set_env_vars(self.get_test_data()) yield # teardown self.remove_env_vars(self.get_test_data()) self.patcher.stop() if hasattr(self, 'patcher') else Noneあるいは、デコレーターを回避してモックする方法も試してみましょう:
@pytest.fixture(autouse=True)def setup_test(self, mocker: MockFixture, monkeypatch): # モックデータを取得 mock_data = self.get_test_data_for_test_retrieve_pages_on_notion_tasks_01_01()
# モックインスタンスを作成 mock_instance = mocker.MagicMock()
# fetch_notionの戻り値を設定 mock_fetch_notion = mocker.MagicMock(return_value=mock_data) mock_instance.fetch_notion = mock_fetch_notion
# retrieve_pages_of_notion_dbの戻り値も設定 mock_instance.retrieve_pages_of_notion_db.return_value = mock_data["results"]
# NotionAPIクラスのコンストラクタをモックして、このモックインスタンスを返す self.patcher = mocker.patch('src.notion_daily_property.NotionAPI', return_value=mock_instance)
# テスト用に参照を保持 self.mock_instance = mock_instance self.mock_fetch_notion = mock_fetch_notion
# 環境変数の設定 self.magic_mock = MagicMock() self.monkey_patch = monkeypatch self.set_env_vars(self.get_test_data()) yield # teardown self.remove_env_vars(self.get_test_data()) self.patcher.stop() if hasattr(self, 'patcher') else Noneそして、テストメソッドでは次のように確認します:
def test_retrieve_pages_on_notion_tasks_01_01(self, mocker): instance = self.property_generator() requests_once = True
pages = instance.retrieve_pages_on_notion_tasks(requests_once)
# NotionAPIがインスタンス化されたことを確認 self.patcher.assert_called_once()
# モックメソッドが呼ばれたことを確認 if hasattr(self, 'mock_fetch_notion'): # 第二のアプローチの場合 self.mock_fetch_notion.assert_called() else: # 第一のアプローチの場合 # ここでは明示的なアサーションができないが、 # デバッグ出力から呼び出しを確認できる pass
# 結果の確認 assert len(pages) == 2デコレーターが複雑なモックの問題を引き起こしている場合、テスト専用の環境変数を設定して、デバッグ用のデコレーターを無効化するという方法も検討する価値があります。例えば、TEST_MODE=True のような環境変数を設定し、デコレーターの適用を条件付きにすることができれば、テスト時のモックがより簡単になります。
よしよし、少し進展したか・・・。
Claudeのプリッ:その10
ダメだ。デコレータを看過できない。
デコレーターの適用を条件付きにする方法を教えてデコレーターの適用を条件付きにするには、デコレーターの実装時に環境変数を確認し、テスト環境では適用しないようにする方法が有効です。以下に実装例を示します。 条件付きデコレーターの実装方法
import osimport functools
def debug_decorator(func): @functools.wraps(func) def wrapper(*args, **kwargs): # 環境変数 TEST_MODE が "True" の場合はデコレーターをスキップ if os.environ.get("TEST_MODE") == "True": # テスト時は元の関数をそのまま実行 return func(*args, **kwargs)
# 通常時はデバッグログを出力 func_name = func.__name__ result_to_print_1 = f" with args: {args}" result_to_print_2 = f" and kwargs: {kwargs}" if len(str(args)) > 500: result_to_print_1 = " with args: ..." if len(str(kwargs)) > 500: result_to_print_2 = " and kwargs: ..." print(f"[DEBUG] {func_name}: Entering function{result_to_print_1}{result_to_print_2}")
# 元の関数を実行して結果を取得 result = func(*args, **kwargs)
result_to_print = f" with result: {result}" if len(str(result)) > 500: result_to_print = " with result: ..." print(f"[DEBUG] {func_name}: Exiting function{result_to_print}") return result
return wrapperテストでの使用方法
テスト実行前に環境変数 TEST_MODE を設定します:
@pytest.fixture(autouse=True)def setup_test(self, mocker: MockFixture, monkeypatch): # テストモードを有効にして、デコレーターをスキップ monkeypatch.setenv("TEST_MODE", "True")
# モックの設定 mock_data = self.get_test_data_for_test_retrieve_pages_on_notion_tasks_01_01()
# ここでは元のメソッドに直接モックを設定できる self.fetch_notion_mock = mocker.patch("src.notion_util.NotionAPI.fetch_notion") self.fetch_notion_mock.return_value = mock_data
# 残りの設定... self.set_env_vars(self.get_test_data()) yield # teardown self.remove_env_vars(self.get_test_data())すでに適用されたデコレーターがある場合 すでにコードベースにデコレーターが適用されていて変更できない場合は、テスト用の差し替えモジュールを作成する方法もあります:
- テスト用のモジュールを作成(例:
test_notion_util.py) - 本番コードと同じクラスやメソッドを持ちますが、デコレーターなしで実装
- テスト時にモジュールのインポートパスを書き換える
# テスト内でのモジュールパス書き換え@pytest.fixture(autouse=True)def setup_test(self, mocker: MockFixture, monkeypatch): # テスト用モジュールをインポートパスに挿入 import sys import test_notion_util sys.modules['src.notion_util'] = test_notion_util
# 以降のモック設定...このアプローチはより侵襲的ですが、既存のコードを変更せずにテストを可能にします。 どの方法を選ぶかは、プロジェクトの状況やテストの目的によって異なります。条件付きデコレーターの実装が最も簡単で推奨される方法です。 その方法だと、既にデコレータを呼んでしまっているのでダメじゃないですか?
結局、分からなかった。
ダメだ。こんなにも出したのに。
お疲れ様。それでは今回の反省と行こうではないか。
あー疲れました・・・。反省します。
一部のソースを貼り漏らした。
fetch_notion自体のソースを貼らなかったので、AIにデコレータを気付いてもらえなかったのかもしれません。 ですがしかし、Cline君に最初見てもらった時は全てのファイルを見てもらった上で、デコレータの指摘は無くて見当違いの修正をされたので、シード値によって解析結果にムラがあります。 しかし、ソースを全部見てもらった方が確度は上がると思います。なので、Cline君をもっと使いたかったですが如何せん金が掛かり過ぎる・・・。これはClaude DesktopをWindows上で動かしてWSLにあるファイルを見れるかどうかを試す時が来たのかもしれない。
クリーンアーキテクチャやらSOLID原則を使う。
全く考えてなかったですからね、このNotionツールを作り始めた頃は・・・。 クリーンアーキテクチャに則ったディレクトリ構成にすれば、おそらくMagicMockとかmonkeypatchとかを使わなくてもモック出来るはずです。なので、依存性逆転、依存の注入、ビジネスロジックの引数には抽象を使う。システム開発は最初が肝心ですね。ディレクトリ構成をリファクタリングするには厳しいコード量になってしまった・・・。
まとめ
今回の内容のまとめです。
pytest-mockをインストールしてテスト関数にmocker引数を渡すと、pytestがテスト実行時に自動的にmockerフィクスチャを注入する。inspectパッケージを使ってソースコードをターミナルに表示出来る。- そもそもプロジェクトのアーキテクチャ設計で依存を排除すれば沼らない。
本気でクリーンアーキテクチャをモノにしなければ・・・。(プリプリ言ってる場合じゃない。)
おしまい


以上になります!
記事を共有
この記事が役に立ったなら、ぜひ他の人と共有してください!