モジュールや環境をモンキーパッチ/モックする方法

テストでは、グローバル設定に依存する機能や、ネットワークアクセスなど簡単にテストできないコードを呼び出す必要がある場合があります。 monkeypatch フィクスチャは、属性、辞書アイテム、環境変数を安全に設定/削除したり、インポートのために sys.path を変更したりするのに役立ちます。

monkeypatch フィクスチャは、テストで機能を安全にパッチおよびモックするためのこれらのヘルパーメソッドを提供します:

すべての変更は、要求されたテスト関数またはフィクスチャが終了した後に元に戻されます。 raising パラメータは、設定/削除操作のターゲットが存在しない場合に KeyError または AttributeError が発生するかどうかを決定します。

次のシナリオを考えてみましょう:

1. テストのために関数の動作やクラスのプロパティを変更する。 例えば、テストのために行わない API 呼び出しやデータベース接続があるが、期待される出力がわかっている場合。 monkeypatch.setattr を使用して、関数やプロパティを希望するテスト動作でパッチします。 これには独自の関数を含めることができます。 monkeypatch.delattr を使用して、テストのために関数やプロパティを削除します。

2. 辞書の値を変更する。 例えば、特定のテストケースのために変更したいグローバル設定がある場合。 monkeypatch.setitem を使用して、テストのために辞書をパッチします。 monkeypatch.delitem を使用してアイテムを削除できます。

3. テストのために環境変数を変更する。 例えば、環境変数が欠落している場合のプログラムの動作をテストするため、または既知の変数に複数の値を設定するため。 monkeypatch.setenv および monkeypatch.delenv を使用してこれらのパッチを行います。

4. monkeypatch.setenv("PATH", value, prepend=os.pathsep) を使用して $PATH を変更し、monkeypatch.chdir を使用してテスト中に現在の作業ディレクトリのコンテキストを変更します。

5. monkeypatch.syspath_prepend を使用して sys.path を変更し、pkg_resources.fixup_namespace_packages および importlib.invalidate_caches() を呼び出します。

6. monkeypatch.context を使用して特定のスコープ内でのみパッチを適用し、複雑なフィクスチャや stdlib へのパッチのテアダウンを制御するのに役立ちます。

monkeypatch blog post を参照して、いくつかの紹介資料とその動機についての議論を確認してください。

関数のモンキーパッチ

ユーザーディレクトリを操作するシナリオを考えてみましょう。 テストの文脈では、テストが実行中のユーザーに依存しないようにしたいものです。 monkeypatch を使用して、ユーザーに依存する関数をパッチして常に特定の値を返すようにすることができます。

この例では、monkeypatch.setattr を使用して Path.home をパッチし、テストが実行されるときに既知のテストパス Path("/abc") が常に使用されるようにします。 これにより、テスト目的で実行中のユーザーへの依存がなくなります。 monkeypatch.setattr は、パッチされた関数を使用する関数が呼び出される前に呼び出す必要があります。 テスト関数が終了すると、Path.home の変更は元に戻されます。

# contents of test_module.py with source code and the test
from pathlib import Path


def getssh():
    """Simple function to return expanded homedir ssh path."""
    return Path.home() / ".ssh"


def test_getssh(monkeypatch):
    # mocked return function to replace Path.home
    # always return '/abc'
    def mockreturn():
        return Path("/abc")

    # Application of the monkeypatch to replace Path.home
    # with the behavior of mockreturn defined above.
    monkeypatch.setattr(Path, "home", mockreturn)

    # Calling getssh() will use mockreturn in place of Path.home
    # for this test with the monkeypatch.
    x = getssh()
    assert x == Path("/abc/.ssh")

返されたオブジェクトのモンキーパッチ: モッククラスの構築

monkeypatch.setattr は、クラスと組み合わせて使用して、値の代わりに関数から返されるオブジェクトをモックすることができます。 API URL を受け取り、JSON レスポンスを返す単純な関数を想像してみてください。

# contents of app.py, a simple API retrieval example
import requests


def get_json(url):
    """Takes a URL, and returns the JSON."""
    r = requests.get(url)
    return r.json()

テスト目的で返されたレスポンスオブジェクト r をモックする必要があります。 r のモックには、辞書を返す .json() メソッドが必要です。 これは、r を表すクラスを定義することでテストファイル内で行うことができます。

# contents of test_app.py, a simple test for our API retrieval
# import requests for the purposes of monkeypatching
import requests

# our app.py that includes the get_json() function
# this is the previous code block example
import app


# custom class to be the mock return value
# will override the requests.Response returned from requests.get
class MockResponse:
    # mock json() method always returns a specific testing dictionary
    @staticmethod
    def json():
        return {"mock_key": "mock_response"}


def test_get_json(monkeypatch):
    # Any arguments may be passed and mock_get() will always return our
    # mocked object, which only has the .json() method.
    def mock_get(*args, **kwargs):
        return MockResponse()

    # apply the monkeypatch for requests.get to mock_get
    monkeypatch.setattr(requests, "get", mock_get)

    # app.get_json, which contains requests.get, uses the monkeypatch
    result = app.get_json("https://fakeurl")
    assert result["mock_key"] == "mock_response"

monkeypatch は、requests.get のモックを mock_get 関数で適用します。 mock_get 関数は、既知のテスト辞書を返すように定義された json() メソッドを持つ MockResponse クラスのインスタンスを返し、外部 API 接続を必要としません。

テストしているシナリオに適した複雑さで MockResponse クラスを構築できます。 例えば、常に True を返す ok プロパティを含めたり、入力文字列に基づいて json() モックメソッドから異なる値を返したりすることができます。

このモックは fixture を使用してテスト間で共有できます:

# contents of test_app.py, a simple test for our API retrieval
import pytest
import requests

# app.py that includes the get_json() function
import app


# custom class to be the mock return value of requests.get()
class MockResponse:
    @staticmethod
    def json():
        return {"mock_key": "mock_response"}


# monkeypatched requests.get moved to a fixture
@pytest.fixture
def mock_response(monkeypatch):
    """Requests.get() mocked to return {'mock_key':'mock_response'}."""

    def mock_get(*args, **kwargs):
        return MockResponse()

    monkeypatch.setattr(requests, "get", mock_get)


# notice our test uses the custom fixture instead of monkeypatch directly
def test_get_json(mock_response):
    result = app.get_json("https://fakeurl")
    assert result["mock_key"] == "mock_response"

さらに、モックがすべてのテストに適用されるように設計されている場合、fixtureconftest.py ファイルに移動し、autouse=True オプションを使用できます。

グローバルパッチの例: リモート操作から "requests" を防ぐ

すべてのテストで "requests" ライブラリが HTTP リクエストを実行しないようにするには、次のようにします:

# contents of conftest.py
import pytest


@pytest.fixture(autouse=True)
def no_requests(monkeypatch):
    """Remove requests.sessions.Session.request for all tests."""
    monkeypatch.delattr("requests.sessions.Session.request")

この autouse フィクスチャは各テスト関数に対して実行され、メソッド request.session.Session.request を削除するため、テスト内で HTTP リクエストを作成しようとする試みはすべて失敗します。

注釈

opencompile などの組み込み関数をパッチすることは、pytest の内部を壊す可能性があるため推奨されません。 それが避けられない場合は、--tb=native--assert=plain--capture=no を渡すと役立つかもしれませんが、保証はありません。

注釈

stdlib 関数や pytest が使用する一部のサードパーティライブラリをパッチすると、pytest 自体が壊れる可能性があるため、そのような場合には MonkeyPatch.context() を使用して、パッチをテストしたいブロックに限定することをお勧めします:

import functools


def test_partial(monkeypatch):
    with monkeypatch.context() as m:
        m.setattr(functools, "partial", 3)
        assert functools.partial == 3

詳細については #3290 を参照してください。

環境変数のモンキーパッチ

環境変数を操作する場合、テスト目的で値を安全に変更したり、システムから削除したりする必要があることがよくあります。 monkeypatch は、setenv および delenv メソッドを使用してこれを行うためのメカニズムを提供します。 テストするためのサンプルコード:

# contents of our original code file e.g. code.py
import os


def get_os_user_lower():
    """Simple retrieval function.
    Returns lowercase USER or raises OSError."""
    username = os.getenv("USER")

    if username is None:
        raise OSError("USER environment is not set.")

    return username.lower()

2 つの可能なパスがあります。 まず、USER 環境変数が値に設定されます。 次に、USER 環境変数が存在しません。 monkeypatch を使用すると、実行環境に影響を与えることなく、両方のパスを安全にテストできます:

# contents of our test file e.g. test_code.py
import pytest


def test_upper_to_lower(monkeypatch):
    """Set the USER env var to assert the behavior."""
    monkeypatch.setenv("USER", "TestingUser")
    assert get_os_user_lower() == "testinguser"


def test_raise_exception(monkeypatch):
    """Remove the USER env var and assert OSError is raised."""
    monkeypatch.delenv("USER", raising=False)

    with pytest.raises(OSError):
        _ = get_os_user_lower()

この動作は fixture 構造に移動してテスト間で共有できます:

# contents of our test file e.g. test_code.py
import pytest


@pytest.fixture
def mock_env_user(monkeypatch):
    monkeypatch.setenv("USER", "TestingUser")


@pytest.fixture
def mock_env_missing(monkeypatch):
    monkeypatch.delenv("USER", raising=False)


# notice the tests reference the fixtures for mocks
def test_upper_to_lower(mock_env_user):
    assert get_os_user_lower() == "testinguser"


def test_raise_exception(mock_env_missing):
    with pytest.raises(OSError):
        _ = get_os_user_lower()

辞書のモンキーパッチ

monkeypatch.setitem を使用して、テスト中に辞書の値を特定の値に安全に設定できます。 この簡略化された接続文字列の例を見てみましょう:

# contents of app.py to generate a simple connection string
DEFAULT_CONFIG = {"user": "user1", "database": "db1"}


def create_connection_string(config=None):
    """Creates a connection string from input or defaults."""
    config = config or DEFAULT_CONFIG
    return f"User Id={config['user']}; Location={config['database']};"

テスト目的で DEFAULT_CONFIG 辞書を特定の値にパッチできます。

# contents of test_app.py
# app.py with the connection string function (prior code block)
import app


def test_connection(monkeypatch):
    # Patch the values of DEFAULT_CONFIG to specific
    # testing values only for this test.
    monkeypatch.setitem(app.DEFAULT_CONFIG, "user", "test_user")
    monkeypatch.setitem(app.DEFAULT_CONFIG, "database", "test_db")

    # expected result based on the mocks
    expected = "User Id=test_user; Location=test_db;"

    # the test uses the monkeypatched dictionary settings
    result = app.create_connection_string()
    assert result == expected

monkeypatch.delitem を使用して値を削除できます。

# contents of test_app.py
import pytest

# app.py with the connection string function
import app


def test_missing_user(monkeypatch):
    # patch the DEFAULT_CONFIG t be missing the 'user' key
    monkeypatch.delitem(app.DEFAULT_CONFIG, "user", raising=False)

    # Key error expected because a config is not passed, and the
    # default is now missing the 'user' entry.
    with pytest.raises(KeyError):
        _ = app.create_connection_string()

フィクスチャのモジュール性により、各潜在的なモックのために個別のフィクスチャを定義し、必要なテストでそれらを参照する柔軟性が得られます。

# contents of test_app.py
import pytest

# app.py with the connection string function
import app


# all of the mocks are moved into separated fixtures
@pytest.fixture
def mock_test_user(monkeypatch):
    """Set the DEFAULT_CONFIG user to test_user."""
    monkeypatch.setitem(app.DEFAULT_CONFIG, "user", "test_user")


@pytest.fixture
def mock_test_database(monkeypatch):
    """Set the DEFAULT_CONFIG database to test_db."""
    monkeypatch.setitem(app.DEFAULT_CONFIG, "database", "test_db")


@pytest.fixture
def mock_missing_default_user(monkeypatch):
    """Remove the user key from DEFAULT_CONFIG"""
    monkeypatch.delitem(app.DEFAULT_CONFIG, "user", raising=False)


# tests reference only the fixture mocks that are needed
def test_connection(mock_test_user, mock_test_database):
    expected = "User Id=test_user; Location=test_db;"

    result = app.create_connection_string()
    assert result == expected


def test_missing_user(mock_missing_default_user):
    with pytest.raises(KeyError):
        _ = app.create_connection_string()

API リファレンス

MonkeyPatch クラスのドキュメントを参照してください。