Service 레이어의 단위 테스트를 작성해보자(2) (with Mockito)

NNIJGNUS·2024년 9월 7일

Unit Test

목록 보기
3/5

이전 게시글에서는 기초적인 서비스 레이어 단위 테스트 작성법과 이에 따른 테스트 커버리지 측정 방법에 대해서 알아봤다.

이번 게시글에서는 서비스 레이어 단위 테스트 작성 시 발생했던 이슈들과 이에 대한 트러블 슈팅을 기록하고자 한다.

Mock 객체의 메서드를 모방하고 싶을 때

@EntityListeners(AuditingEntityListener.class)
@MappedSuperclass
@Getter
public class BaseEntity {
    @CreatedDate
    @JsonSerialize(using = LocalDateTimeSerializer.class)
    @JsonDeserialize(using = LocalDateTimeDeserializer.class)
    @Column(updatable = false)
    private LocalDateTime createdAt;

    @LastModifiedDate
    @JsonSerialize(using = LocalDateTimeSerializer.class)
    @JsonDeserialize(using = LocalDateTimeDeserializer.class)
    private LocalDateTime updatedAt;
}

위 코드는 프로젝트에서 사용한 BaseEntity 클래스다.

또한 프로젝트에서는 위 클래스를 상속받는 BaseUser 클래스가 존재했는데, 테스트 환경에서 BaseUser 객체의 CreatedAtUpdatedAt을 조회해야 했다.

하지만 CreatedAtUpdatedAtJPAAuditing을 통해 자동으로 할당된다.
즉, 데이터베이스에 레코드가 저장될 시기에 할당되는 필드인 것이다.

하지만 테스트 환경의 Repository는 Mock 객체였기 때문에 데이터베이스에 레코드가 저장될 리 만무하였고, 따라서 CreatedAtUpdatedAt은 자연히 null 일 수 밖에 없었다.

결국 테스트 진행 시 NullPointerException 이 발생하는 상황, 어떻게 문제를 해결해야 할까?

1. @Setter 어노테이션 사용하기

간단히 BaseEntity 클래스에 Setter 어노테이션을 사용해 해결할 수 있다.

setCreatedAt(), setUpdatedAt() 메서드를 사용하여 필드를 할당해 줄 수 있고, 이를 통해 NullPointerException을 방지할 수 있었다.

하지만 이는 근본적인 해결책이 될 수 없었는데, 그 이유는 다음과 같았다.

테스트를 위해 코드를 수정하는게 올바른 행동일까?
그것도 누구나 쓰지 말라는 Setter를 추가하는게 맞는걸까?

하지만 그 때 당시 시간이 촉박했고, 우선은 현실에 순응해 미래의 나에게 고민을 떠넘기고 Setter의 따스한 품에 안기기로,,,

2. 메서드 Mocking하기

사실 가장 처음에 생각한 방법이지만 잘 통하지 않았다.

BaseUser user = new BaseUser();
given(user.getCreatedAt()).willReturn(LocalDateTime.now());

위는 내가 메서드를 Mocking하기 위해 작성한 코드다.

인텔리제이도 오류를 잡지 않고 컴파일도 정상적으로 됐지만, 테스트 실행시 런타임 예외가 발생한다.

이를 해석하면 아래와 같다.

  1. final/private/native/equals()/hashCode() 메서드 중 하나를 스텁(stub)하려고 했습니다. 이러한 메서드는 스텁하거나 검증할 수 없습니다. 비공식 부모 클래스에서 선언된 메서드를 모킹하는 것은 지원되지 않습니다.
  2. when() 내부에서 모의 객체가 아닌 다른 객체의 메서드를 호출했을 때 발생할 수 있습니다.

이 메시지를 처음 봤을 당시에는 Mockito 프레임워크에 대한 이해가 부족했었고, 첫번째 원인을 분석해 오류를 해결하고자 했었다.

하지만 잘 풀리지 않았고, Setter를 통해 NPE를 해결했었다.

이 후 며칠이 지나서 실마리를 잡을 수 있었고, 원인은 1번이 아닌 2번에 있었다.

테스트 코드의 BaseUser 객체는 모의 객체가 아니라 실제 객체였고, 메서드를 모방하기 위해서는 모의 객체로 바꿔줘야 할 필요가 있었던 것이다.

1차 수정

@Mock
BaseUser user = new BaseUser();

@Test
@DisplayName("mockingTest")
void mockingTest() {
    given(user.getCreatedAt()).willReturn(LocalDateTime.now());
    System.out.println(user.getCreatedAt());
}

BaseUser 객체를 Mock 객체로 선언해줬다.

하지만 여전히 같은 오류가 났고, 이 뒤로 며칠을 더 소요했다.

2차 수정

void mockingTest() {
    BaseUser user = spy(new BaseUser());
    given(user.getCreatedAt()).willReturn(LocalDateTime.now());
    
    System.out.println(user.getCreatedAt());
}

최종 수정본은 위와 같았다.

BaseUserspy() 메서드로 감싸 메서드를 모방할 수 있었다.

Mock vs Spy

Mock: 모의 객체를 생성한다. 만약 모방되지 않은 메서드를 호출한다면 예외를 발생시킬 수 있다.
Spy: 실제 객체를 스파이 객체로 모방한다. 만약 모방되지 않은 메서드를 호출한다면 실제 객체의 메서드를 호출한다.

실패 테스트 작성하기

지금까지는 성공 테스트만을 작성했지만, 실패 테스트 또한 성공 테스트 못지 않게 중요하다.
아래는 Mockito를 통한 실패 테스트 예시다.

@Test
@DisplayName("추첨 이벤트 조회 테스트 - 실패 (이벤트 없음)")
void getLotteryEventTest_Failure_NoLotteryEvent() {

    //given
    given(lotteryEventRepository.findAll()).willReturn(new ArrayList<>());

    //when
    CustomException exception = assertThrows(CustomException.class, () ->
            eventCacheService.getLotteryEvent()
    );

    //then
    assertEquals(CustomErrorCode.NO_LOTTERY_EVENT, exception.getErrorCode());
    assertEquals("추첨 이벤트를 찾을 수 없습니다.", exception.getMessage());
}

추첨 이벤트 조회 메서드의 실패 테스트이다.

우선 lotteryEventRepositoryfindAll() 메서드를 모킹하여 빈 리스트를 반환하도록 하였고,
이에 따라 커스텀 예외가 발생하는 것을 테스트한다.

서비스 클래스의 외부 주입 필드

@Service
@RequiredArgsConstructor
public class UrlService {

    @Value("${shortenUrlService.url}")
    private String shortenBaseUrl;
    
}

위는 단축 URL을 구현하는 UrlService 클래스의 일부이다.

단축 URL의 서버 주소인 shortenBaseUrl을 설정 파일을 통해 가져오는 것을 확인할 수 있다.

@Test
@DisplayName("단축 url 생성 테스트 - 성공")
void generateShortUrlTest_Success() throws NoSuchPaddingException, IllegalBlockSizeException, NoSuchAlgorithmException, BadPaddingException, InvalidKeyException {
    //given
    Url originalUrl = spy(new Url(AESUtils.encrypt(user.getPhoneNumber(), secretKey)));

    given(urlRepository.save(any())).willReturn(originalUrl);
    given(originalUrl.getId()).willReturn(1L);

    //when
    ShortenUrlResponseDto shortenUrlResponseDto = urlService.generateShortUrl(user);

    //then
    assertThat(shortenUrlResponseDto.shortenUrl()).isEqualTo("baseUrl/link/B");
}

위는 테스트 코드 중 일부이다.

원본 URL을 변환했을 때, baseUrl/link/B가 반환되도록 예측하였고, 아래는 그 테스트 결과이다.

UrlServiceshortenBaseUrl을 인식하지 못하는 것을 확인할 수 있다.

Mockito의 환경은 스프링 컨텍스트와 별개로 처리되며 @Value 어노테이션을 처리하지 않는다.
따라서 의존성 주입 기능이 활성화되지 않아 shortenBaseUrl이 올바르게 설정되지 않는다.

ReflectionTestUtil

ReflectionTestUtils는 스프링 프레임워크에서 제공하는 유틸리티 클래스이다.
주로 단위 테스트에서 객체의 비공식적인 필드에 접근하거나, 값을 설정하거나, 메서드를 호출할 때 사용된다.

ReflectionTestUtils.setField(urlService, "shortenBaseUrl", "baseUrl");

위 코드를 통해 shortenBaseUrl을 설정해 줄 수 있다.

0개의 댓글