[Java] LocalDate.now() mocking - 정적 메소드 테스트 하기
이번 포스팅에서는
LocalDateTime 혹은 LocalDate 의 now() 를 테스트를 위해
mocking 하는 방법에대해 공부해보고자 합니다.
(포스팅에 나오는 모든 코드는 회사 코드의 형식을 유지하면서 수정한것이기 때문에 뭔가 이상할 수 도 있습니다. 👍)
📌 얼마전에 오늘 일자로 특정 날짜가, 몆 주차이인지 계산하는 로직을 작성하여 이를 테스트하고자 했습니다.
하지만 막상 테스트를 짤려하니, LocalDate.now() 메소드는 static 메소드이긴 때문에 목킹하여 항상 성공하는 테스트 케이스를 작성하는데에 어려움이 있었서, 찾아보니 2가지 방법을 찾을 수 있었습니다.
- 첫번쨰는, Mockito를 이용하여서도 static method 를 mocking 하는 것이고 (PowerMock 을 이용해도 가능하지만, Mockito 3.4.0버전부터 MockedStatic라는 새로운 기능이 들어감)
- 두번째는, 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);
}
}
💡 3) 정리
static mock 을 지양하는 이유
- static mock은 안티패턴이고
- 테스트코드 작성 방식이 복잡하고
- try문으로 감싸야 합니다. (명시적으로 close() 하거나)
- 또한 특정 라이브러리를 의존해야하고 이 과정에서 일부 라이브러리와 호환되지 않을 수 있다고 합니다.
- 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 의 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로 주입받을 수는 없는 상황이었습니다.
👏🏻 해결 방법
- 결론을 이야기하자면 테스트에서 사용하기 위한 정적 메소드를 추가로 만들어주었습니다.
- 외부에서 주입받는 것은 맞지만 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 으로 열어주지않고
유틸클래스의 캡슐화는 지키면서 최소한 어디서 사용했는지 명시적으로 컨트롤 할 수 있는 정적 메소드를 추가하고 테스트를 더 잘하자..! 를 위주로 두고 이러한 코드를 작성하였습니다.
아직도 뭐가 맞는건지 잘 모르겠네요.
저는 이런식으로 문제를 풀어갔지만
더 좋은 풀이방안을 아시는 분이 계시다면 꼭꼭 저 좀 알려주세요!
끝!
참고