Python의 Namespace와 단위 테스트 (with Mock)

Googy·2024년 6월 25일
0
post-thumbnail

단위 테스트와 테스트 더블

테스트 더블을 사용하면 테스트를 단순화하고 외부 환경과 독립된 코드를 테스트할 수 있다는 장점이 있다. 테스트 더블에는 다양한 종류가 있지만 여기서는 Stub과 Mock을 통칭하여 Mock이라고 부르겠다.

Mocking - 의존성 주입

  • client.py
class Client:
    def list_items(self):
        raise Exception("Network Exception!")

Mocking 여부를 쉽게 파악할 수 있도록, list_items가 에러를 발생시키도록 구현하였다.

  • service.py
import client


class Service:
    def __init__(self, _client: client.Client):
        self.client = _client

    def do_list(self):
        return self.client.list_items()
  • test_service.py
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에서의 Mocking

Python에서는 위처럼 의존성 주입 형태로 구현하지 않아도 unittest.mock.patch를 이용하여 Mocking을 수행할 수 있다.

  • client.py
def list_items():
    raise Exception("Network Exception!")
  • service.py
import client


def do_list():
    return client.list_items()
  • test_service.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'])

patch는 Namespace에 등록된 이름이 생성한 Mock 객체를 가리키도록 한다.

Python mock 주의사항

Python unittest에서는 의존성 주입 형태로 구현하지 않더라도 Mock을 주입할 수 있는 강력한 기능을 제공한다. 하지만 위 코드에서 import client 부분을 from client import list_items로 변경한다면 Mocking이 제대로 이루어지지 않고 테스트는 실패한다. 원인을 제대로 이해하기 위해 Python의 Namespace에 대해서 자세히 알아보자.

https://docs.python.org/ko/3/library/unittest.mock.html#id6

Python namespace

Python에서 Namespace는 Object 정보를 가리키는 이름을 매핑해 둔 공간으로 Dictionary(Key:Value) 형태로 저장된다.

  • Key: Name
  • Value: Object address

종류

Namespace에는 다음과 같이 4가지 종류가 있다.

  • Function namespace (Local namespace)
  • Enclosing namespace
  • Module namespace (Global namespace)
  • Built-in namespace

위 순서와 같이 좁은 범위에서 넓은 범위로 탐색한다.

Python에서 패키지는 디렉터리 단위, 모듈은 파일 단위로 볼 수 있다.

Python import

Python에서는 일반적으로 2가지 방식의 import 구문을 가지고 있다.

  • 모듈을 Import
    from <package> import <module>
  • 모듈 내 함수를 Import
    from <module> import <function>

이렇게 다른 import 문이 Namespace에서 어떤 차이가 있는지 예제를 통해서 살펴보자.

모듈을 Import 하는 경우

  • client.py
def list_items():
    raise Exception("Network Exception!")
  • service.py
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를 호출하게 된다.

모듈 내 함수를 Import 하는 경우

  • client.py
def list_items():
    raise Exception("Network Exception!")
  • service.py
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 함수를 가리키고 있다.

테스트 (patch 구문 수정)

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/

profile
재밌는 걸 만들고 싶어요

0개의 댓글