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

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

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

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

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

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

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

```python
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` 메서드가 예상된 인자와 함께 호출되었는지 확인해보겠습니다.

```python
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 객체가 특정 메서드 호출 시 정해진 값을 반환하도록 설정할 수 있습니다.

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

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

#### 2\. 속성값 설정

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

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

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

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

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

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

```python
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`를 테스트하는데, `ARepository`의 `find_by_id` 메서드가 예외를 발생시키는 경우를 시뮬레이션해 보겠습니다.

```python
# 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` 속성을 통해 이 기록을 확인할 수 있습니다.

```python
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`는 마지막 호출만을 비교하기 때문에 주의해야 합니다.

```python
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 객체가 호출될 때마다 해당 이터러블의 다음 요소를 반환하거나 실행합니다.

```python
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` 함수에 전달되므로, 입력 인자에 따라 다른 값을 반환하거나 다른 동작을 수행하도록 프로그래밍할 수 있습니다.

```python
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`)나 직접 제어하기 어려운 의존성을 모킹할 때 유용합니다.

```python
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`와 달리 패치 대상을 객체와 속성 이름으로 직접 지정합니다. 데코레이터로 사용하면 테스트 함수 전체 범위에 걸쳐 패치가 적용되고, 함수 종료 시 자동으로 복구됩니다.

```python
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
```

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

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

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

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

```python
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 객체가 의도대로 동작했는지 명확하게 확인할 수 있습니다.
    
    ```python
    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` 값 자체와 혼동하지 않고 명확하게 검증할 수 있습니다.
    
    ```python
    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`](http://sentinel.my)`_key` 대신 `None`으로 패치했다면, `assert RoachExternalApi.api_key is None` 검증은 통과하겠지만 이것이 패치의 결과인지 원래 값이 `None`이었기 때문인지 구분하기 어려울 수 있습니다. `sentinel`은 이러한 모호성을 제거해줍니다.
    

### 결론

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

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