Spring/Test-Driven Develop

[Java] LocalDate.now() mocking - 정적 메소드 테스트 하기

민돌v 2023. 1. 27. 19:37

 

이번 포스팅에서는

LocalDateTime 혹은 LocalDatenow() 를 테스트를 위해

mocking 하는 방법에대해 공부해보고자 합니다.

 

(포스팅에 나오는 모든 코드는 회사 코드의 형식을 유지하면서 수정한것이기 때문에 뭔가 이상할 수 도 있습니다. 👍)

 


📌 얼마전에 오늘 일자로 특정 날짜가, 몆 주차이인지 계산하는 로직을 작성하여 이를 테스트하고자 했습니다.

 

하지만 막상 테스트를 짤려하니, LocalDate.now() 메소드는 static 메소드이긴 때문에 목킹하여 항상 성공하는 테스트 케이스를 작성하는데에 어려움이 있었서, 찾아보니 2가지 방법을 찾을 수 있었습니다.

  1. 첫번쨰는, Mockito를 이용하여서도 static method 를 mocking 하는 것이고 (PowerMock 을 이용해도 가능하지만, Mockito 3.4.0버전부터 MockedStatic라는 새로운 기능이 들어감)
  2. 두번째는, LocalDate now(Clock clock) 메소드를 이용하는 것입니다.

 


📌 1. Mockito static method mock - mockedStatic

이 방법은 안티패턴입니다. 학습을 위해 해보았지만 2번 방법을 추천드립니다.

 

수많은 블로그나, 레펀런스를 보면 보통 Mockito-inline 을 dependecy 에 추가해주어야한다고 이야기합니다.

  • 저는 Mockito-core 에도 있을줄 알고 추가해주었더니 아래와 같은 에러를 볼 수 있었습니다...ㅎㅎ
  • 하지만 알고보니 Mocktio-core가 또 일부 byte-buddy 를 의존하고있어 생기는 에러였습니다.
  • 여담이지만 Mockito-core 가 공식적이고, mockito-inline 은 약간의 실험적인(?) 느낌이 있어 mockito-core 를 의존한다고 하더군여
java.lang.IllegalStateException: Could not initialize plugin: interface org.mockito.plugins.MockMaker (alternate: null)
Caused by: java.lang.IllegalStateException: Internal problem occurred, please report it. Mockito is unable to load the default implementation of class that is a part of Mockito distribution. Failed to load interface org.mockito.plugins.MockMaker

 

💡 1) Maven 종속성 추가

  • 그래서 mockito-core 와 byte-buddy 를 추가해주었습니다.
<dependency>
    <groupId>org.mockito</groupId>
    <artifactId>mockito-core</artifactId>
    <version>5.0.0</version>
    <scope>test</scope>
</dependency>


<dependency>
    <groupId>net.bytebuddy</groupId>
    <artifactId>byte-buddy</artifactId>
    <version>1.12.22</version>
</dependency>

 

💡 2) Mockito static mocking test code

  • Mockito.mockStatic 메소드를 사용하며 해당 매소드는 MockedStatic 제네릭 객체를 반환합니다.
@Test
@DisplayName("LocalDate mocking")
void staticMock() {

    LocalDate date = LocalDate.of(1997, 11, 4);
    
    //여기부터 Localdate static mocking 하기 때문에 제가 원하는 LocalDate.of 를 위에 선언해야 합니다.
    MockedStatic<LocalDate> localDateMockedStatic = Mockito.mockStatic(LocalDate.class);

    localDateMockedStatic.when(LocalDate::now).thenReturn(date);

    assertThat(LocalDate.now()).isEqualTo(date);

    //mocking이 끝났으면 객체를 닫아주어야 합니다.
    localDateMockedStatic.close();
}
  • MockedStatic 객체는 정적 메소드의 모킹이 활성화 되었음을 나타냅니다.
  • mocking 은 MockedStatic 이 만들어진 스레드에만 영향을 미치며 다른 스레드에서 이 개체를 사용하는 것은 안전하지 않다고 합니다.
  • 또한, MockedStatic 을 close() 해주지 않으면 시작 스레드에서 계속 활성 상태로 유지하기 때문에 JUnit 규칙 또는 확장을 사용하여 명시적으로 관리하지 않는 한 try-with-resources 문 내에서 이 객체를 생성하는 것이 좋다고 합니다.
@Test
@DisplayName("MockedStatic 을 Try로 관리")
void calculateWeekDiffFromToday() {

    LocalDate date = LocalDate.of(1997, 11, 4);

    //close 를 조금 더 명확하게
    try (MockedStatic<LocalDate> localDateMockedStatic = Mockito.mockStatic(LocalDate.class)) {
        localDateMockedStatic.when(LocalDate::now).thenReturn(date);
        assertThat(LocalDate.now()).isEqualTo(date);
    }
}

now() 가 제 탄생일로 돌아간 걸 볼 수 있습니다.

 

💡 3) 정리

static mock 을 지양하는 이유

  1. static mock은 안티패턴이고
  2. 테스트코드 작성 방식이 복잡하고
  3. try문으로 감싸야 합니다. (명시적으로 close() 하거나)
  4. 또한 특정 라이브러리를 의존해야하고 이 과정에서 일부 라이브러리와 호환되지 않을 수 있다고 합니다.
  5. static method를 모킹하기 위해 PowerMock이나 mockito-inline 라이브러리를 설치하면 private 메서드, final 클래스 등 테스트 가능한 상태가 된다. http://shoulditestprivatemethods.com/ 라는 사이트가 있을 정도로 private method의 테스트 불필요성에 대해선 많은 자료가 있으니 허용하지 않는 상태로 두어보자! 

 

→ 즉, 왠만하면 쓰지말자! 그러면 어떻게하냐?? 바로 2번 방법을 사용해라~

 

 


📌 2. LocalDate.now(Clock clock)

좀 생소하지만, 전혀 어렵지 않습니다.
LocalDate.now(Clock clock) 의 Clock 객체는 접근 제어자가 Public이고 Static 객체가 아니기 때문에 mocking이 가능합니다. 

 

LocalDate.now() 를 mocking 하기 힘든 것은 now() 가 static method 이기 때문입니다.

→ 하지만 내부적으로 들어가보니 LocalDate.now() 는 LocalDate.now(Clock clock) 메소드를 호출하는 것을 알 수 있었습니다.

 

👏🏻 LocalDate now() 를 원하는 시간대로 지정해주기위해 LocalDate.now() 대신 → LocalDate.now(Clock clock)를 사용해줍니다.

  • Clock을 mocking 하고 의존성을 줄이기위해선, 외부에서 받아와야겠죠
  • 그리고 이렇게되면 Spring IOC/DI 를 이용할 수 있기 때문에 Clock 을 Spring Bean으로 등록해줍니다.

 

1) LocalDate.now(Clock clock) 예시

@RequiredArgsConstructor(access = AccessLevel.PRIVATE)
public final class DateUtil  {

    private final Clock clock;
    private final LocalDate date;

    public static DateUtil of(final LocalDate date) {
        return new DateUtil(Clock.systemDefaultZone(), date);
    }

    //오늘과 특정날짜의 일자 차이를 구하는 메소드
    public int getDiffFromToDay() {
        
        //LocalDate.now() → LocalDate.now(Clock clock) 호출
        final LocalDate now = LocalDate.now(clock);
        return (int) ChronoUnit.DAYS.between(now, date);
    }
}

 

2) Clock Bean 등록

@Configuration
public class ClockConfig {

    @Bean
    public Clock clock() {
        return Clock.systemDefaultZone();
    }
}

 

3) Test Code

  • 이제 Clock 을 mocking 할 수 있으니 현재 시각을 커스텀하게 지정해줄 수 있습니다.
@ExtendWith(MockitoExtension.class)
class DateUtilTest {

    @Mock
    Clock clock;

    @InjectMocks
    DateUtil dateUtil;

    @Test
    void clockMock() {
    
        when(clock.instant()).thenReturn(Instant.parse("1997-11-04T00:00:00Z"));
        when(clock.getZone()).thenReturn(ZoneId.systemDefault());

		//디버깅을 위해 아무 값과 비교ㅎ
        assertThat(dateUtil.getDiffFromToDay()).isEqualTo(1);
    }
}

LocalDate.now 가 의도된 값을 추출함

 

여기까지가 LocalDate 의 static메소드인 now() 를 mocking 하는 방법입니다.

 

❓ 하지만 이것또한 테스트를위한 코드가 되는것이 아닌가? 살짝 의구심이 들었지만 Oracle에서 테스트를 위해 Clock을 받아 사용하는 목적으로 사용하라고 명시되어있어서 쿨하게 넘어가기로 했습니다.

💡 실제로 Oracle 문서를 보면 now(Clock)를 테스트에서 대안점으로 사용된다고 설명하고 있다.

This will query the specified clock to obtain the current date - today. Using this method allows the use of an alternate clock for testing. The alternate clock may be introduced using dependency injection.

 


📌 3. 유틸리티 클래스의 LocalDate

제 개인적인 케이스이지만, 고민을 많이 한 부분이기에 남겨놓습니다 :)

👏🏻 문제상황

  • 위에서는 Clock 을 빈으로 등록하여 외부에서 의존하도록 유도하였지만
  • 지금 기존의 코드같은 경우 유틸성 클래스라 캡슐화를 위해 정적 생성자 메소드만 사용하도록 제한하였기 때문에 테스트 코드 상 모두 Spring DI로 주입받을 수는 없는 상황이었습니다.

생성자가 Private 으로 외부 접근을 막아놈

 

👏🏻 해결 방법

  • 결론을 이야기하자면 테스트에서 사용하기 위한 정적 메소드를 추가로 만들어주었습니다.
  • 외부에서 주입받는 것은 맞지만 Spring DI로 주입해주는 것이 아니기 때문에 ClockConfig는 삭제하였습니다.
@Test
void clockMock() {

    Clock clockMock = mock(Clock.class);

    when(clockMock.instant()).thenReturn(Instant.parse("2022-11-04T00:00:00Z"));
    when(clockMock.getZone()).thenReturn(ZoneId.systemDefault());

    LocalDate anyDate = LocalDate.of(2023, 1, 1);
    DateUtil dateUtil = DateUtil.of(clockMock, anyDate);

    assertThat(dateUtil.getDiffFromToDay()).isEqualTo(58);

}

결과적으로는 현재 코드상 테스트를위한 정적 메소드가 하나 더 생긴거라 이게 맞나 고민을 많이했습니다..

모든 건 trade-off 라는 생각으로 @AllArgsConstructor @RequiredArgsConstructor(access = AccessLevel.PRIVATE) 을 PUBLIC 으로 열어주지않고

유틸클래스의 캡슐화는 지키면서 최소한 어디서 사용했는지 명시적으로 컨트롤 할 수 있는 정적 메소드를 추가하고 테스트를 더 잘하자..! 를 위주로 두고 이러한 코드를 작성하였습니다.


 

아직도 뭐가 맞는건지 잘 모르겠네요.

저는 이런식으로 문제를 풀어갔지만

더 좋은 풀이방안을 아시는 분이 계시다면 꼭꼭 저 좀 알려주세요!

 

 

 

끝!

 

 


참고