nikkie-ftnextの日記

イベントレポートや読書メモを発信

Pythonで非同期ジェネレータをモックしてテストを書く 〜genai-processors の ResearchAgent を例に〜

はじめに

七尾百合子さん、お誕生日 130日目 おめでとうございます! nikkieです。

genai-processorsのResearchAgentをモックしてテストを書いた備忘録です。

目次

書きたいテストは

llm-deep-researchを始めました。
2025年7月23日(水)のリリース - nikkie-ftnextの日記
ハマりながらテストを書く中で理解したことがあります。

simonw/llmプラグインとして実装しているので、以下のexecute()メソッドをテストします。
https://github.com/ftnext/llm-deep-research/blob/0.0.1/llm_deep_research.py#L46

class GenAIProcessorsAsyncResearch(llm.AsyncKeyModel):
    async def execute(self, prompt, stream, response, conversation, key):
        ...
        async for content_part in ResearchAgent(api_key=key)(input_stream):
            yield content_part.text
  • テスト対象のGenAIProcessorsAsyncResearch.execute()は非同期ジェネレータ1
  • テスト時にGemini APIにリクエストが送られないようResearchAgentをモックしたい
    • ResearchAgentインスタンス__call__()の返り値は(async forに書ける)非同期イテラブル2

Pythonでコルーチン(async def)のテスト

pytest-asyncioを使っています。
https://pypi.org/project/pytest-asyncio/

@pytest.mark.asyncio
async def test_GenAIProcessorsAsyncResearch_execute(ResearchAgent):
    # await(など)が書ける

async forに書けるようにモックを設定する

ResearchAgentインスタンス__call__()の返り値を非同期イテラブルにするのが最初見えていませんでした。
https://github.com/ftnext/llm-deep-research/blob/8ead3c51971fee8dfd29d56e70c4449bd36649d3/tests/test_deep_research.py#L18-L47

@pytest.mark.asyncio
@patch("llm_deep_research.ResearchAgent")  # クラスをモック
async def test_GenAIProcessorsAsyncResearch_execute(ResearchAgent):
    researcher = ResearchAgent.return_value  # ResearchAgentインスタンス

    async def generator(self):
        yield ProcessorPart("doing...", substream_name="status")
        yield ProcessorPart("DONE!", substream_name="")

    researcher.side_effect = generator  # __call__() をモック

    # ...

    async for part in sut.execute(
        prompt,
        stream=False,
        response=MagicMock(),
        conversation=MagicMock(),
        key="test-key",
    ):
        # ...

side_effectで非同期ジェネレータをモックの__call__()として設定しています。
呼び出されたときに返す値は非同期ジェネレータイテレータ3となり、これは非同期イテラブルを満たします(=async forに書ける)。
思い返していたのはこちらの過去記事。

ResearchAgent__call__()は、親のProcessorクラスでasync def __call__()として定義されていました。
https://github.com/google-gemini/genai-processors/blob/v1.0.5/genai_processors/processor.py#L109
実装を見るとyieldが使われていて、非同期ジェネレータになっています。
モックの仕方も同じように非同期ジェネレータでよいのではと考えています。

終わりに

非同期ジェネレータをモックしての学びを記しました。

  • テストはpytest-asyncioを使って、コルーチンで書く
  • モックの持つメソッドを、テストのスコープで非同期ジェネレータに差し替え
    • 非同期ジェネレータイテレータは非同期イテラブルなので、async forに書ける

一息にテストを書き上げようとすると非同期イテラブルまわりの概念に慣れていなかったので大変でしたが、一歩一歩進むことで時間はかかりましたが理解を深めて書き上げられました!