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 대상이 존재하지 않을 때 KeyError나 AttributeError를 발생시킬지 결정합니다.
다음과 같은 시나리오들을 떠올려볼게요.
- 테스트용으로 함수 동작이나 클래스 속성을 바꾸기 — 예: 테스트에서 실제로 하지 않을 API 호출이나 DB 연결이 있는데 예상 출력은 알고 있는 경우.
monkeypatch.setattr로 함수·속성을 원하는 테스트 동작으로 패치해요(자신의 함수도 포함 가능).monkeypatch.delattr로 테스트에서 함수·속성을 제거할 수도 있어요. - 딕셔너리 값 바꾸기 — 특정 테스트 케이스에서 바꾸고 싶은 전역 설정이 있는 경우.
monkeypatch.setitem으로 테스트용 딕셔너리를 패치하고,monkeypatch.delitem으로 항목을 제거해요. - 환경변수 바꾸기 — 예: 환경변수가 없는 경우의 프로그램 동작을 테스트하거나 여러 값을 알려진 변수에 설정하는 경우.
monkeypatch.setenv와monkeypatch.delenv로 패치해요. monkeypatch.setenv("PATH", value, prepend=os.pathsep)로$PATH를 바꾸고,monkeypatch.chdir로 테스트 중 현재 작업 디렉터리 컨텍스트를 바꾸기.monkeypatch.syspath_prepend로sys.path를 바꾸기 — 이러면pkg_resources.fixup_namespace_packages와importlib.invalidate_caches도 호출돼요.monkeypatch.context로 특정 범위에서만 패치 적용하기 — 복잡한 픽스처나 stdlib 패치의 teardown을 제어하는 데 도움이 돼요.
함수 몽키패칭
사용자 디렉터리를 다루는 시나리오를 생각해볼게요. 테스트 맥락에서는 실행 중인 사용자에 테스트가 의존하길 원하지 않아요. 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")
반환 객체 몽키패칭: 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"
monkeypatch는 requests.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이 모든 테스트에 적용되도록 설계됐다면, fixture를 conftest.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
환경변수 몽키패칭
환경변수를 다룰 때는 테스트 목적으로 값을 안전하게 바꾸거나 시스템에서 삭제해야 하는 경우가 많아요. 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()
두 가지 가능한 경로가 있어요. 첫째, 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()