【Python】monkeypatchで環境変数や外部依存を切り替えてテストする

PythonのTopに戻る

利用者の設定に左右されないテストにする

環境変数、作業ディレクトリ、外部から取得する値を使う関数は、実行したPCの状態によって結果が変わりやすい。pytestのmonkeypatchを使うと、テスト中だけ値や関数を差し替え、終了時に復元できる。環境を手で変更してからテストするより、必要な条件をテスト自身へ記録できる。

ここでは環境変数LAB_PREFIX、相対パスのlabel.txt、取得関数fetch_label()を組み合わせる小さな関数を使う。取得関数は実サービスへ接続せず、未設定なら例外を出す教材用の境界である。テストはそこへ固定値を返す関数を差し込み、ネットワークや認証情報を必要としない。

環境変数・cwd・依存関数を一時的に変更する

report_config.py

from pathlib import Path
import os


def fetch_label():
    raise RuntimeError("real service is not configured in this example")


def build_report():
    prefix = os.environ.get("LAB_PREFIX", "default")
    local = Path("label.txt").read_text(encoding="utf-8").strip()
    return f"{prefix}:{local}:{fetch_label()}"

test_report_config.py

from pathlib import Path
import os
import pytest
import report_config


@pytest.fixture
def work(tmp_path):
    (tmp_path / "label.txt").write_text("local A\n", encoding="utf-8")
    return tmp_path


def test_context_restores(monkeypatch, work):
    old_cwd = Path.cwd()
    old_value = os.environ.get("LAB_PREFIX")
    original_fetch = report_config.fetch_label
    with monkeypatch.context() as patcher:
        patcher.setenv("LAB_PREFIX", "test")
        patcher.chdir(work)
        patcher.setattr(report_config, "fetch_label", lambda: "remote fixture")
        assert report_config.build_report() == "test:local A:remote fixture"
    assert Path.cwd() == old_cwd
    assert os.environ.get("LAB_PREFIX") == old_value
    assert report_config.fetch_label is original_fetch


def test_default_prefix(monkeypatch, work):
    monkeypatch.delenv("LAB_PREFIX", raising=False)
    monkeypatch.chdir(work)
    monkeypatch.setattr(report_config, "fetch_label", lambda: "fixture")
    assert report_config.build_report() == "default:local A:fixture"


def test_dependency_failure(monkeypatch, work):
    def timeout():
        raise TimeoutError("simulated timeout")
    monkeypatch.chdir(work)
    monkeypatch.setattr(report_config, "fetch_label", timeout)
    with pytest.raises(TimeoutError, match="simulated timeout"):
        report_config.build_report()

同じフォルダーでpython -m pytest -q test_report_config.pyを実行する。3件が成功し、最初のテストではコンテキストを抜けた後の環境変数・作業ディレクトリ・関数の復元まで確認した。以下は集計の抜粋である。

実行結果(抜粋)

3 passed

設定がない状態も明示的に作る

setenv()で環境変数を設定し、delenv()で未設定の状態を作る。raising=Falseにすると、元から存在しなくても削除の失敗にしない。「自分のPCにはその変数がないから」という前提を置かず、テストが必要な状態を明示するのがポイントである。値が空文字の場合と変数がない場合は違うので、仕様に必要なら両方をテストする。

chdir()でtmp_pathへ移動すれば、相対パスの読み込みも制御できる。復元されるので、次のテストが意図しない作業ディレクトリから始まる問題を減らせる。ただし作業ディレクトリはプロセス全体の状態であり、同じプロセス内の別スレッドへ影響しない仕組みではない。例は通常の単一スレッドのテストである。

setattr()はオブジェクトの属性を一時的に置き換える。report_configの中から参照されるfetch_labelを差し替えているため、build_report()は固定値を使う。呼び出し回数や引数を詳しく検証したい場合はunittest.mockも選べるが、この例の目的は依存先を安定した条件へ切り替えることである。

差し替える時点と場所に注意する

この関数は呼ばれるたびに環境変数を読む。モジュールをimportした時点で値を定数へ保存する設計なら、後からsetenv()してもその定数は変わらない。入力を引数や設定オブジェクトで受け取るようにするとテストしやすくなる場合がある。むやみにモジュールを再読み込みする前に、値を読むタイミングを確認する。

別モジュールからfrom … import …した関数は、元の定義場所だけを差し替えても利用側の参照が変わらないことがある。どこから名前を探しているかを見て、利用側の参照を差し替える。差し替え先の名前が存在しない場合は、通常setattr()が失敗するので、綴り間違いを見つけやすい。

復元できても、実際の副作用までは戻らない

monkeypatchは変更した属性や環境を戻すが、呼び出した関数が送信した通信や書き換えた外部データを元に戻すものではない。実サービスへ接続しない境界を先に設け、変更するファイルもtmp_pathへ限定する。取り消しできる差し替えと、取り消しできない外部操作を混同しない。

固定値のテストが成功しても、本物のサービスとの接続や認証が正しいとは分からない。それらは別の統合テストで確認する。単体テストでは固定値と時間切れなどの失敗を制御し、呼び出し側の処理を確かめると、外部の一時障害と自分のコードの不具合を切り分けやすい。

実行環境と関連情報

掲載コードはLinux上のCPython 3.12.14で実行した。pytestを使う例はpytest 9.1.1で確認している。OS固有のファイル操作や対話環境の違いは、本文に記した条件に従って扱う。

関連:fixtureとtmp_pathで実データを壊さずファイル処理をテストする / mockが効かない原因はどこ?patchする場所とautospecを理解する

PythonのTopに戻る