[파이썬 100강] 88강. unittest.mock으로 외부 의존성 없이 테스트 신뢰도 높이기
실무 테스트가 느려지고 불안정해지는 가장 흔한 원인은 코드 자체보다도 "바깥 세상"(네트워크, 시간, 파일, 랜덤 값)에 테스트가 직접 묶여 있기 때문입니다. 이번 강의에서는 unittest.mock을 사용해 외부 의존성을 끊고, 실패 원인을 코드 로직으로 좁히는 방법을 예제로 바로 익히겠습니다.
핵심 개념
Mock은 실제 객체 대신 호출 기록, 반환값, 예외를 제어할 수 있는 테스트 대체 객체입니다.patch는 특정 경로의 객체를 테스트 범위 안에서 일시적으로 교체합니다. 핵심은 "정의 위치"가 아니라 "사용 위치"를 패치해야 한다는 점입니다.assert_called_once_with,call_args,side_effect같은 검증 도구를 쓰면 "함수가 어떻게 호출됐는지"까지 테스트할 수 있습니다.
테스트는 단순히 결과값만 맞는지 보는 일이 아닙니다. 함수가 외부 API를 올바른 파라미터로 호출했는지, 실패 상황에서 재시도 로직이 동작하는지, 예외를 삼키지 않고 적절히 전달하는지까지 검증해야 품질이 올라갑니다. 이때 실서버에 진짜 요청을 보내거나 시스템 시간을 기다리면 테스트 속도가 급격히 느려지고, 네트워크 상태에 따라 테스트 결과가 흔들립니다. unittest.mock은 이런 불안정을 줄여 줍니다. 즉, 테스트를 "실행 환경 운"에서 분리해 "코드의 의도"만 검증하게 만드는 도구입니다.
기본 사용
예제 1) Mock으로 반환값 고정하고 호출 여부 검증
>>> from unittest.mock import Mock
>>> notifier = Mock()
>>> notifier.send.return_value = True
>>> def publish_event(notifier, event_name):
... ok = notifier.send(event_name)
... return "done" if ok else "failed"
...
>>> publish_event(notifier, "user_signed_up")
'done'
>>> notifier.send.assert_called_once_with("user_signed_up")
해설:
notifier.send가 실제 구현이 없어도 호출 흐름을 검증할 수 있습니다.- 테스트는 빠르고, 호출 인자 검증까지 가능해 계약(인터페이스) 확인에 유리합니다.
예제 2) side_effect로 성공/실패 시나리오 분기 테스트
>>> from unittest.mock import Mock
>>> gateway = Mock()
>>> gateway.charge.side_effect = [TimeoutError("timeout"), {"status": "ok"}]
>>> def pay_with_retry(gateway, amount):
... for _ in range(2):
... try:
... return gateway.charge(amount)
... except TimeoutError:
... continue
... return {"status": "failed"}
...
>>> pay_with_retry(gateway, 10000)
{'status': 'ok'}
>>> gateway.charge.call_count
2
해설:
- 첫 호출은 예외, 두 번째 호출은 성공으로 제어해 재시도 로직을 재현했습니다.
- 외부 결제 시스템 없이도 장애 복구 시나리오를 안정적으로 테스트할 수 있습니다.
예제 3) patch로 시간 의존 로직 고정하기
>>> from unittest.mock import patch
>>> import datetime as dt
>>> def is_morning():
... now = dt.datetime.now()
... return 6 <= now.hour < 12
...
>>> with patch("datetime.datetime") as fake_dt:
... fake_dt.now.return_value = dt.datetime(2026, 2, 17, 9, 0, 0)
... print(is_morning())
...
True
해설:
- 실행 시각에 따라 달라지는 로직을 고정하면 테스트가 언제 실행돼도 동일한 결과를 냅니다.
- 다만 실제 프로젝트에서는 모듈 import 경로 기준으로 패치 위치를 더 정확히 잡아야 합니다.
예제 4) autospec=True로 잘못된 호출을 조기 발견
>>> from unittest.mock import patch
>>> class Client:
... def fetch(self, user_id, include_history=False):
... return {"id": user_id}
...
>>> with patch.object(Client, "fetch", autospec=True) as mock_fetch:
... c = Client()
... c.fetch("u-100", include_history=True)
... print(mock_fetch.call_args)
...
call(<__main__.Client object at ...>, 'u-100', include_history=True)
해설:
autospec=True를 사용하면 실제 시그니처와 맞지 않는 호출을 테스트 단계에서 바로 잡을 수 있습니다.- 팀 규모가 커질수록 "호출은 됐는데 파라미터가 틀린" 버그를 줄이는 효과가 큽니다.
자주 하는 실수
실수 1) 패치 경로를 정의 모듈로 잡아 효과가 없는 경우
>>> from unittest.mock import patch
>>> # 잘못된 예시(개념): service.py가 from client import api_call 로 가져왔다면
>>> # patch("client.api_call")은 service 내부 참조에는 적용되지 않을 수 있음
원인:
patch는 "어디에서 정의됐는지"가 아니라 "테스트 대상 코드가 현재 어떤 이름으로 참조하는지"를 기준으로 동작합니다.- 그래서 import 방식(
import xvsfrom x import y)에 따라 패치 경로가 달라집니다.
해결:
>>> # 올바른 방향(개념): service 모듈에서 사용하는 이름을 패치
>>> # patch("service.api_call")
>>> print("패치 기준: 사용 위치")
패치 기준: 사용 위치
- 테스트 작성 전 대상 모듈의 import 라인을 먼저 확인하세요.
- "패치했는데 진짜 API가 호출된다"면 경로가 틀렸을 가능성이 가장 큽니다.
실수 2) Mock을 너무 느슨하게 써서 오타를 못 잡는 경우
- 증상: 존재하지 않는 메서드(
clinet.sned)를 호출해도 테스트가 통과합니다. - 원인: 기본
Mock은 어떤 속성이든 동적으로 만들어 주기 때문에 오타가 숨어버립니다. - 해결:
spec또는autospec을 사용해 실제 객체 인터페이스를 강제합니다.
>>> from unittest.mock import Mock
>>> class Mailer:
... def send(self, to, body):
... return True
...
>>> m = Mock(spec=Mailer)
>>> m.send("[email protected]", "hello")
<Mock name='mock.send()' id='...'>
>>> try:
... m.sned("[email protected]", "typo")
... except AttributeError as e:
... print(type(e).__name__)
...
AttributeError
실수 3) 호출 횟수만 확인하고 인자 검증을 빼먹는 경우
>>> from unittest.mock import Mock
>>> repo = Mock()
>>> def save_user(repo, user_id):
... repo.save({"id": user_id, "active": True})
...
>>> save_user(repo, "u-77")
>>> repo.save.called
True
원인:
called == True만 보면 "호출됐다"는 사실만 알 수 있고, 잘못된 payload가 전달돼도 놓칩니다.
해결:
>>> repo.save.assert_called_once_with({"id": "u-77", "active": True})
- 테스트 실패 메시지가 명확해져 디버깅 속도가 빨라집니다.
- 특히 API 요청 body, SQL 파라미터, 이벤트 스키마 검증에서 반드시 인자 단위 검증을 하세요.
실무 패턴
- 입력 검증 규칙: 외부 시스템 호출 전 파라미터 정규화를 별도 함수로 분리하고, 해당 함수는 순수 함수 테스트로 빠르게 검증합니다.
- 로그/예외 처리 규칙: 네트워크 장애 시
side_effect로 예외를 재현하고, 로깅 함수가 어떤 레벨(warning/error)로 호출됐는지까지 검증합니다. - 재사용 함수/구조화 팁: 공통 패치 세트(시간 고정, UUID 고정, 환경변수 고정)는 fixture/헬퍼로 묶어 테스트 중복을 줄입니다.
- 성능/메모리 체크 포인트: 무거운 통합 테스트는 소수로 유지하고, 대부분은 mock 기반 단위 테스트로 구성해 CI 시간을 짧게 유지합니다.
추가로 팀에서 합의하면 좋은 규칙이 있습니다. 첫째, 단위 테스트는 외부 I/O를 금지하고 mock으로 대체합니다. 둘째, 통합 테스트만 실제 I/O를 허용합니다. 셋째, 배포 전 파이프라인에서 mock 테스트와 통합 테스트를 분리해 실패 원인을 즉시 파악할 수 있게 합니다. 이렇게 계층을 나누면 "어제는 통과했는데 오늘은 외부 API 장애 때문에 실패" 같은 상황을 줄일 수 있습니다. 결국 unittest.mock의 목적은 테스트를 속이는 것이 아니라, 테스트 범위를 명확히 나눠 책임 있는 검증을 가능하게 만드는 데 있습니다.
오늘의 결론
한 줄 요약: unittest.mock은 테스트를 빠르게 만드는 도구가 아니라, 실패 원인을 코드 로직으로 좁혀 신뢰도를 높이는 도구입니다.
기억할 것:
patch는 반드시 "사용 위치"를 기준으로 적용합니다.spec/autospec으로 인터페이스 오타를 조기에 차단합니다.- 호출 여부만 보지 말고 인자(
assert_called_once_with)까지 검증해야 합니다.
연습문제
fetch_profile(api, user_id)함수에서api.get_user(user_id)를 호출하도록 만들고,Mock으로 정상 응답/예외 응답 두 시나리오를 테스트해 보세요.- 시간 기준 할인 함수
is_discount_time()를 만들고,patch로 오전 10시/오후 3시를 고정해 결과를 검증해 보세요. send_email(mailer, to, body)함수 테스트에서assert_called_once_with를 사용해 제목/본문 포맷이 정확한지 검증해 보세요.
이전 강의 정답
parse_date(text)에서 구형 포맷 경고 후 변환
>>> import warnings
>>> from datetime import datetime
>>> def parse_date(text):
... if "/" in text:
... warnings.warn("구형 날짜 포맷(YYYY/MM/DD)입니다. YYYY-MM-DD 사용을 권장합니다.", UserWarning)
... text = text.replace("/", "-")
... return datetime.strptime(text, "%Y-%m-%d").date()
...
>>> parse_date("2026/02/17")
datetime.date(2026, 2, 17)
legacy_login()의DeprecationWarning검증
>>> import warnings
>>> def legacy_login(user_id):
... warnings.warn("legacy_login은 제거 예정입니다. new_login을 사용하세요.", DeprecationWarning, stacklevel=2)
... return {"user_id": user_id, "ok": True}
...
>>> with warnings.catch_warnings(record=True) as bucket:
... warnings.simplefilter("always")
... _ = legacy_login("u-1")
... print(len(bucket), bucket[0].category.__name__)
...
1 DeprecationWarning
- 환경별 경고 정책 함수
>>> import warnings
>>> def apply_warning_policy(env):
... warnings.resetwarnings()
... if env == "ci":
... warnings.simplefilter("error", DeprecationWarning)
... elif env == "prod":
... warnings.filterwarnings("ignore", category=UserWarning, message=".*미세 노이즈.*")
... warnings.simplefilter("default", DeprecationWarning)
... else:
... warnings.simplefilter("default")
...
>>> apply_warning_policy("ci")
>>> try:
... warnings.warn("deprecated test", DeprecationWarning)
... except DeprecationWarning:
... print("ci에서는 경고를 실패로 처리")
...
ci에서는 경고를 실패로 처리
실습 환경/재현 정보
- 실행 환경:
condaenvpython100(Python 3.11.14) - 가정한 OS: macOS/Linux 공통
- 테스트 러너 기준:
python -m unittest또는pytest에서 동일 개념 적용 가능 - 재현 팁: 패치 경로 문제를 줄이려면 테스트 대상 모듈의 import 구문을 먼저 확인