-
타임머신 테스트 하기🕰(feat.LocalDateTime.now(clock))카테고리 없음 2022. 8. 7. 20:56
0. Intro
@Entity @EntityListeners(AuditingEntityListener.class) public class AuthCode { private static final long VALID_MINUTE = 5L; @CreatedDate private LocalDateTime createdAt; //... public void verifyTime() { LocalDateTime expireTime = this.createdAt.plusMinutes(VALID_MINUTE); if (LocalDateTime.now().isAfter(expireTime)) { throw new InvalidAuthCodeException(); } } }
인증코드가
생성시점에서 5분이 지나면 만료
되는 로직을 짜던 도중이었다.구현 후 테스트코드를 짜려고 보니 문제가 생겼다.
LocalDateTime.now()
는 시스템의 지금 시간 을 리턴하는데어떻게
특정 시간으로부터 만료되었는지
검증하는 테스트 코드를 짤 수 있지? (어떻게 타임머신을 타지??)즉, 시간 변수를 테스트에서 임의로 지정해 쓸 수 없었다.
그래서
현재시각을 임의지정 후 모킹
해 테스트하는 방법을 생각해봤다.
1. 현재 시각을 모킹하는 법
여러가지 방법이 있었다.
1. Clock 을 빈 등록해 모킹하기
2. LocalDateTime 을 static mock으로 모킹하기
3. LocalDateTime.now() 를 리턴하는 새 클래스 생성해 모킹하기
4. AuthCode 생성자에서 LocalDateTime 인터페이스를 받고, 그 안에서 LocalDateTime/mock을 넣기여러 방법들 중 나는 1번,
Clock
을 빈 등록해 모킹하기 를 택했다.왜냐하면
2번 static mock은 테스트코드 작성 방식이 복잡하고 try문으로 감싸야 한다.
3번은 now()이외의 시간변수를 모킹하고 싶을 때마다 일일히 지정해줘야 한다.
4번은 어디까지 해당 객체를 넣어줄지 범위를 끊어주기 어려웠다.(Controller →service→domain…)
2. 왜
Clock
인가?1)
LocalDateTime
은 모킹할 수 없다public final class LocalDateTime implements Temporal, TemporalAdjuster, ChronoLocalDateTime<LocalDate>, Serializable { public static LocalDateTime now() { return now(Clock.systemDefaultZone()); } public static LocalDateTime now(ZoneId zone) { return now(Clock.system(zone)); } 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); } //... of() 등 이외 메소드 }
LocalDateTime.now()
는 다음 방식으로 짜여있다.보다시피 now()는 static 메소드이기 때문에
LocalDateTime을 객체로 등록해 사용하더라도 메소드를 꺼내쓸 수 없다…ㅠㅠ그런데 여기서 주목할 점이 있다.
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); }
평소에는
LocalDateTime.now()
로 현재 시간을 가져오지만,사실
LocalDateTime.now(clock)
형태로,Clock
객체를 파라미터로 받아 시간을 가져오는 형태였다!2)
Clock
이 뭐지?그렇다면 이
Clock
은 뭘까?정확히 말하면
final Instant now = clock.instant(); // called once
에서 사용되는
clock.instant()
는 뭘까?public abstract class Clock { //----------------------------------------------------------------------- /** * Gets the current instant of the clock. *<p> * This returns an instant representing the current instant as defined by the clock. * *@returnthe current instant from this clock, not null *@throwsDateTimeExceptionif the instant cannot be obtained, not thrown by most implementations */ public abstract Instant instant(); //... }
Clock 객체의 현재 instant를 리턴해주는 추상 메소드이다.
3)
Clock
을 빈으로 등록하자즉,
LocalDateTime
의 메소드들은static
메소드지만,clock
객체를 파라미터로 받을 수 있으니clock
을 빈으로 등록해 사용하면 된다!3. 어떻게 모킹하지?
1) 현재 시간(
now
)을 파라미터로 추출public class AuthCode { public void verifyTime(LocalDateTime now) { // now를 파라미터로 이동 LocalDateTime expireTime = this.createdAt.plusMinutes(VALID_MINUTE); if (now.isAfter(expireTime)) { throw new InvalidAuthCodeException(); } } }
authcode.verifyTime()
메소드에서현재 시간(
now
)을 파라미터로 추출했다.2)
clock
을Bean
등록import java.time.Clock; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @Configuration public class TimeConfig { @Bean public Clock clock() { return Clock.systemDefaultZone(); } }
다음,
clock
을@Configuration
으로 빈 등록했다.public abstract class Clock { public static Clock systemDefaultZone() { return new SystemClock(ZoneId.systemDefault()); } }
이때
Clock.systemDefaultZone()
을 활용했다.clock
클래스의 현재시간clock
객체를 리턴한다.3)
AuthService
에서Bean
주입그리고
AuthService
에서clock
을 빈으로 주입받은 후AuthCode
객체에clock
을 주입한다.@Service public class AuthService { private final AuthCodeRepository authCodeRepository; // ... private final Clock clock; public AuthService(//..., Clock clock) { this.authCodeRepository = authCodeRepository; this.clock = clock; } public void verifyAuthCode(VerificationRequest verificationRequest) { AuthCode authCode = authCodeRepository.findBySerialNumber(serialNumber) .orElseThrow(SerialNumberNotFoundException::new); //... LocalDateTime now = LocalDateTime.now(clock); authCode.verifyTime(now); } // ... }
4. 어떻게 테스트하지?
1)
AuthCode
(도메인 계층)AuthCode
의 경우 등록된clock
빈을 사용하지 않고도 다음 방식으로 테스트할 수 있다.@SpringBootTest class AuthCodeTest { @DisplayName("인증코드 생성시간으로부터 5분이 지나면 인증이 불가능하다") @Test void verifyTime_Exception_Time() { AuthCode authCode = AuthCode.builder() .code("ABCDEF") .serialNumber("21f46568bf6002c23843d198af30bb2bc8123695bd3d12ce86e0fc35bc5d3279") .createdAt(LocalDateTime.parse("2007-12-03T10:15:30")) .build(); assertThatThrownBy(() -> authCode.verifyTime(LocalDateTime.parse("2007-12-03T10:20:31"))) .isInstanceOf(InvalidAuthCodeException.class); } }
2)
AuthService
(서비스 계층)AuthService
의 경우clock
빈을 이용해 다음 방식으로 테스트할 수 있다.class AuthServiceTest extends IntegrationTest { private static final Clock FUTURE_CLOCK = Clock.fixed(Instant.parse("3333-08-22T10:00:00Z"), ZoneOffset.UTC); @Autowired private AuthService authService; @Autowired private AuthCodeRepository authCodeRepository; @SpyBean private Clock clock; @DisplayName("인증번호 만료 시 예외 발생") @Test void verifyAuthCode_Exception_Expired() { AuthCode authCode = //~ authCodeRepository.save(authCode); // clock.instant() 리턴값을 임의로 지정해 미래로 현재 시간을 바꾼다! doReturn(Instant.now(FUTURE_CLOCK)) .when(clock) .instant(); VerificationRequest verificationRequest = new VerificationRequest("test@gmail.com", "ABCDEF"); assertThatThrownBy(() -> authService.verifyAuthCode(verificationRequest)) .isInstanceOf(InvalidAuthCodeException.class); } }
📚참고자료
테스트 코드에선 LocalDate.now()를 쓰지말자.
How can I mock java.time.LocalDate.now()