[Python unittest - 1] unittest.mock 으로 Mock 객체 다루기

roachroach
9 min read

소프트웨어 개발에서 테스트의 중요성은 아무리 강조해도 지나치지 않습니다. 특히 단위 테스트(Unit Test)는 코드의 각 부분이 의도한 대로 정확하게 동작하는지 검증하는 필수적인 과정입니다. 하지만 테스트 대상 코드가 외부 시스템, 데이터베이스, 혹은 아직 구현되지 않은 다른 모듈에 의존하고 있다면 어떻게 해야 할까요? 바로 이때 모의 객체(Mock Object)가 강력한 해결책이 됩니다.

Python의 표준 라이브러리인 unittest.mock은 이러한 모의 객체를 생성하고 관리하는 데 필요한 다양한 도구를 제공합니다. 오늘은 unittest.mock 라이브러리, 특히 MagicMock 클래스와 patch 기능을 중심으로 모의 객체를 활용한 테스트 작성법을 자세히 알아보겠습니다.

테스트 코드에서 자주 등장하는 패칭(Patching)이라는 용어는 테스트가 실행되는 동안 특정 객체나 속성을 임시로 다른 것(주로 모의 객체)으로 교체하는 행위를 의미합니다. 이를 통해 테스트 환경을 격리하고 예측 가능한 결과를 얻을 수 있습니다.

기본 Mock 객체 생성 및 사용: MagicMock

unittest.mock 라이브러리에서 가장 핵심적인 클래스는 MagicMock입니다. 이 클래스는 매우 유연하여, 존재하지 않는 속성이나 메서드에 접근하더라도 즉석에서 새로운 Mock 객체를 반환하며 모든 상호작용을 기록합니다.

간단한 예시 클래스를 정의하고, 이 클래스의 메서드를 MagicMock으로 대체해보겠습니다.

class ProductClass:
  def method(self):
    # 내부적으로 something 메서드를 호출합니다.
    return self.something(1, 2, 3)
  def something(self, a, b, c):
    # 실제로는 어떤 작업을 수행하지만, 테스트에서는 이 부분을 모킹할 것입니다.
    print(a, b, c)

이제 ProductClass의 인스턴스를 생성하고, something 메서드를 MagicMock 객체로 교체(패치)한 후, method를 호출했을 때 something 메서드가 예상된 인자와 함께 호출되었는지 확인해보겠습니다.

from unittest.mock import MagicMock

real = ProductClass()
# real 객체의 something 메서드를 MagicMock 인스턴스로 교체합니다.
real.something = MagicMock()

# real 객체의 method를 호출합니다. 내부적으로는 real.something(1, 2, 3)이 호출될 것입니다.
# 하지만 real.something은 이제 MagicMock 객체이므로, 실제 print는 실행되지 않습니다.
real.method()

# real.something 모의 객체가 정확히 한 번, 인자 (1, 2, 3)과 함께 호출되었는지 검증합니다.
real.something.assert_called_once_with(1, 2, 3)

위 코드에서 real.something = MagicMock() 라인은 real 객체의 something 속성을 MagicMock 인스턴스로 런타임에 교체합니다. 이후 real.method()가 호출되면, 내부의 self.something(1, 2, 3)은 실제 something 메서드가 아닌 MagicMock 객체를 호출하게 됩니다. MagicMock은 이 호출을 기록하며, assert_called_once_with(1, 2, 3)를 통해 이 기록을 검증할 수 있습니다.

Mock 객체 동작 제어하기

Mock 객체의 진정한 힘은 그 동작을 테스트 상황에 맞게 제어할 수 있다는 점입니다. 반환값을 설정하거나, 특정 속성값을 지정하거나, 심지어 예외를 발생시키도록 만들 수도 있습니다.

1. 반환값 설정 (return_value)

Mock 객체가 특정 메서드 호출 시 정해진 값을 반환하도록 설정할 수 있습니다.

mock = MagicMock()
# mock 객체의 get_name 메서드가 호출될 때 "roach"를 반환하도록 설정합니다.
mock.get_name.return_value = "roach"

# mock.get_name()을 호출하면 설정된 "roach"가 반환됩니다.
print(mock.get_name()) # 출력: roach

2. 속성값 설정

Mock 객체에 직접 속성값을 할당할 수도 있습니다.

mock = MagicMock()
# mock 객체에 name 속성을 만들고 "roach" 값을 할당합니다.
mock.name = "roach"

# mock.name으로 속성값에 접근할 수 있습니다.
print(mock.name) # 출력: roach

3. 예외 발생시키기 (side_effect)

테스트하려는 코드가 특정 상황에서 발생하는 예외를 잘 처리하는지 검증해야 할 때가 있습니다. side_effect 속성을 사용하면 Mock 객체가 호출될 때 예외를 발생시키도록 설정할 수 있습니다.

간단한 서비스-리포지토리 구조를 예로 들어보겠습니다.

class ARepository:
  def __init__(self, db_connection) -> None:
    self.db_connection = db_connection

  def find_by_id(self):
    # 실제로는 데이터베이스에서 조회하지만, 테스트에서는 이 부분을 모킹합니다.
    return self.db_connection.find_by_id()

class AService:
  def __init__(self, repository) -> None:
    self.repository = repository

  def find_by_id(self):
    # 내부적으로 리포지토리의 find_by_id를 호출합니다.
    return self.repository.find_by_id()

이제 AService를 테스트하는데, ARepositoryfind_by_id 메서드가 예외를 발생시키는 경우를 시뮬레이션해 보겠습니다.

# ARepository의 Mock 객체를 생성합니다.
mock_repository = MagicMock()
# mock_repository의 find_by_id 메서드가 호출될 때 Exception("error")를 발생시키도록 설정합니다.
mock_repository.find_by_id.side_effect = Exception("error")

# AService 인스턴스를 생성할 때 실제 리포지토리 대신 Mock 객체를 주입합니다.
service = AService(repository=mock_repository)

# service.find_by_id()를 호출하면 내부적으로 mock_repository.find_by_id()가 호출되고,
# 설정된 side_effect에 따라 예외가 발생합니다.
try:
  service.find_by_id()
except Exception as e:
  print(e) # 출력: error

호출 추적 및 검증

MagicMock 객체는 자신에게 발생한 모든 호출(메서드 호출, 속성 접근 등)을 기록합니다. mock_calls 속성을 통해 이 기록을 확인할 수 있습니다.

mock = MagicMock()
mock.method()
mock.attribute.method(10, x=53)

# mock 객체에 기록된 호출 내역을 확인합니다.
print(mock.mock_calls)
# 출력: [call.method(), call.attribute.method(10, x=53)]

이 호출 기록을 바탕으로 다양한 assert_* 메서드를 사용하여 Mock 객체가 예상대로 사용되었는지 검증할 수 있습니다.

  • assert_called_once_with(*args, **kwargs): 정확히 한 번, 지정된 인자와 함께 호출되었는지 검증합니다.

  • assert_called_with(*args, **kwargs): 마지막으로 호출될 때 지정된 인자와 함께 호출되었는지 검증합니다.

  • assert_any_call(*args, **kwargs): 한 번이라도 지정된 인자와 함께 호출된 적이 있는지 검증합니다.

만약 특정 메서드가 여러 번 호출되었고, 그중 특정 인자로 호출된 적이 있는지 확인하고 싶다면 assert_any_call을 사용해야 합니다. assert_called_with는 마지막 호출만을 비교하기 때문에 주의해야 합니다.

mock = MagicMock()
mock.attribute.method(10, x=53) # 첫 번째 호출
mock.attribute.method(10, x=43) # 두 번째 호출 (마지막 호출)

# assert_called_with는 마지막 호출(x=43)과 비교합니다.
try:
    mock.attribute.method.assert_called_with(10, x=53) # 첫 번째 호출 인자
except AssertionError as e:
    print(f"AssertionError: {e}") # 에러 발생: Expected: method(10, x=53) Actual: method(10, x=43)

# assert_any_call은 호출 기록 중 일치하는 것이 있는지 확인합니다.
mock.attribute.method.assert_any_call(10, x=53) # 성공
mock.attribute.method.assert_any_call(10, x=43) # 성공

side_effect의 고급 활용

side_effect 속성은 단순히 예외를 발생시키는 것 외에도 다양한 방식으로 Mock 객체의 동작을 제어할 수 있습니다.

1. 순차적인 반환값/동작 정의 (이터러블 활용)

side_effect에 리스트나 튜플 같은 이터러블(iterable) 객체를 할당하면, Mock 객체가 호출될 때마다 해당 이터러블의 다음 요소를 반환하거나 실행합니다.

from collections import namedtuple

Product = namedtuple('Product', ['name', 'price'])
product1 = Product('apple', 1000)
product2 = Product('banana', 2000)
product3 = Product('orange', 3000)
products = [product1, product2, product3]

mock = MagicMock()
# is_exist 메서드가 호출될 때마다 순서대로 False, True, True를 반환하도록 설정합니다.
mock.is_exist.side_effect = [False, True, True]

for product in products:
  print(f"Checking: {product}")
  # 첫 번째 호출 시 False 반환, 두 번째/세 번째 호출 시 True 반환
  if mock.is_exist(product):
    print("  -> Already exists, skipping.")
    continue
  print(f"  -> New product: {product}")

# 출력:
# Checking: Product(name='apple', price=1000)
#   -> New product: Product(name='apple', price=1000)
# Checking: Product(name='banana', price=2000)
#   -> Already exists, skipping.
# Checking: Product(name='orange', price=3000)
#   -> Already exists, skipping.

2. 호출 인자에 따른 동적 동작 정의 (함수 활용)

side_effect에 함수를 할당하면, Mock 객체가 호출될 때마다 해당 함수가 대신 실행됩니다. 이때 Mock 객체에 전달된 인자가 그대로 side_effect 함수에 전달되므로, 입력 인자에 따라 다른 값을 반환하거나 다른 동작을 수행하도록 프로그래밍할 수 있습니다.

from unittest.mock import MagicMock

# 특정 인자 조합에 대해 예상되는 반환값을 정의합니다.
# 딕셔너리 키는 인자를 (키, 값) 쌍의 정렬된 튜플로 만들어 순서에 무관하게 처리합니다.
expected_results = {
    (('x', 1), ('y', 2)): 3,
    (('x', 4), ('y', 9)): 13
}

def side_effect_func(*args, **kwargs):
    """Mock 객체 호출 시 실행될 함수. kwargs를 기반으로 결과를 반환합니다."""
    print(f"side_effect_func called with: args={args}, kwargs={kwargs}")
    if kwargs and isinstance(kwargs, dict):
        # 입력 kwargs를 정렬된 튜플 키로 변환
        key = tuple(sorted(kwargs.items()))
        if key in expected_results:
            return expected_results[key]
    # 예상치 못한 인자가 들어온 경우 예외 발생 (선택 사항)
    raise KeyError(f"Unexpected arguments: {args}, {kwargs}")

mock = MagicMock()
# add 메서드의 side_effect로 위에서 정의한 함수를 할당합니다.
mock.add.side_effect = side_effect_func

# 테스트 실행
result1 = mock.add(x=1, y=2) # side_effect_func({'x': 1, 'y': 2}) 실행 -> 키 (('x', 1), ('y', 2)) -> 값 3 반환
result2 = mock.add(y=9, x=4) # side_effect_func({'y': 9, 'x': 4}) 실행 -> 키 (('x', 4), ('y', 9)) -> 값 13 반환

print(f"Result 1: {result1}") # 출력: Result 1: 3
print(f"Result 2: {result2}") # 출력: Result 2: 13

assert result1 == 3
assert result2 == 13

mock.add.assert_any_call(x=1, y=2)
mock.add.assert_any_call(x=4, y=9)

print("Test passed!")

이처럼 함수를 side_effect로 사용하면 매우 복잡하고 동적인 Mock 동작을 구현할 수 있습니다.

패칭(Patching): 임시로 객체 교체하기

앞서 언급했듯이, 패칭은 테스트 중에 특정 객체나 속성을 임시로 교체하는 기술입니다. unittest.mock은 이를 위한 patch 함수를 제공하며, 주로 컨텍스트 관리자(context manager)나 데코레이터(decorator) 형태로 사용됩니다.

1. patch 컨텍스트 관리자

with 문과 함께 patch를 사용하면 with 블록 내에서만 지정된 대상이 Mock 객체로 교체됩니다. 블록을 벗어나면 원래대로 복구됩니다. 주로 테스트 범위 내에서 외부 라이브러리 함수(예: requests.get)나 직접 제어하기 어려운 의존성을 모킹할 때 유용합니다.

import requests
from unittest.mock import patch

def get_naver():
  """네이버 페이지를 요청하고 상태 코드에 따라 결과를 반환하는 함수"""
  try:
      response = requests.get("https://www.naver.com")
      print(f"response status: {response.status_code}")
      if response.status_code == 200:
        return "success"
      else:
        return "failure"
  except requests.exceptions.RequestException as e:
      print(f"Request failed: {e}")
      return "error"

# patch 컨텍스트 관리자를 사용하여 'requests.get' 함수를 모킹합니다.
with patch("requests.get") as mock_get: # mock_get은 requests.get의 Mock 객체입니다.
  print(f"Mock object for requests.get: {mock_get}")

  # requests.get이 반환할 Mock 응답 객체를 설정합니다.
  mock_response = MagicMock()
  mock_response.status_code = 200
  mock_get.return_value = mock_response # mock_get (즉, 모킹된 requests.get) 호출 시 mock_response 반환

  # get_naver() 함수를 호출합니다. 내부의 requests.get은 mock_get으로 대체되어 실행됩니다.
  result = get_naver()
  print(f"Result inside patch: {result}")
  assert result == "success"

  # mock_get이 https://www.naver.com 인자와 함께 호출되었는지 검증합니다.
  mock_get.assert_called_once_with("https://www.naver.com")

# with 블록을 벗어나면 requests.get은 원래 함수로 복구됩니다.
print("\nAfter patch block:")
result_after = get_naver() # 실제 네트워크 요청 발생 (환경에 따라 성공/실패/에러)
print(f"Result outside patch: {result_after}")

2. patch.object 데코레이터/컨텍스트 관리자

patch.object는 특정 객체(클래스 또는 인스턴스)가 가진 속성(메서드 포함)을 패치할 때 사용합니다. patch와 달리 패치 대상을 객체와 속성 이름으로 직접 지정합니다. 데코레이터로 사용하면 테스트 함수 전체 범위에 걸쳐 패치가 적용되고, 함수 종료 시 자동으로 복구됩니다.

from unittest.mock import patch, sentinel # sentinel은 잠시 후에 설명합니다.

class SomeClass:
  attribute = 1 # 클래스 속성

original = SomeClass.attribute # 원래 값 저장 (1)
print(f"[Before Test] SomeClass.attribute: {SomeClass.attribute}")

# @patch.object 데코레이터를 사용하여 test 함수 실행 동안 SomeClass.attribute를
# sentinel.attribute라는 고유 객체로 교체합니다.
@patch.object(SomeClass, 'attribute', sentinel.attribute)
def test():
    print(f"  [Inside Test] SomeClass.attribute: {SomeClass.attribute}")
    # 테스트 함수 내에서는 SomeClass.attribute가 sentinel.attribute로 변경되었습니다.
    assert SomeClass.attribute == sentinel.attribute
    # original 변수는 영향을 받지 않습니다.
    print(f"  [Inside Test] Original value still: {original}")

test() # 테스트 함수 실행

# 테스트 함수 실행이 끝나면 SomeClass.attribute는 원래 값(1)으로 자동 복구됩니다.
print(f"[After Test] SomeClass.attribute: {SomeClass.attribute}")
assert SomeClass.attribute == original

patchpatch.object는 테스트 환경을 격리하고 예측 가능하게 만드는 데 필수적인 도구입니다.

unittest.mock.sentinel: 고유한 테스트 식별자 생성

테스트를 작성하다 보면, Mock 객체의 반환값이나 특정 속성값으로 사용할 고유한 객체가 필요할 때가 있습니다. 예를 들어, 단순히 None이나 빈 문자열, 0과 같은 값을 사용하면 테스트의 의도가 명확하지 않거나 우연히 실제 값과 겹칠 수도 있습니다.

이때 unittest.mock.sentinel 객체가 유용합니다. sentinel은 접근할 때마다 고유한 이름을 가진 객체를 생성해주는 특별한 객체입니다.

from unittest.mock import sentinel

# sentinel 객체의 속성에 접근하면 해당 이름의 고유한 객체가 생성됩니다.
print(sentinel.foo)       # 출력: sentinel.foo
print(sentinel.bar)       # 출력: sentinel.bar

# 각 sentinel 속성은 고유한 객체입니다.
print(sentinel.foo is sentinel.bar)  # 출력: False

# 같은 이름의 속성은 항상 동일한 객체를 반환합니다.
print(sentinel.foo is sentinel.foo)  # 출력: True

sentinel은 언제 사용하면 좋을까요?

  1. Mock 반환값 검증: Mock 객체가 특정 sentinel 객체를 반환하도록 설정하고, 실제 반환값이 해당 sentinel 객체와 동일한지(is) 검사하여 Mock 객체가 의도대로 동작했는지 명확하게 확인할 수 있습니다.

     from unittest.mock import MagicMock, sentinel
    
     # 고유한 sentinel 객체를 반환값으로 사용
     expected_object = sentinel.some_unique_object
     mock = MagicMock()
     mock.method.return_value = expected_object
    
     # 반환값이 예상된 sentinel 객체와 동일한지(is) 확인
     assert mock.method() is expected_object
    
  2. patch.object와의 조합: 특정 속성이 예상대로 패치되었는지 확인할 때 유용합니다. 특히 원래 값이 None과 같이 흔한 값일 경우, sentinel을 사용하면 패치가 적용되었는지 여부를 None 값 자체와 혼동하지 않고 명확하게 검증할 수 있습니다.

     from unittest.mock import patch, sentinel
    
     class RoachExternalApi:
         api_key = None # 초기값은 None
    
     # 테스트: api_key 속성을 sentinel.my_key로 패치하고 검증
     @patch.object(RoachExternalApi, 'api_key', sentinel.my_key)
     def test_patching_none():
         print(f"  [Inside Test] RoachExternalApi.api_key: {RoachExternalApi.api_key}")
         # api_key가 None이 아니라 고유한 sentinel.my_key로 패치되었는지 확인
         assert RoachExternalApi.api_key is sentinel.my_key
    
     print(f"[Before Test] RoachExternalApi.api_key: {RoachExternalApi.api_key}")
     test_patching_none()
     print(f"[After Test] RoachExternalApi.api_key: {RoachExternalApi.api_key}") # 원래 값 None으로 복구됨
    

    만약 위 예제에서 sentinel.my_key 대신 None으로 패치했다면, assert RoachExternalApi.api_key is None 검증은 통과하겠지만 이것이 패치의 결과인지 원래 값이 None이었기 때문인지 구분하기 어려울 수 있습니다. sentinel은 이러한 모호성을 제거해줍니다.

결론

Python의 unittest.mock 라이브러리는 단위 테스트 작성 시 의존성을 효과적으로 제어하고 테스트 환경을 격리하는 데 필수적인 도구입니다. MagicMock을 사용하여 유연한 모의 객체를 생성하고, return_valueside_effect로 동작을 제어하며, assert_* 메서드로 상호작용을 검증할 수 있습니다. 또한 patchpatch.object를 이용해 테스트 범위 내에서 객체나 속성을 임시로 교체하고, sentinel을 통해 고유한 테스트 식별자를 사용하여 테스트의 명확성과 견고함을 높일 수 있습니다.

이러한 기능들을 잘 이해하고 활용한다면, 복잡한 시스템에서도 신뢰성 높은 단위 테스트를 구축하는 데 큰 도움이 될 것입니다.

0
Subscribe to my newsletter

Read articles from roach directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

roach
roach