파이썬 테스트 #4 mock과 monkeypatch: 통제할 수 없는 것을 통제하기

6 분 소요

지금 시각을 읽는 함수, 랜덤 값을 뽑는 함수, 외부 API를 호출하는 함수는 실행할 때마다 결과가 달라집니다. 어제 통과한 테스트가 오늘 깨지고, 내 컴퓨터에서 통과한 테스트가 CI에서 깨집니다. 이번 글에서는 이런 통제할 수 없는 의존성을 테스트 안에서 통제하는 두 가지 도구, monkeypatchunittest.mock을 다루겠습니다.

  • #1 pytest 시작
  • #2 픽스처
  • #3 parametrize와 마커
  • #4 mock과 monkeypatch ← 이번 글
  • #5 외부 세계 테스트
  • #6 테스트 설계와 커버리지
  • #7 CI 연동

그대로는 테스트할 수 없는 함수 #

다음 함수를 테스트한다고 생각해 봅니다.

discount.py: 시간에 의존하는 함수
from datetime import datetime

def night_discount(price: int) -> int:
    """밤 10시 이후 주문은 10% 할인."""
    now = datetime.now()
    if now.hour >= 22:
        return int(price * 0.9)
    return price

이 함수의 결과는 테스트를 실행하는 시각에 따라 달라집니다. 낮에 돌리면 할인 분기는 영원히 실행되지 않고, 밤 10시 이후에 돌리면 반대쪽 분기가 죽습니다. 랜덤 값, 환경변수, 네트워크 호출도 같은 문제를 만듭니다. 해결 방향은 하나입니다. 테스트가 실행되는 동안만 그 의존성을 내가 정한 값으로 바꿔치기하는 것입니다.

monkeypatch: pytest의 기본 교체 도구 #

monkeypatch는 pytest가 기본으로 제공하는 픽스처입니다. #2 픽스처에서 본 것처럼 테스트 함수의 인자로 받기만 하면 됩니다. 핵심은 테스트가 끝나면 바꾼 것을 전부 자동으로 원상 복구한다는 점입니다.

setattr로 시간 고정 #

test_discount.py: 시각을 고정
from datetime import datetime
import discount

class FakeDatetime:
    @classmethod
    def now(cls):
        return datetime(2026, 7, 12, 23, 0)   # 밤 11시로 고정

def test_night_discount(monkeypatch):
    monkeypatch.setattr(discount, "datetime", FakeDatetime)
    assert discount.night_discount(10000) == 9000

monkeypatch.setattr(대상 객체, "속성 이름", 대체물) 형태입니다. discount 모듈 안의 datetime을 가짜로 바꿨으니, 함수가 보는 현재 시각은 항상 밤 11시입니다. 테스트가 끝나면 원래 datetime으로 돌아갑니다.

setenv와 chdir #

환경변수와 작업 디렉터리도 같은 방식으로 통제합니다.

환경변수와 디렉터리 교체
def test_api_key_required(monkeypatch):
    monkeypatch.setenv("API_KEY", "test-key-123")
    assert load_config().api_key == "test-key-123"

def test_missing_key(monkeypatch):
    monkeypatch.delenv("API_KEY", raising=False)
    with pytest.raises(KeyError):
        load_config()

def test_reads_local_file(monkeypatch, tmp_path):
    monkeypatch.chdir(tmp_path)               # 임시 디렉터리로 이동
    (tmp_path / "data.txt").write_text("hello")
    assert read_data() == "hello"

os.environ을 직접 고치면 다음 테스트까지 오염되지만, monkeypatch.setenv는 테스트 단위로 격리됩니다. 환경변수 하나 때문에 테스트 순서에 따라 결과가 달라지는 사고를 원천 차단합니다.

Mock과 MagicMock: 기록하는 가짜 객체 #

monkeypatch는 “무엇으로 바꿀지"를 직접 만들어야 합니다. 표준 라이브러리 unittest.mockMock은 그 대체물을 즉석에서 만들어 주는 도구입니다.

Mock의 세 가지 능력
from unittest.mock import Mock

fake = Mock()

# 1. return_value: 호출하면 정해진 값을 돌려줌
fake.get_user.return_value = {"id": 1, "name": "커티스"}
assert fake.get_user(1) == {"id": 1, "name": "커티스"}

# 2. 호출 기록: 어떻게 불렸는지 전부 기억
fake.get_user.assert_called_once_with(1)
assert fake.get_user.call_count == 1

# 3. side_effect: 예외를 던지거나 호출마다 다른 값
fake.fetch.side_effect = TimeoutError("연결 실패")
fake.parse.side_effect = [10, 20, 30]   # 호출 순서대로 반환

side_effect에 예외를 넣으면 “외부 서비스가 죽었을 때 우리 코드가 어떻게 반응하는가"를 테스트할 수 있습니다. 실제로는 재현하기 어려운 장애 상황을 한 줄로 만드는 셈입니다. MagicMockMock__len__, __iter__ 같은 매직 메서드 지원을 더한 버전이며, patch가 기본으로 만들어 주는 것도 MagicMock입니다. 일상적으로는 둘을 구분하지 않고 써도 무방합니다.

patch의 핵심 함정: 정의한 곳이 아니라 쓰는 곳을 패치 #

unittest.mock.patch는 지정한 경로의 객체를 MagicMock으로 바꿔 줍니다. 그런데 여기에 mock을 처음 쓰는 사람의 절반이 걸리는 함정이 있습니다.

weather.py: requests.get을 가져와서 사용
from requests import get

def today_temp(city: str) -> float:
    resp = get(f"https://api.example.com/weather/{city}")
    return resp.json()["temp"]

이 코드를 테스트하면서 requests.get을 패치하면 어떻게 될까요?

🚫 패치했는데 진짜 네트워크 호출이 나감
from unittest.mock import patch

def test_today_temp_wrong():
    with patch("requests.get") as fake_get:        # ✗ 효과 없음
        today_temp("seoul")                         # 진짜 API 호출 발생

weather.pyfrom requests import get으로 함수를 자기 모듈 이름 공간에 복사해 두었습니다. requests.get을 바꿔도 weather.get이라는 복사본은 그대로 진짜 함수를 가리킵니다. 패치 대상은 그 이름이 정의된 곳이 아니라, 테스트하려는 코드가 그 이름을 찾아 쓰는 곳이어야 합니다.

✅ 쓰는 곳을 패치
from unittest.mock import patch

def test_today_temp():
    with patch("weather.get") as fake_get:
        fake_get.return_value.json.return_value = {"temp": 28.5}
        assert today_temp("seoul") == 28.5
        fake_get.assert_called_once_with("https://api.example.com/weather/seoul")

fake_get.return_value는 “가짜 get이 반환한 응답 객체"이고, 그 객체의 json()이 반환할 값을 다시 지정한 것입니다. 마지막 줄의 assert_called_once_with정확히 한 번, 정확히 그 URL로 호출됐는지를 검증합니다. 호출 자체가 없었다면 assert_not_called, 여러 호출 중 하나라도 일치하면 되는 경우는 assert_any_call을 씁니다.

같은 일을 monkeypatch로도 할 수 있습니다. monkeypatch.setattr("weather.get", fake_get)처럼 문자열 경로를 받는 형태도 지원하므로, pytest 스타일을 유지하고 싶다면 monkeypatch + Mock 조합이 자연스럽습니다.

mock이 세 개를 넘으면 설계를 의심 #

mock은 편리한 만큼 남용하기 쉽습니다. 위험 신호는 명확합니다.

🚫 구현을 그대로 베낀 테스트
def test_order_process():
    with patch("shop.db.get_user") as m1, \
         patch("shop.db.get_cart") as m2, \
         patch("shop.payment.charge") as m3, \
         patch("shop.mailer.send") as m4:
        m1.return_value = ...
        m2.return_value = ...
        process_order(user_id=1)
        m3.assert_called_once()
        m4.assert_called_once()

이 테스트는 “주문이 올바르게 처리되는가"가 아니라 함수가 내부에서 이 네 가지를 이 순서로 부르는가를 검증합니다. 동작은 그대로 둔 채 내부 호출 구조만 리팩터링해도 테스트가 깨지는데, 이것이 테스트가 구현에 결합된 상태입니다. mock이 셋을 넘어가면 함수가 너무 많은 일을 한다는 설계 신호로 읽는 편이 좋습니다. 외부 경계(네트워크, 시간, 랜덤)만 mock으로 막고, 나머지는 진짜 객체로 테스트하는 것이 기본 원칙입니다.

mock 대신 fake: 직접 만드는 가짜 #

mock 설정이 길어진다면, 차라리 단순한 가짜 구현(fake)을 직접 만드는 선택지가 있습니다.

✅ fake 저장소
class FakeUserRepo:
    def __init__(self):
        self.users: dict[int, str] = {}

    def save(self, user_id: int, name: str) -> None:
        self.users[user_id] = name

    def find(self, user_id: int) -> str | None:
        return self.users.get(user_id)

def test_register():
    repo = FakeUserRepo()
    register_user(repo, user_id=1, name="커티스")
    assert repo.find(1) == "커티스"

fake는 실제로 동작하는 최소 구현이라 호출 순서가 아니라 결과 상태를 검증하게 됩니다. 리팩터링에 강하고, 여러 테스트에서 재사용할 수 있으며, 읽는 사람이 설정 코드를 해독할 필요가 없습니다. 호출 여부 자체가 검증 대상일 때(메일 발송, 결제 요청)는 mock이, 데이터를 주고받는 협력자일 때는 fake가 어울립니다.

정리 #

이번 글에서 다룬 내용입니다.

  • 시간, 랜덤, 외부 API에 의존하는 함수는 그대로 테스트할 수 없으므로 테스트 동안만 바꿔치기합니다
  • monkeypatchsetattr, setenv, chdir로 교체하고 테스트가 끝나면 자동 복원합니다
  • Mockreturn_value로 반환값을 정하고, side_effect로 예외와 순차 반환을 만들며, 호출을 전부 기록합니다
  • patch는 이름이 정의된 곳이 아니라 쓰는 곳을 대상으로 지정해야 합니다
  • assert_called_once_with, call_count로 호출 방식을 검증합니다
  • mock이 셋을 넘으면 구현 결합 테스트가 되고 있다는 설계 신호입니다
  • 데이터를 주고받는 협력자는 mock 대신 fake 구현이 리팩터링에 강합니다

다음 글(#5 외부 세계 테스트)에서는 여기서 한 걸음 더 나가, 데이터베이스와 HTTP API처럼 진짜 외부 세계와 닿는 코드를 테스트하는 전략을 다루겠습니다. #3 parametrize와 마커에서 본 마커로 느린 테스트를 분리하는 방법도 함께 등장합니다.

X