HOWTO · Python
Python でのモンキー パッチ
この記事では、モンキー パッチとは何か、Python でどのように実装できるかについて説明します。 2つのセクションがあります。 1つ目は関数内でのサルのパッチ適用を示し、2つ目はファイルの単体テストでどのように実装できるかを示します。
このページの内容
モンキーパッチとは、プログラムの実行中に属性を変更または追加することです。Pythonでは、関数をクラスに割り当てたり、モジュール内の名前を置き換えたりすると、その後の属性参照で見つかるものが変わります。範囲を絞ったテストには便利ですが、変更を行うタイミングや場所によって動作が変わることもあります。
Pythonでのモンキーパッチとは
Pythonでは、実行時に多くのオブジェクトへ属性を割り当てられます。モンキーパッチとは通常、既存の属性を変更したり、新しい属性を追加したりして、コードが置き換え後の動作を使うようにすることです。元のソースファイルを変更する必要はありません。また、動的言語だけに限られた手法でもなく、ほかの言語にも独自のランタイム機能やテストツールを使った同様の仕組みがあります。
このガイドで扱う2つのケースは関連していますが、異なるものです。関数をクラスに割り当てると、そのクラスのインスタンスに動作を追加できます。一方、モジュールのグローバル名を置き換えると、そのモジュール内の関数が実行時に読み取る値が変わります。後者は、製品コードが設定済みのパスや依存先を使う場合に、テストで役立つことがあります。
実行時にクラスへメソッドを追加する
通常の関数をクラスに割り当てると、Pythonのディスクリプタ機構により、その関数をインスタンス経由で参照したときにインスタンスが第1引数として束縛されます。関数をselfを受け取る形で定義し、それをクラスに割り当てます。これによって既存のクラスオブジェクトが変更されます。新しいクラスが作られるわけではありません。
関数をクラスに割り当てると、その関数はディスクリプタとして保存されます。インスタンス経由でアクセスすると、そのインスタンスが第1引数として束縛されるため、self用の引数を定義します。次の例を実行します。
このセクションのコードと表示されるラベルや出力は英語のままです。
class Greeter:
pass
def greet(self, name):
return f"Hello, {name}!"
Greeter.greet = greet
print(Greeter().greet("Ada"))
Hello, Ada!
この割り当ては、現在のプロセス内にあるGreeterとそのインスタンスに影響します。関数を単一のインスタンスに直接付けた場合、その関数はインスタンスに保存されます。クラス上の関数と同じ方法で自動的にメソッドとして束縛されるわけではありません。すべてのインスタンスから追加したメソッドを使えるようにするには、クラスに割り当てます。
pytestのテストでモジュールのグローバル名を置き換える
pipeline.pyでDATA_DIR = Path(__file__).parent / "data"を定義し、collect_files(pattern)関数がlist(DATA_DIR.glob(pattern))を返すとします。この関数は呼び出されたときに、pipelineモジュール内のDATA_DIRを参照します。テストで一時ディレクトリを作成しただけでは参照先は変わらないため、関数が名前を参照するモジュール内で、その名前を置き換える必要があります。
pytestのテストでは、tmp_pathから一時的なPathを取得できます。以下の例では、collect_files関数がそのモジュールの名前空間からDATA_DIRを参照する、小さなpipelineモジュールを定義します。通常のデータディレクトリは空で、テスト用ディレクトリにはfile1.txtがあります。パッチを適用する前は、assert pipeline.collect_files("*.txt") == [expected_file]は失敗します。比較結果は[] == [expected_file]です。monkeypatch.setattrを使うと、関数はテスト用ディレクトリのファイルを返します。これは示した設定で起きる決定的な失敗を説明したもので、実際にpytestを実行して得た結果ではありません。
このセクションのコードと表示されるラベルや出力は英語のままです。
from pathlib import Path
import tempfile
import types
pipeline = types.ModuleType("pipeline")
production_data = tempfile.TemporaryDirectory()
pipeline.DATA_DIR = Path(production_data.name)
exec(
"def collect_files(pattern):\n"
" return list(DATA_DIR.glob(pattern))\n",
pipeline.__dict__,
)
def test_collect_files_uses_temporary_data(tmp_path, monkeypatch):
test_data = tmp_path / "data"
test_data.mkdir()
expected_file = test_data / "file1.txt"
expected_file.write_text("sample", encoding="utf-8")
files_before_patch = pipeline.collect_files("*.txt")
assert files_before_patch == []
monkeypatch.setattr(pipeline, "DATA_DIR", test_data)
assert pipeline.collect_files("*.txt") == [expected_file]
パッチを当てる対象が重要です。collect_filesはそのモジュールのグローバル名を参照するため、pipeline.DATA_DIRを置き換えます。テストモジュール内に同じ名前の変数を置き換えても、pipeline.collect_filesが使うグローバル名は変わりません。fixture名はmonkeypatchで、属性を置き換える操作はmonkeypatch.setattrです。pytestはテスト終了後に元の値へ戻し、テストが失敗した場合も復元します。これにより、変更をそのテスト内にとどめやすくなります。このfixtureが管理するのは属性の置き換えです。テスト用のファイルやディレクトリを自動的にモジュールのデータディレクトリにするわけではありません。
モンキーパッチは慎重に使う
テストで依存先を置き換えたり、製品コードの呼び出し経路を変えずにモジュールレベルの設定を切り替えたりする場合、モンキーパッチが役立ちます。置き換えはテスト内に限定し、コードが実際に参照する名前を対象にします。また、元の属性をテスト後に復元できるよう、pytestのmonkeypatch fixtureを使うのがおすすめです。
共有クラスやモジュールへの変更は、後から実行される無関係なコードにも影響することがあり、インポート順やテスト順によって結果が変わる場合もあります。通常の設定、依存性注入、または依存先を明示する小さなラッパー関数で対応できるなら、アプリケーション全体に及ぶ恒久的なパッチは避けてください。追加したメソッドをそのクラスのインスタンスから使えるようにする場合はクラスへのパッチが適しています。関数が参照するモジュールレベルの名前をテストで置き換える場合は、モジュールグローバルへのパッチが適しています。この違いを理解しておくと、失敗の原因を特定しやすくなり、予期しない動作も減らせます。