목차
- 서론
- static method 조롱하지 않기
- 테스트 코드 작성
- 현재 시간을 구하는 클래스
- LocalDateTime.now() 모킹
- 테스트 데이터 변경
- Clock 이용하기
- 최종 선택
1. 서론 😄
`줍줍` 프로젝트에서는 내가 원하는 시간에 리마인드할 수 있도록 슬랙 메시지를 보내주는 리마인드 기능이 있습니다. 이 리마인더 목록을 조회할 때 이미 알림을 보냈던 리마인더는 조회할 필요가 없다고 판단했고 현재 시간을 기준으로 조회하기 위해 LocalDateTime.now()를 호출했습니다. 해당 로직을 어떤 식으로 테스트 코드를 작성할까 고민했던 일에 대해 포스팅해보려 합니다.
2. static method 조롱하지 않기 😡
Why doesn't Mockito mock static methods?
LocalDateTime.now()를 모킹하면 해결되면 정말 좋겠지만!! 아쉽게도 Mockito의 가장 기본적인 라이브러리인 mockito-core에서는 static method를 모킹하는 기능을 지원하지 않는다. (PowerMock 또는 mockito-inline 라이브러리 별도 설치 필요) 왜 지원해주지 않는걸까?를 찾아보다가 한 StackOverflow 글을 보게 되었다. 많은 사람들이 static method를 모킹해야한다는 상황이 온다면 먼저 좋은 코드를 작성한게 맞는지 생각해보라 한다. 그래서 static method에 대한 이야기를 먼저 해보려고 한다.
❗ 유연하지 못한 설계
static을 사용하는게 좋은 케이스도 하지만 지나치게 남발하면 유연하지 못한 설계가 된다.
- 객체끼리의 결합도가 높아짐
- 의존성을 발견하기 힘들어짐
- 테스트 코드 작성이 어려워짐
❗ private method의 테스트
static method를 모킹하기 위해 PowerMock이나 mockito-inline 라이브러리를 설치하면 private 메서드, final 클래스 등 테스트 가능한 상태가 된다. http://shoulditestprivatemethods.com/ 라는 사이트가 있을 정도로 private method의 테스트 불필요성에 대해선 많은 자료가 있으니 생략한다. 😄
3. 테스트 코드 작성 😵
최종적으로는 Clock을 이용한 방식을 선택했다.
그 이유에 대해서는 각 방법의 하단에 적었다.
⭐ 예제에서 final 클래스, static 메서드 등을 모킹하기 위해 @SpyBean 또는 Mockito.mockStatic()를 활용했다. 이를 위해서는 아래 라이브러리를 추가해야한다. (Mockito의 버전에 따라 아래 라이브러리가 아닌 PowerMock을 추가해야할 수 있다.)
testImplementation 'org.mockito:mockito-inline'
📕 현재 시간을 구하는 클래스
1. 현재 시간을 구하는 클래스 생성
@Component
public class Now {
public LocalDateTime value() {
return LocalDateTime.now();
}
}
2. LocalDateTime.now() 대신 1 사용
@Transactional(readOnly = true)
@Service
public class ReminderService {
@Autowired
private final Now now;
public ReminderResponses find(final Long id) {
// LocalDateTime now = LocalDateTime.now();
LocalDateTime now = now.value();
...
}
}
3. 1의 메서드 모킹
@SpringBootTest
class ReminderServiceTest {
@SpyBean
private Now now;
@DisplayName("리마인더 조회")
@Test
void find() {
given(now.value())
.willReturn(LocalDateTime.of(2022, 8, 12, 0, 0));
// ...
}
}
👉 현재 시간을 구할 때 누구나 LocalDateTime.now()를 떠올릴텐데 우리가 직접 구현한 클래스를 이용해야만 하는 상황이 싫었다. 다른 개발자가 해당 클래스의 존재를 모르면 내가 겪었던 LocalDateTime.now()를 어떻게 모킹해야할까... 라는 상황을 또 겪을 것 같았다.
📒 LocalDateTime.now() 모킹
LocalDateTime.now()를 직접 모킹하는 방식이다. (static method mock 이런 키워드로 검색하면 유사한 케이스를 많이 찾을 수 있다.)
@SpringBootTest
class ReminderServiceTest {
private static MockedStatic<LocalDateTime> localDateTimeMockedStatic;
@BeforeAll
static void setMock() {
localDateTimeMockedStatic = Mockito.mockStatic(LocalDateTime.class, Mockito.CALLS_REAL_METHODS);
localDateTimeMockedStatic.when(LocalDateTime::now)
.thenReturn(LocalDateTime.of(2022, 8, 10, 0, 0, 0));
}
@AfterAll
static void closeMock() {
localDateTimeMockedStatic.close();
}
@DisplayName("리마인더 조회")
@Test
void find() {
// ...
}
}
🔻 try-with-resources 사용
@SpringBootTest
class ReminderServiceTest {
@DisplayName("리마인더 조회")
@Test
void find() {
try (MockedStatic<LocalDateTime> localDateTimeMockedStatic = Mockito.mockStatic(LocalDateTime.class)) {
localDateTimeMockedStatic.when(LocalDateTime::now)
.thenReturn(LocalDateTime.of(2022, 8, 10, 0, 0, 0));
// ...
}
}
}
👉 인수 테스트 같이 랜덤 포트, @DirtiesContext 등을 이용하는 테스트 코드에서는 불가능하다. (테스트가 돌아가는 스레드랑 로직이 실행되는 스레드가 달라 static method의 모킹이 제대로 되지 않는 것이라고 예상)
📗 테스트 데이터 변경
만약 오늘 날짜 이후의 데이터만 조회하는 상황이라면 아래처럼 테스트 데이터를 변경할 수 있다.
@SpringBootTest
class ReminderServiceTest {
@Autowired
private ReminderRepository reminderRepository;
@DisplayName("리마인더 조회")
@Test
void find() {
// given
reminderRepository.save(new Reminder("test1", LocalDateTime.now().plusDays(1));
reminderRepository.save(new Reminder("test2", LocalDateTime.now().plusDays(1));
reminderRepository.save(new Reminder("test3", LocalDateTime.now().plusDays(1));
// LocalDateTime.now()를 통해서 이후 날짜의 데이터 조회 로직 + 검증
}
}
👉 개인적으로는 가장 깔끔한 방법이라고 생각한다. 현재 테스트 코드들이 @Sql을 통해 sql 파일들에 의존하고 있다. 이를 텍스트 픽스쳐로 변경해서 사용하는 쪽으로 시도해보려고 하고 있는데 대규모 작업이기도 하고 팀원 중 하나가 이 일을 맡고 있었기 때문에 당장의 테스트 데이터 변경이 힘든 상황이었다.
📘 Clock 이용하기
LocalDateTime.now()를 열어보면 아래와 같은 코드가 존재한다.
내부를 보면 now(Clock) 호출을 확인할 수 있다.
public static LocalDateTime now() {
return now(Clock.systemDefaultZone());
}
now(Clock)을 살펴보니 접근 제어자가 public인 것을 확인할 수 있다.
즉, LocalDateTime.now(Clock)도 직접 호출할 수 있다는 의미이다.
public static LocalDateTime now(Clock clock) {
Objects.requireNonNull(clock, "clock");
final Instant now = clock.instant(); // called once
ZoneOffset offset = clock.getZone().getRules().getOffset(now);
return ofEpochSecond(now.getEpochSecond(), now.getNano(), offset);
}
실제로 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.
우리는 여기서 이 Clock을 이용하려고 한다.
Clock을 bean으로 등록해서 시간을 가져오는 clock의 메서드인 instant()를 모킹하려고 한다.
1. Clock을 bean으로 등록
@Configuration
public class TimeConfig {
@Bean
public Clock clock() {
return Clock.systemDefaultZone();
}
}
2. LocalDateTime.now()를 LocalDateTime.now(clock)으로 변경
@Transactional(readOnly = true)
@Service
public class ReminderService {
@Autowired
private final Clock clock;
public ReminderResponses find(final Long id) {
// LocalDateTime now = LocalDateTime.now();
LocalDateTime now = LocalDateTime.now(clock);
...
}
}
3. 테스트 코드에서 clock.instant()를 모킹
- LocalDateTime.now(Clock)에서는 clock.instant()를 이용해 시간 정보를 조회
- clock.instant() 메서드는 static method가 아님 (=모킹 가능)
- 예제에서는 BDDMockito의 given()을 사용했지만 Mockito의 when()을 사용해도 무방
@SpringBootTest
class ReminderServiceTest {
@SpyBean
private Clock clock;
@DisplayName("리마인더 조회")
@Test
void find() {
given(clock.instant())
.willReturn(Instant.parse("2022-08-10T00:00:00Z"));
// ...
}
}
👉 Oracle에서 테스트를 위해 Clock을 받아 사용하는 목적으로 사용하라고 명시되어있어 안심하고 사용할 수 있었다. 최종적으로 선택하게 되었다.
💖 Special Thanks to
- 우테코 4기 크루 헌치, 봄, 써머 & 우테코 코치 제이슨
✨ 참고
- 우테코 4기 헌치의 블로그 - 타임머신 테스트 하기
- StackOverflow - Why doesn't Mockito mock static methods?
- Oracle - LocalDateTime
- StackOverflow - mockito mock static function does not work if the function is called in a Thread
- StackOverflow - Is using a lot of static methods a bad thing? ㅇㅇ
- DZone - Static Classes Are Evil, Make Your Dependencies Explicit
'Develop > Spring+JPA' 카테고리의 다른 글
[Spring] DispatcherServlet (0) | 2022.09.28 |
---|---|
[토비의 스프링] Chapter 1. 오브젝트와 의존관계 (0) | 2022.09.10 |
[Spring] ResponseEntity vs DTO (3) | 2022.08.11 |
모든 요구사항을 한 엔드포인트로 처리하는 방법 (2) | 2022.07.08 |
[Spring] CORS 에러 해결하기 (0) | 2022.06.13 |