5 min read

파이썬과 DDD로 라이프 캘린더 만들기 #8 캘린더 수정 기능

우리는 1편부터 7편까지에 걸쳐 캘린더 생성 기능을 완성했습니다. 이번 글에서는 정리한 요구사항 중, 생성된 캘린더의 이름, 생년월일, 예상 수명은 언제든 수정할 수 있다.에 해당하는 기능을 한 편에 걸쳐 빠르게 만들어 보겠습니다. 필요한 설정과 인프라 코드를 모두 만들어 두었기 때문에 금방 할 수 있을 겁니다!

모델 작성

우선 TestCalendar에 다음과 같은 테스트 코드를 작성합시다.

import datetime

from src.domain.entity.calendar import Calendar


class TestCalendar:
    ...

    def test_update_basic_info(self):
        calendar = Calendar.create("고도", datetime.date(1988, 6, 21), 80)

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

        assert calendar.name == name
        assert calendar.birthday == birthday
        assert calendar.lifespan == lifespan
        assert len(calendar.years) == lifespan + 1
        assert len(calendar.years[0].weeks) == 52

테스트 실패를 확인하고 Calendar 엔티티에 경로에 다음과 같은 메소드를 작성해줍시다.

class Calendar:
    ...

    def update_basic_info(
        self, name: str, birthday: datetime.date, lifespan: int
    ) -> None:
        self.name = name
        self.birthday = birthday
        self.lifespan = lifespan

테스트를 다시 실행하면 다음과 같은 에러를 볼 수 있을 것입니다.

>       assert len(calendar.years) == lifespan + 1
E       assert 81 == (100 + 1)
E        +  where 81 = len([<src.domain.entity.calendar.Year object at 0x1062c5dd0>, <src.domain.entity.calendar.Year object at 0x106323c90>, <sr... <src.domain.entity.calendar.Year object at 0x10632a490>, <src.domain.entity.calendar.Year object at 0x10632b210>, ...])
E        +    where [<src.domain.entity.calendar.Year object at 0x1062c5dd0>, <src.domain.entity.calendar.Year object at 0x106323c90>, <sr... <src.domain.entity.calendar.Year object at 0x10632a490>, <src.domain.entity.calendar.Year object at 0x10632b210>, ...] = <src.domain.entity.calendar.Calendar object at 0x106392e50>.y

update_basic_info 메소드에서 years를 업데이트하지 않았기 때문이죠! create 메소드처럼 Year의 리스트를 똑같이 업데이트할 필요가 있습니다. _update_years 비공개 메소드를 추가해서 해결하고 테스트가 성공하는 것을 확인합니다.

class Calendar:
    ...

    def update_basic_info(
        self, name: str, birthday: datetime.date, lifespan: int
    ) -> None:
        self.name = name
        self.birthday = birthday
        self.lifespan = lifespan 
        self._update_years()

    def _update_years(self) -> None:
        self.years = [
            Year(yearnum=yearnum)
            for yearnum in range(
                self.birthday.year, self.birthday.year + self.lifespan + 1
            )
        ]

create 메소드에서도 _update_years를 재사용해주고 모든 테스트가 통과하는지 확인해봅시다. 여기서 커밋합시다!

class Calendar:
    ...

    @classmethod
    def create(cls, name: str, birthday: datetime.date, lifespan: int) -> Calendar:
        instance = cls(
            id=uuid.uuid4(), name=name, birthday=birthday, lifespan=lifespan, years=[]
        )
        instance._update_years()
        return instance

기능 작성

캘린더 생성과 동일한 느낌으로 UpdateCalendar 객체를 테스트와 함께 작성해 줍시다. 생성이 아니라 업데이트이기 때문에, 캘린더 모델의 ID가 필요하다는 점에 주의해 줍시다. 작성 후에는 DI 컨테이너에 등록도 같이 해줍니다.

# src/appl/update_calendar.py

class UpdateCalendar:
    def __init__(self, db_context: IDBContext, calendar_repo: ICalendarRepo) -> None:
        self.db_context = db_context
        self.calendar_repo = calendar_repo

    def run(
        self, calendar_id: uuid.UUID, name: str, birthday: datetime.date, lifespan: int
    ) -> None:
        with self.db_context.begin_tx():
            calendar = self.calendar_repo.get_or_error(calendar_id)
            calendar.update_basic_info(name, birthday, lifespan)
# src/appl/container.py

class Container(IContainer):
    def compose(self) -> None:
		...

        self.register(
            UpdateCalendar(self.resolve(SAContext), self.resolve(SACalendarRepo))
        )

생성 테스트를 작성했을 때와는 다르게, 파이썬의 테스트 도구인 MagicMock을 이용해서 테스트 코드 실행시 데이터베이스가 필요 없도록 설정해 줍시다.

# test/appl/test_update_calendar.py

class TestUpdateCalendar:
    def test_run(self):
        calendar = self._create_calendar()
        db_context = MagicMock(spec=IDBContext)
        calendar_repo = MagicMock(spec=ICalendarRepo)
        calendar_repo.get_or_error.return_value = calendar
        command = UpdateCalendar(db_context, calendar_repo)

        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)

테스트 코드에서 UpdateCalendar 객체를 DI 컨테이너를 통해서 가져오는 것이 아니라 직접 생성해주는 것을 볼 수 있습니다. 실제로는 진짜 동작하는 객체를 주입해야 하지만, 테스트 환경에서는 mock 객체를 주입해야하기 때문입니다. 나중에 이 프로젝트에 환경별로 설정을 따로 구성할 수 있게 만들고 나면, 컨테이너도 환경별로 따로 구성할 생각입니다. 그 때 까지는 이대로 두겠습니다. 테스트가 동작하는 것까지 확인하고 커밋해줍시다!

HTTP 공개

기능에 HTTP 핸들러를 연결하는 것은 캘린더 생성과 동일합니다. 캘린더 ID를 URL 경로에 표기해 주는 부분만 확인합시다. 스웨거에 들어가서 API를 호출해본 후에 커밋합니다!

# src/http/v1/calendar.py

class UpdateCalendarReq(BaseModel):
    name: str
    birthday: datetime.date
    lifespan: int


@api_router_calendar.post("/{calendar_id}/update")
async def update(calendar_id: uuid.UUID, req: UpdateCalendarReq):
    container.resolve(UpdateCalendar).run(
        calendar_id, req.name, req.birthday, req.lifespan
    )


미리 만들어둔 도구들을 이용해서 새 기능을 빠르게 완성해 보았습니다. 다음 글에서는 위에서 잠깐 언급했던, 우리 프로젝트를 환경별로 설정을 구성하고 설정에 맞게 DI 컨테이너 등의 동작을 구분할 수 있게 만들어 보겠습니다.