파이썬과 DDD로 라이프 캘린더 만들기 #9 테스트 컨테이너

저번 글에서는 캘린더 수정 기능 개발과 함께 테스트 코드를 작성했습니다. 이 때 필요한 객체는 테스트 케이스 안에서 직접 조립해서 사용했었는데요, 이번 글에서는 테스트 케이스도 DI 컨테이너를 통해서(src 패키지의 실제 코드 처럼) 객체를 구성할 수 있게 만들어 보겠습니다.

컨테이너 확장

그렇게 하려면 우선 기존의 DI 컨테이너와는 다른 환경과 객체 구성을 가지는 컨테이너가 필요합니다. 이를 테스트 컨테이너라고 부르겠습니다. 테스트 컨테이너에서는 파이썬의 테스트 도구인 MagicMock 객체를 적극적으로 사용할 예정이므로, IContainer.resolve 메소드가 MagicMock 타입에도 잘 동작하도록 아래와 같이 코드를 변경해 보겠습니다.

# src/appl/i_container.py

class IContainer(abc.ABC):
	...

    def resolve(self, type_: Type[T]) -> T:
        impl_type = type_
        if inspect.isabstract(type_):
            impl_type = None
            for t, o in self.obj_map.items():
                if isinstance(o, type_):
                    impl_type = t
                    break  # just use first implementation

            if impl_type is None:
                raise NotRegisteredTypeError(f"type: {type_}")

        try:
            obj = self.obj_map[impl_type]
        except KeyError:
            raise NotRegisteredTypeError(f"type: {type_}")

        return obj

MagicMockspec(혹은 spec_set)을 이용하면 MagicMock 객체도 isinstance 함수를 통해 원본처럼 타입을 구별할 수 있습니다. 일단 여기서 커밋하겠습니다.

이제 환경별로 다른 객체를 구성하기 위한 메소드를 IContainer.compose_by_env 라는 이름으로 추가해 봅시다.

# src/appl/i_container.py

class IContainer(abc.ABC):
    @abc.abstractmethod
    def compose_by_env(self) -> None:
        ...

기존 컨테이너의 구현체도 변경된 스펙에 맞춰 바꿔주어야 합니다. 그리고 환경별로 분리할 객체들은 compose_by_env 안에서 등록하고, 그렇지 않은 객체들은 기존대로 compose 메소드에 둡니다. 그리고 compose 메소드는 시작하자마자 compose_by_env 메소드를 호출해 줍니다. 여기서 커밋해 줍시다!

# src/appl/container.py

class Container(IContainer):
    def compose_by_env(self) -> None:
        # repository
        self.register(SA("postgresql://qodot@localhost/lifecalendar", {}))
        self.register(SAContext(self.resolve(SA)))
        self.register(SACalendarRepo(self.resolve(SA)))

    def compose(self) -> None:
        self.compose_by_env()

        # application
        self.register(
            CreateCalendar(self.resolve(IDBContext), self.resolve(ICalendarRepo))
        )
        self.register(
            UpdateCalendar(self.resolve(IDBContext), self.resolve(ICalendarRepo))
        )

테스트 컨테이너 작성

테스트 컨테이너를 만들기 위한 준비가 되었으니 실행에 옮겨봅시다. 테스트 패키지에 아래와 같은 코드를 작성합니다.

# test/appl/container.py

from unittest.mock import MagicMock

from src.appl.container import Container
from src.domain.repo.i_calendar_repo import ICalendarRepo
from src.domain.repo.i_db_context import IDBContext


class TestContainer(Container):
    def compose_by_env(self) -> None:
        # repository
        self.register(MagicMock(spec_set=IDBContext))
        self.register(MagicMock(spec_set=ICalendarRepo))


container = TestContainer()


def compose_container():
    global container
    container.compose()

실제 컨테이너를 상속받아 그대로 사용하되, 다른 객체 구성(이 경우에는 MagicMock을 통해)이 필요한 타입만 compose_by_env 메소드 안에 등록해 줍니다. 이 코드를 커밋합시다!

이제 방금 만든 컨테이너를 테스트 케이스에서도 사용해봅시다! pytest의 fixture를 이용해서 테스트 컨테이너를 재사용 가능하게 만들어주고 테스트 케이스에서 이를 받아 사용해줍시다. 기존처럼 테스트 케이스에서 직접 모든 객체를 만들어 구성하는 코드를 삭제하고, 실제 앱과 동일한 매커니즘으로 테스트 케이스를 작성할 수 있게 되었습니다.

# test/appl/conftest.py

from test.appl.container import compose_container
from test.appl.container import container as _container

import pytest


@pytest.fixture(scope="session")
def container():
    compose_container()
    return _container
# test/appl/test_create_calendar.py

class TestCreateCalendar:
    def test_run(self, container: IContainer):
        command = container.resolve(CreateCalendar)

        name = "고도"
        birthday = datetime.date(1988, 6, 21)
        lifespan = 80
        command.run(name, birthday, lifespan)

        assert command.calendar_repo.save.call_count == 1
# test/appl/test_update_calendar.py

class TestUpdateCalendar:
    def test_run(self, container: IContainer):
        command = container.resolve(UpdateCalendar)
        calendar = self._create_calendar()
        command.calendar_repo.get_or_error.return_value = calendar

        name = "뉴고도"
        birthday = datetime.date(2000, 6, 21)
        lifespan = 100
        command.run(calendar.id, name, birthday, lifespan)

        assert calendar.name == name
        assert calendar.birthday == birthday
        assert calendar.lifespan == lifespan

    def _create_calendar(self) -> Calendar:
        return Calendar.create("고도", datetime.date(1988, 6, 21), 80)

테스트를 실행해보고 모두 통과하는 것을 확인했다면 커밋해줍시다!

다음 글에서는 최초 1편에서 기획했던 요구사항 중 나머지인

  • 캘린더를 상세 조회하면 생년월일부터 수명에 따른 죽음까지의 주 매트릭스(가로 52주 X 세로 수명)를 보여준다.
  • 주 매트릭스에서는 과거(past) 주, 이번(now) 주, 미래(future) 주를 각각 다른 색으로 표시한다.

를 모두 구현해 보겠습니다. 아직 화면이 없기 때문에 아마도 JSON 데이터를 확인하는 정도로 만족해야 할 것 같네요!