ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 타임머신 테스트 하기🕰(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) clockBean 등록

    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);
        }
    }

    📚참고자료

    (Java) 타임머신을 타고 시간 여행 떠나기

     

    (Java) 타임머신을 타고 시간 여행 떠나기

    문제의 발단가끔 현재 시간을 기준으로 코드를 짜야할 때가 있다.이런 경우에 자바의 경우에는 LocalDate, LocalTime, LocalDateTime 등등의 클래스에 있는 static 메서드인 now 메서드로 현재 시간을 구한다

    perfectacle.github.io

    테스트 코드에선 LocalDate.now()를 쓰지말자.

     

    테스트 코드에선 LocalDate.now()를 쓰지말자.

    여러 사람의 코드를 볼때 가끔 테스트 코드에서 LocalDate.now() 를 사용하는걸 종종 보게 됩니다. 아무래도 편하게 작성할 수 있다보니 사용된것 같지만, 이는 좋은 패턴이 아닙니다. 그래서 예제로

    jojoldu.tistory.com

    How can I mock java.time.LocalDate.now()

     

    How can I mock java.time.LocalDate.now()

    In my test case, I need test time sensitive method, in that method we're using java 8 class LocalDate, it is not Joda. What can I do to change time, when I'm running test

    stackoverflow.com

     

Designed by Tistory.