pytest monkeypatch로 테스트 환경 안전하게 패치하기

pytest monkeypatch로 테스트 환경 안전하게 패치하기

테스트가 전역 설정에 의존하거나 네트워크 접근처럼 쉽게 테스트 못 하는 코드를 호출해야 할 때가 있어요. monkeypatch 픽스처는 속성·딕셔너리 항목·환경변수를 안전하게 설정/삭제하거나 import용 sys.path를 바꾸는 것을 도와줍니다. 모든 변경은 테스트가 끝나면 자동으로 원복되기 때문에, 다른 테스트에 영향을 주지 않고 격리된 환경을 만들 수 있어요.

출처: How to monkeypatch/mock modules and environments — pytest 공식 문서

본문

monkeypatch 픽스처가 제공하는 헬퍼 메서드는 다음과 같아요.

  • monkeypatch.setattr(obj, name, value, raising=True)
  • monkeypatch.delattr(obj, name, raising=True)
  • monkeypatch.setitem(mapping, name, value)
  • monkeypatch.delitem(obj, name, raising=True)
  • monkeypatch.setenv(name, value, prepend=None)
  • monkeypatch.delenv(name, raising=True)
  • monkeypatch.syspath_prepend(path)
  • monkeypatch.chdir(path)
  • monkeypatch.context()

모든 변경은 요청한 테스트 함수나 픽스처가 끝나면 되돌아가요. raising 인자는 set/delete 대상이 존재하지 않을 때 KeyErrorAttributeError를 발생시킬지 결정합니다.

다음과 같은 시나리오들을 떠올려볼게요.

  1. 테스트용으로 함수 동작이나 클래스 속성을 바꾸기 — 예: 테스트에서 실제로 하지 않을 API 호출이나 DB 연결이 있는데 예상 출력은 알고 있는 경우. monkeypatch.setattr로 함수·속성을 원하는 테스트 동작으로 패치해요(자신의 함수도 포함 가능). monkeypatch.delattr로 테스트에서 함수·속성을 제거할 수도 있어요.
  2. 딕셔너리 값 바꾸기 — 특정 테스트 케이스에서 바꾸고 싶은 전역 설정이 있는 경우. monkeypatch.setitem으로 테스트용 딕셔너리를 패치하고, monkeypatch.delitem으로 항목을 제거해요.
  3. 환경변수 바꾸기 — 예: 환경변수가 없는 경우의 프로그램 동작을 테스트하거나 여러 값을 알려진 변수에 설정하는 경우. monkeypatch.setenvmonkeypatch.delenv로 패치해요.
  4. monkeypatch.setenv("PATH", value, prepend=os.pathsep)$PATH를 바꾸고, monkeypatch.chdir로 테스트 중 현재 작업 디렉터리 컨텍스트를 바꾸기.
  5. monkeypatch.syspath_prependsys.path를 바꾸기 — 이러면 pkg_resources.fixup_namespace_packagesimportlib.invalidate_caches도 호출돼요.
  6. monkeypatch.context로 특정 범위에서만 패치 적용하기 — 복잡한 픽스처나 stdlib 패치의 teardown을 제어하는 데 도움이 돼요.

함수 몽키패칭

사용자 디렉터리를 다루는 시나리오를 생각해볼게요. 테스트 맥락에서는 실행 중인 사용자에 테스트가 의존하길 원하지 않아요. monkeypatch는 사용자에 의존하는 함수를 패치해 항상 특정 값을 반환하게 할 수 있습니다. 이 예시에서는 monkeypatch.setattrPath.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")

반환 객체 몽키패칭: mock 클래스 만들기

monkeypatch.setattr는 클래스와 함께 사용해 값 대신 함수가 반환하는 객체를 mock할 수 있어요. 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을 mock해야 해요. r의 mock은 딕셔너리를 반환하는 .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"

monkeypatchrequests.get을 우리의 mock_get 함수로 패치해요. mock_get은 알려진 테스트 딕셔너리를 반환하는 json() 메서드만 있고 외부 API 연결이 필요 없는 MockResponse 클래스 인스턴스를 반환합니다.

MockResponse 클래스는 테스트하려는 시나리오에 맞는 적절한 복잡도로 만들 수 있어요. 예를 들어 항상 True를 반환하는 ok 속성을 넣거나, 입력 문자열에 따라 json() mock 메서드가 다른 값을 반환하게 할 수 있습니다.

이 mock은 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"

만약 mock이 모든 테스트에 적용되도록 설계됐다면, 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 요청을 만들려는 모든 시도가 실패하게 됩니다.

open, compile 같은 내장 함수를 패치하는 것은 pytest의 내부를 망가뜨릴 수 있어 권장하지 않아요. 어쩔 수 없다면 --tb=native, --assert=plain, --capture=no를 넘기는 게 도움이 될 수 있지만 보장되지는 않습니다. 또 pytest가 쓰는 stdlib 함수나 일부 서드파티 라이브러리를 패치하면 pytest 자신이 망가질 수 있어요. 표준 라이브러리의 원본 객체를 패치하는 대신 여러분 코드가 쓰는 레퍼런스를 패치하는 게 낫습니다. 예를 들어 모듈에서 from os import getcwd로 가져온다면 os.getcwd 대신 mymodule.getcwd를 패치하세요.

여러분이 관리하는 코드라면 의존성을 명시적으로 만들어 테스트 대상 코드에 전역 패치 대신 인자로 넘기는 게 장기적으로 더 안전한 패턴이에요. stdlib 객체 패치가 어쩔 수 없을 때는 MonkeyPatch.context로 패칭을 테스트하려는 블록으로 한정하세요.

import functools


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

환경변수 몽키패칭

환경변수를 다룰 때는 테스트 목적으로 값을 안전하게 바꾸거나 시스템에서 삭제해야 하는 경우가 많아요. monkeypatchsetenvdelenv 메서드로 이를 처리해줍니다. 테스트할 예제 코드:

# 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()

두 가지 가능한 경로가 있어요. 첫째, 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 to 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()

픽스처의 모듈성 덕분에 각 잠재적 mock마다 별도 픽스처를 정의하고 필요한 테스트에서 참조할 수 있어요.

# 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()

더 알아보기