Pytest - 3. Monkeypatching functions
1. Monkeypatching 이란?
공식 문서에는 다음과 같이 Monkeypatching을 설명한다. 항상 특정 값을 반환하도록 만들 때 monkeypatch를 사용하라고.
아래의 예에서 monkeypatch.setattr은 Path.home을 패치하는 데 사용되므로 테스트가 실행될 때 ('/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")
2. Monkeypatching returned objects: building mock classes
monkeypatch.setattr은 클래스와 함께 사용하여 값 대신 함수에서 반환된 객체를 모의실험 할 수 있다. API URL을 가져와서 json 응답을 반환하는 간단한 함수를 상상해 보자.
# 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는 mock_get 함수를 사용하여 mock for requests.get을 적용한다. mock_get 함수는 MockResponse 클래스의 인스턴스를 반환합니다. MockResponse 클래스는 알려진 테스트 사전을 반환하도록 정의된 json() 메서드를 가지며 외부 API 연결이 필요하지 않는다. 테스트 중인 시나리오에 적합한 복잡도로 MockResponse 클래스를 만들 수 있다. 예를 들어, 항상 True를 반환하는 ok 속성을 포함하거나 입력 문자열을 기반으로 하는 json() 메서드에서 다른 값을 반환할 수 있다.
이 mock은 fixutre를 사용하여 테스트 간에 공유도 할 수 있다.
# 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"
3. Monkeypatching environment variables
환경 변수로 작업하는 경우 테스트 목적으로 시스템에서 값을 안전하게 변경하거나 삭제해야 하는 경우가 많다. 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 환경 변수가 값으로 설정된다. 둘째, 사용자 환경 변수가 존재하지 않는 것. 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()
4. Monkeypatching dictionaries
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
삭제나 fixture는 다음과 같다.
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()
fixture:
# 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()
참고
https://docs.pytest.org/en/7.2.x/how-to/monkeypatch.html#monkeypatching
How to monkeypatch/mock modules and environments — pytest documentation
How to monkeypatch/mock modules and environments Sometimes tests need to invoke functionality which depends on global settings or which invokes code which cannot be easily tested such as network access. The monkeypatch fixture helps you to safely set/delet
docs.pytest.org