2 분 소요

오늘은 테스트코드를 작성하며 Mock객체사용을 최소화 해야하는 이유와 해결법에 관한 포스팅입니다.

평소에도 자주 개발관련 스터디를 하며 도와주시는 분이 있는데 Mock객체를 쓰지 말고 유닛 테스트 짜는법을 알려주셧습니다.
이방법을 써보고 개인적으로 Mock객체를 사용한 유닛테스트에 비해 좋아진점을 포스팅합니다!

Mock을 사용한 테스트 코드

간단한 예제 코드입니다.

@Test
void register() throws Exception {
  
  //given
  NoticeDTO noticeDTO = NoticeDTO.builder()
    .title("공지사항 제목 1")
    .content("공지사항 내용 1")
    .endDate(LocalDate.now().plusDays(1))
    .build();

  Notice notice = Notice.builder()
    .id(1L)
    .title("공지사항 제목 1")
    .content("공지사항 내용 1")
    .endDate(LocalDate.now().plusDays(1))
    .build();
  
  given(noticeRepository.save())
    .willReturn(notice);
  
  //when
  Notice register = noticeService.register(noticeDTO);

  //then
  assertThat(register.getId()).isNotNull();
}

해당 유저가 새로운 멤버십을 가입한다는 것을 가입을 위해 결제하는 테스트코드입니다.
보시면 다른 서비스와 DB계층사용을 위해 noticeService noticeRepository 목객체를 주입하였습니다.
다음과 같을땐 큰 문제 없을수 있습니다. 아직은 테스트 코드가 적고 만든지 얼마 안되었거든요.
하지만 느껴지는 문제점과 시간이 지나 발견되는 문제점들이 있습니다.

가독성

제일 먼저 테스트코드가 너무 길어져 가독성에 문제가 생깁니다.
가독성에도 문제가 생깁니다.
위의 경우 noticeRepository의 save만 모킹하면되지만 만약 모킹해야될 객체가 많아지면 모키토 코드가 굉장히 많아지는 문제가 발생합니다.

TDD와 궁합

테스트작성시 협력객체의 행위를 먼저 정해야합니다.
특히 TDD에 입각하여 개발을 할 때 테스트코드를 먼저 작성시 협력객체의 세부구현을 먼저 정해야한다는 문제가 있습니다.

테스트가 많아 질때

자 그럼 이제 테스트가 많아 질 때를 봐야합니다.
먼저 매번 해당 서비스에서 주입받는 객체의 메서드를 이용할때마다 given을 활용하여 데이터를 셋팅하여야 합니다.
모든 테스트 코드마다 given등 모킹 관련 코드가 메서드에 들어가게 됩니다.
공통된 부분의 given들은 @BeforeEach등을 이용하여 given을 셋팅하여 줄수 있지만
해당 테스트에서만 이용하는 기능들의 경우 여전히 given으로 데이터를 셋팅해야합니다.
즉 테스트 메서드 작성시 매번 given코드 작성 및 테스트 데이터 셋팅등 피로도가 증가합니다.

협력 객체의 리펙토링

시간이 지나 리펙토링이나 추가 요구사항으로 해당 서비스로직이 리펙토링 또는 변경되어야 할 일이 생겼습니다.
given을 통해 데이터를 셋팅 할때의 또 다른 문제점이 여기서 발견됩니다.
협력 객체가 어떤 식으로 협력하는지가 테스트코드에 들어난다는 것이죠.
만약 위의 예제코드에서 userMemberShipRepository.getMembership()가 List가 아닌 List로 변경되야하는 경우 해당 테스트들 given을 전부 수정해야합니다.\ 내부 구현에 변경에 영향받는 테스트코드라는 뜻입니다.

Mock을 피하는 방법

먼저 코드부터 보시겠습니다.

public class NoticeRepositoryResolver implements ParameterResolver {
    @Override
    public boolean supportsParameter(ParameterContext parameterContext, ExtensionContext extensionContext) throws ParameterResolutionException {
        return parameterContext.getParameter().getType() == NoticeRepository.class;
    }

    @Override
    public Object resolveParameter(ParameterContext parameterContext, ExtensionContext extensionContext) throws ParameterResolutionException {
        return new NoticeRepository() {
            @Override
            public Notice save(Notice notice) {
                return Notice.builder()
                        .id(1L)
                        .title("공지사항 제목 1")
                        .content("공지사항 내용 1")
                        .startDate(LocalDate.now())
                        .endDate(LocalDate.now().plusWeeks(1))
                        .build();
            }

            @Override
            public Notice update(Notice notice) {
                return notice;
            }
            // ...
        };
    }
}

ParameterResolver를 상속 받아 해당 NoticeRepository를 직접 정의한 구현체로 리턴합니다.

실제 테스트코드입니다.

@ExtendWith(NoticeRepositoryResolver.class) // NoticeRepositoryResolver에서 정의한 NoticeRepository를 파라미터로 리턴하기 위해 선언합니다.
class NoticeServiceTest {

    private NoticeService noticeService;

    @BeforeEach // NoticeRepositoryResolver에서 파라미터 타입이 NoticeRepository일시 resolveParameter실행시켜 객체를 주입받습니다.
    void setUp(NoticeRepository noticeRepository) {
        noticeService = new NoticeServiceImpl(noticeRepository, OBJECT_MAPPER);
    }

    @DisplayName("공지사항 등록")
    @Test
    void register() {
        //given
        NoticeDTO noticeDTO = NoticeDTO.builder()
                .title("공지사항 제목 1")
                .content("공지사항 내용 1")
                .endDate(LocalDate.now().plusDays(1))
                .build();

        //when
        Notice register = noticeService.register(noticeDTO);

        //then
        assertThat(register.getId()).isNotNull();

    }

    @DisplayName("공지사항 수정")
    @Test
    void modify() {
        //given
        NoticeDTO noticeDTO = NoticeDTO.builder()
                .id(1L)
                .title("공지사항 제목 수정")
                .content("공지사항 내용 수정")
                .build();

        //when
        Notice modify = noticeService.modify(noticeDTO);

        //then
        assertThat(modify)
                .extracting("id", "title", "content")
                .containsExactly(1L, "공지사항 제목 수정", "공지사항 내용 수정");
    }

}

먼저 각 테스트 메서드마다 given이 사라졌습니다.
추가로 테스트시 항상 결과값만 비교합니다. (내부 구현은 피하는것이 리펙토링에 안전합니다. 리펙토링에 더 안전한 테스트 코드(내부 구현 검증을 피하라!))
또한 given이 사라짐으로써 내부구현에 대한 관심사가 테스트코드에서 완전 분리되었습니다.
이제 NoticeRepository에 변경이 생겨도 테스트코드를 변경할 일은없습니다.(물론 Resolver 객체는 수정해야할수 있습니다.)

테스트코드가 짧아져 가독성에서도 많은 이점이 있습니다.
추가로 TDD로 개발을 할때 테스트 작성시 더이상 협력 객체를 신경쓰지 않고 테스트코드를 먼저 작성할수있습니다. <- 해당 방법을 연습한 가장 큰 이유입니다.

해당 코드들은 api-notice 위 링크에서 직접 코드를 확인 하실수 있습니다.

댓글남기기