nikkie-ftnextの日記

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

HTTPXのリクエストをRESPXでモックする 〜リクエストのヘッダやデータもassert篇〜

はじめに

新宿御苑.wasm、ありがとうございました!刺激的だった〜 nikkieです。

先日Groqへのリクエストを例に、HTTPクライアント HTTPXをRESPXでモックしてテストを書きました。

その中の宿題事項にアプローチします。

ヘッダやJSON形式のデータもモックに指定できるか

目次

結論:宿題に取り組んだ後のテストコード

メンテしているSpeechRecognitionより
https://github.com/Uberi/speech_recognition/blob/3.12.0/tests/recognizers/test_groq.py

@respx.mock(assert_all_called=True, assert_all_mocked=True)
def test_transcribe_with_groq_whisper(respx_mock, monkeypatch):
    monkeypatch.setenv("GROQ_API_KEY", "gsk_grok_api_key")

    respx_mock.post(
        "https://api.groq.com/openai/v1/audio/transcriptions",
        headers__contains={"Authorization": "Bearer gsk_grok_api_key"},
        data__contains={"model": "whisper-large-v3"},
    ).mock(
        return_value=httpx.Response(
            200,
            json={
                "text": "Transcription by Groq Whisper",
                "x_groq": {"id": "req_unique_id"},
            },
        )
    )

    # 省略

前回のテストコード

transcribe.py

from groq import Groq


def transcribe(filename: str) -> str:
    client = Groq()
    with open(filename, "rb") as f:
        transcription = client.audio.transcriptions.create(
            file=(filename, f.read()),
            model="whisper-large-v3-turbo",
        )
    return transcription.text


if __name__ == "__main__":
    print(transcribe("sample.wav"))

test_transcribe.py

動作環境

  • Python 3.12.6
  • groq 0.13.0
  • httpx 0.27.2
  • respx 0.21.1
  • pytest 8.3.4

環境変数GROQ_API_KEYmonkeypatch.setenv()

groq.Groq()環境変数GROQ_API_KEYが設定されていないとgroq.GroqErrorを送出します。

>>> import groq
>>> groq.Groq()
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "/.../.venv/lib/python3.12/site-packages/groq/_client.py", line 89, in __init__
    raise GroqError(
groq.GroqError: The api_key client option must be set either by passing api_key to the client or by setting the GROQ_API_KEY environment variable

pytestのフィクスチャのmonkeypatchを使って、テスト中にダミーの値を設定することにしました。
https://docs.pytest.org/en/stable/how-to/monkeypatch.html#monkeypatching-environment-variables
HTTPリクエストはRESPXがモックする(=テストでは通信しない)ので、ダミーの値で問題ありません

RESPXできめ細かなモック

RESPXのドキュメントを見ていたところ、リクエストのヘッダやボディに特定のデータが含まれている場合にモックできることが分かりました。
https://lundberg.github.io/respx/api/#patterns

モックする際に今回見る項目

モックの仕方

こんなモックが可能ということです!

  • headers__eq=...
    • ヘッダが一致する場合にモック
  • headers__contains=...
    • ヘッダに含む場合にモック
    • headers=...はこちらの意味とのこと
  • data__eq=...
    • 送信するデータが一致する場合にモック
    • data=...はこちらの意味とのこと
  • data__contains=...
    • 送信するデータに含む場合にモック
@respx.mock(assert_all_called=True, assert_all_mocked=True)
def test_transcribe(respx_mock, monkeypatch):
    monkeypatch.setenv("GROQ_API_KEY", "gsk_grok_api_key")

    respx_mock.post(
        "https://api.groq.com/openai/v1/audio/transcriptions",
+        headers__contains={"Authorization": "Bearer gsk_grok_api_key"},
+        data__contains={"model": "whisper-large-v3-turbo"},
    ).mock(

追加したのは2点

  • Authorizationヘッダを含むときモック
  • リクエストのデータが指定したモデルを含む時にモック

試行錯誤していたときはモックが当たらず、「groq.APIConnectionError: Connection error.」をよく見かけました(時間がかかるのでおそらく通信しようとしている)

mock()に代えてrespond()

RESPXのドキュメントを見ていて知りました。
https://lundberg.github.io/respx/api/#respond

Shortcut for creating and mocking a HTTPX Response.

mock()return_valueとしてhttpx.Responseインスタンス化する代わりに

    respx_mock.post(
        # 省略
    ).respond(
        200,
        json={
            "text": "Your transcribed text appears here...",
            "x_groq": {"id": "req_unique_id"},
        },
    )

と書けます。

まとめ:リクエストのヘッダやデータもassertするテストコード

終わりに

RESPXについて知識を深めました。

  • headersdatacontainseqで検証しつつモックできる
    • headers__containsという規則
  • respond()を使うと、httpx.Responseインスタンス化するよりもスッキリ書ける

なお、残っている宿題は以下です

sample.wavを不要にできる?