테스트 더블을 사용하면 테스트를 단순화하고 외부 환경과 독립된 코드를 테스트할 수 있다는 장점이 있다. 테스트 더블에는 다양한 종류가 있지만 여기서는 Stub과 Mock을 통칭하여 Mock이라고 부르겠다.
class Client:
def list_items(self):
raise Exception("Network Exception!")
Mocking 여부를 쉽게 파악할 수 있도록, list_items가 에러를 발생시키도록 구현하였다.
import client
class Service:
def __init__(self, _client: client.Client):
self.client = _client
def do_list(self):
return self.client.list_items()
from unittest import TestCase
from unittest.mock import Mock
from service import Service
class TestService(TestCase):
def test_do_list(self):
# given
mock_client = Mock()
mock_client.list_items.return_value = ['item1', 'item2', 'item3']
target_service = Service(mock_client)
# when
result = target_service.do_list()
# then
self.assertEqual(result, ['item1', 'item2', 'item3'])
일반적인 언어에서는 의존성 주입 형태로 구현해 두어야, 대상 서비스를 생성할 때 Mock 객체를 주입하여 Mock 테스트를 구현할 수 있다.
Python에서는 위처럼 의존성 주입 형태로 구현하지 않아도 unittest.mock.patch
를 이용하여 Mocking을 수행할 수 있다.
def list_items():
raise Exception("Network Exception!")
import client
def do_list():
return client.list_items()
from unittest import TestCase
from unittest.mock import patch
import service
class TestService(TestCase):
@patch('client.list_items', return_value=['items1', 'items2', 'items3'])
def test_do_list(self, mock_list_items):
result = service.do_list()
self.assertEqual(result, ['items1', 'items2', 'items3'])
patch는 Namespace에 등록된 이름이 생성한 Mock 객체를 가리키도록 한다.
Python unittest에서는 의존성 주입 형태로 구현하지 않더라도 Mock을 주입할 수 있는 강력한 기능을 제공한다. 하지만 위 코드에서 import client
부분을 from client import list_items
로 변경한다면 Mocking이 제대로 이루어지지 않고 테스트는 실패한다. 원인을 제대로 이해하기 위해 Python의 Namespace에 대해서 자세히 알아보자.
Python에서 Namespace는 Object 정보를 가리키는 이름을 매핑해 둔 공간으로 Dictionary(Key:Value) 형태로 저장된다.
Namespace에는 다음과 같이 4가지 종류가 있다.
위 순서와 같이 좁은 범위에서 넓은 범위로 탐색한다.
Python에서 패키지는 디렉터리 단위, 모듈은 파일 단위로 볼 수 있다.
Python에서는 일반적으로 2가지 방식의 import 구문을 가지고 있다.
from <package> import <module>
from <module> import <function>
이렇게 다른 import 문이 Namespace에서 어떤 차이가 있는지 예제를 통해서 살펴보자.
def list_items():
raise Exception("Network Exception!")
import client
def do_list():
return client.list_items()
service.py
모듈의 Namespace에 client라는 이름이 등록되고, 이는 client.py
모듈의 경로를 가리킨다.
from unittest import TestCase
from unittest.mock import patch
import service
class TestService(TestCase):
@patch('client.list_items', return_value=['items1', 'items2', 'items3'])
def test_do_list(self, mock_list_items):
result = service.do_list()
self.assertEqual(result, ['items1', 'items2', 'items3'])
service.py
모듈의 client는 실제 client.py
를 가리키기 때문에 client.list_items
를 패치하면 do_list가 Mock list_items를 호출하게 된다.
def list_items():
raise Exception("Network Exception!")
from client import list_items
def do_list():
return list_items()
앞 예제에서 service.py
모듈에서 기능은 변경하지 않고 import 구문만 (함수를 직접 import 하도록) 수정하였다.
위처럼 함수를 직접 import 할 경우, service.py
모듈의 Namespace에 list_items라는 이름이 등록되고, 이는 실제 list_items 함수를 가리킨다.
from unittest import TestCase
from unittest.mock import patch
import service
class TestService(TestCase):
@patch('client.list_items', return_value=['items1', 'items2', 'items3'])
def test_do_list(self, mock_list_items):
result = service.do_list()
self.assertEqual(result, ['items1', 'items2', 'items3'])
테스트 코드를 변경하지 않았다면, client.py
모듈의 list_items는 패치되어 Mock 객체를 가리키지만, do_list가 호출하는 service.py
모듈의 list_items는 여전히 실제 list_items 함수를 가리키고 있다.
from unittest import TestCase
from unittest.mock import patch
import service
class TestService(TestCase):
@patch('service.list_items', return_value=['items1', 'items2', 'items3'])
def test_do_list(self, mock_list_items):
result = service.do_list()
self.assertEqual(result, ['items1', 'items2', 'items3'])
client.py
모듈의 list_items가 아닌 service.py
모듈의 list_items를 패치하면, 테스트 대상인 do_list 함수에서 호출하는 list_items가 정상적으로 Mocking되어 테스트가 성공한다.
import 구문을 다양한 방식으로 사용할 경우 단위 테스트의 Mock 처리에서 혼란을 일으킬 수 있다. 따라서 import 하는 방법을 하나로 통일하여 컨벤션으로 정해두는 것을 추천한다. 개인적으로는 모듈을 import 하는 방법을 선호한다.
또 다른 방법으로는 Mocking 대상을 다른 언어와 같이 (Dependency Injection으로 구현하지 않더라도) 클래스로 구현하는 방법이 있다. 원리는 다음 글에서 설명하겠다.
https://docs.python.org/ko/3/library/unittest.mock.html
https://realpython.com/python-namespaces-scope/