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

Code

はじまり

リサちゃん
リサちゃん

これ何時になったら終わるの??

135ml
135ml

沼のようだ。

なんでこんなに沼っているんだ

何時まで経っても解決できない僕とClaude君。

最初はClineにソースを読ませて解決しようとしたんですけど、読ませるにはファイルがすくすくとデカく育っていたもので、初回のデバッグの失敗を機に、Claude君と直接チャットしながらコードペタペタして解決に挑みました。

しかし・・・、大体4時間ぐらい沼っていましたかね。それでも全然解決しなくて、まあ結局解決しなくて、最終的にはメソッドをモック対象のメソッドと同じファイル内に置いて終わらせたんですけど。うーん、結局何が原因だったんだろう?

こいつも原因の一つだったのだろうか・・・。デコレータァァァ!!

    @wrap_with_debug_log
    def 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.py

Claudeのプリプリッ。

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をテスト関数の引数として使用していますが、これを正しく動作させるためには以下の方法があります:

  1. pytest.fixtureとしてmockerを宣言する必要はありません。pytest-mockプラグインをインストールすると、mockerフィクスチャが自動的に利用可能になります。
  2. テストファイルの先頭に以下のインポートが必要です:
import pytest

テストを実行する前に、pytest-mockプラグインをインストールしておく必要があります:

pip install pytest-mock

    完全なテストファイル例:

    # tests/lambda_test.py
    import pytest
    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")
    
        # テスト対象の関数を呼び出し
        result = lambda_handler(event={}, context={})
    
        # アサーションをここに追加
        # assert result == expected_result

    pytest-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でアサーションエラーにならないというのは、通常、モックが呼び出されていないか、正しく設定されていないことを示しています。

    テストクラス内でモックを正しく動作させるための一般的な問題と解決策をいくつか挙げます:

    1. pytestのスコープ問題:クラスメソッド内でのモックは、そのメソッドのスコープ内でのみ有効です。
    2. autouse=Trueのフィクスチャを使用する
    import pytest
    from 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()
    
    1. 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()
    
    1. モジュールパスの問題: モックするパスが正確であることを確認してください。lambda_function.pyが別のディレクトリにある場合、パスを調整する必要があります。
    2. 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" というパスでモックを適用していますが、実際のインポートパスと一致していない可能性があります。

    以下の解決策を試してみてください:

    1. インポートパスの確認: モックされるべきクラスが実際にどのようにインポートされているか確認します。コードの中でどのように NotionAPI をインポートしているかを確認してください。
    2. モックパスの修正: 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()
        # ...
    
    1. インポート順序の問題: モジュールのインポートがモックの設定前に行われている可能性があります。以下の方法を試してみてください:
    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()
    
    1. モック方法の変更: クラスではなく関数として直接モックを設定する方法も試してみてください:
    @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)
        # ...
    
    1. 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になった)という結果は進展を示しています。

    NotionDailyPropertyGeneratorretrieve_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()
    

    変更点のポイント:

    1. NotionAPIのパスを src.notion_daily_property.NotionAPI に変更しています。これは、retrieve_pages_on_notion_tasks メソッド内でインポートしているクラスをモックするために必要です。
    2. モックの戻り値を retrieve_pages_of_notion_db メソッドに設定しています。コードを見ると、実際に呼び出されているメソッドは fetch_notion ではなく retrieve_pages_of_notion_db です。
    3. 戻り値として 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 pages
    

    na.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()
    

    重要なポイント:

    1. fetch_notion ではなく retrieve_pages_of_notion_db メソッドをモックして戻り値を設定
    2. アサーションも retrieve_pages_of_notion_db メソッドが呼ばれることを確認するように変更
    3. パラメータの順序と名前を正確に合わせる(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 os
    import 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())
    

    すでに適用されたデコレーターがある場合

    すでにコードベースにデコレーターが適用されていて変更できない場合は、テスト用の差し替えモジュールを作成する方法もあります:

    1. テスト用のモジュールを作成(例: test_notion_util.py
    2. 本番コードと同じクラスやメソッドを持ちますが、デコレーターなしで実装
    3. テスト時にモジュールのインポートパスを書き換える
    # テスト内でのモジュールパス書き換え
    @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
        
        # 以降のモック設定...
    

    このアプローチはより侵襲的ですが、既存のコードを変更せずにテストを可能にします。

    どの方法を選ぶかは、プロジェクトの状況やテストの目的によって異なります。条件付きデコレーターの実装が最も簡単で推奨される方法です。

    その方法だと、既にデコレータを呼んでしまっているのでダメじゃないですか?

    結局、分からなかった。

    ダメだ。こんなにも出したのに。

    結局、テストしたいメソッドをモックしたいメソッドを持つクラスのメソッドにしたら、解決しました。まあ、元々同じクラスのメソッドだった片方のメソッドを別のクラスのメソッドとして移動したら発生した出来事だったんですけど。

    以下のような感じです。Claude君は仕事が速いです。

    import unittest
    from unittest.mock import patch
    
    # 元々のクラス構造(問題発生前)
    class OriginalClass:
        def method_to_test(self):
            # テスト対象のメソッド
            result = self.method_to_mock()
            return result * 2
        
        def method_to_mock(self):
            # モックしたいメソッド
            return 5
    
    # メソッドを別クラスに移動した後の構造(問題発生)
    class NewClass:
        def method_to_mock(self):
            # 移動したメソッド
            return 5
    
    class BrokenClass:
        def method_to_test(self):
            # テスト対象のメソッド
            # 元々同じクラス内にあったメソッドが別クラスに移動したので問題発生
            new_class = NewClass()
            result = new_class.method_to_mock()
            return result * 2
    
    # 解決策: メソッドを同じクラスに戻す
    class FixedClass:
        def method_to_test(self):
            # テスト対象のメソッド
            result = self.method_to_mock()
            return result * 2
        
        def method_to_mock(self):
            # 同じクラスに戻したモック対象メソッド
            return 5
    
    # テストケース
    class TestExample(unittest.TestCase):
        def test_original_class(self):
            # 元々の構造(問題なし)
            with patch.object(OriginalClass, 'method_to_mock', return_value=10):
                obj = OriginalClass()
                self.assertEqual(obj.method_to_test(), 20)
        
        def test_broken_class(self):
            # 問題のある構造(モックが難しい)
            with patch.object(NewClass, 'method_to_mock', return_value=10):
                # この方法では適切にモックできない可能性がある
                obj = BrokenClass()
                # NewClassのインスタンスが内部で生成されるため、パッチが効かない場合がある
                self.assertEqual(obj.method_to_test(), 20)  # 失敗する可能性あり
        
        def test_fixed_class(self):
            # 解決した構造(再び問題なし)
            with patch.object(FixedClass, 'method_to_mock', return_value=10):
                obj = FixedClass()
                self.assertEqual(obj.method_to_test(), 20)  # 成功
    
    if __name__ == '__main__':
        unittest.main()

    お疲れ様。それでは今回の反省と行こうではないか。

    あー疲れました・・・。反省します。

    一部のソースを貼り漏らした。

    fetch_notion自体のソースを貼らなかったので、AIにデコレータを気付いてもらえなかったのかもしれません。

    ですがしかし、Cline君に最初見てもらった時は全てのファイルを見てもらった上で、デコレータの指摘は無くて見当違いの修正をされたので、シード値によって解析結果にムラがあります。

    しかし、ソースを全部見てもらった方が確度は上がると思います。なので、Cline君をもっと使いたかったですが如何せん金が掛かり過ぎる・・・。これはClaude DesktopをWindows上で動かしてWSLにあるファイルを見れるかどうかを試す時が来たのかもしれない。

    クリーンアーキテクチャやらSOLID原則を使う。

    全く考えてなかったですからね、このNotionツールを作り始めた頃は・・・。

    クリーンアーキテクチャに則ったディレクトリ構成にすれば、おそらくMagicMockとかmonkeypatchとかを使わなくてもモック出来るはずです。なので、依存性逆転、依存の注入、ビジネスロジックの引数には抽象を使う。システム開発は最初が肝心ですね。ディレクトリ構成をリファクタリングするには厳しいコード量になってしまった・・・。

    まとめ

    今回の内容のまとめです。

    • pytest-mockをインストールしてテスト関数にmocker引数を渡すと、pytest がテスト実行時に自動的に mocker フィクスチャを注入する。
    • inspectパッケージを使ってソースコードをターミナルに表示出来る。
    • そもそもプロジェクトのアーキテクチャ設計で依存を排除すれば沼らない。

    本気でクリーンアーキテクチャをモノにしなければ・・・。(プリプリ言ってる場合じゃない。)

    おしまい

    リサちゃん
    リサちゃん

    オオッ・・・! テストが一気に流れていくぞ・・・!

    135ml
    135ml

    川のようだ。

    以上になります!

    コメント

    タイトルとURLをコピーしました