개발을 하다보면 현재 시간이나, 랜덤 값이 필요한 로직이 분명 필요합니다.
프로젝트를 진행하면서, LocalDate.now()와 UUID.randomUUID 등을 사용해야 했습니다.
하지만 테스트를 할 수가 없는 문제가 발생하였습니다.
모임 엔티티의 생성 조건과 참여 조건의 요구사항은 다음과 같습니다.
- 모임 시작날짜, 종료날짜는 현재시간 이유여야만 한다.
- 종료날짜 이후에는 참여하지 못하고 예외가 발생하게 된다.
이 때, 종료 날짜는 무조건 현재날짜 이후에만 지정 가능하고, 현재 요청한 시간은 항상 endDate 이므로
만약 endDate가 3월 16일이고, 3월 15일날 테스트를 하게된다면 항상 통과할 수 밖에 없게 되어 모임 가입이 불가능한 테스트를 할 수 없게 됩니다.
public class BookGroup extends BaseTimeColumn { private void validatePeriod (LocalDate startDate, LocalDate endDate) { LocalDate currentDate = LocalDate.now(); if (!startDate.isEqual(currentDate) && !startDate.isAfter(currentDate)) { throw new InvalidArgumentException( INVALID_GROUP_START_DATE); } validateEndDate(startDate, endDate) } public void checkCanJoin() { LocalDate currentDate = LocalDate.now(); if (currentDate.isAfter(this.endDate)) { throw new ExpiredJoinGroupPeriodException(); } checkMemberCount(); } }
문제점이 무엇일까요?
- 내부적으로 LocalDate.now()에 의존하고 있다.
- 만약 이 코드를 잘 모르는사람이 사용하게 된다면 어쩔때는 동작하게되고 어쩔때는 동작하지 않게 된다.
그렇다면 이런 코드를 테스트 하는 방법이 무엇이 있을까 고민해봤습니다.
1. Mockito 라이브러리의 MockStatic 이용
먼저, mockito-inline
이라는 외부 의존성이 필요합니다.
try (MockedStatic<IntCalculatorUtil> mockedStaticClass = Mockito.mockStatic(LocalDate.class)) { when(LocalDate.now()) .thenReturn(LocalDate.now().minusDays(2)); }
하지만 이렇게 하게 되면 다음과 같은 단점이 있습니다.
- mocking하기 위한 추가적인 의존성
- try-with-resources로 반드시 자원 할당을 해줘야 한다는 점.
- 그로 인한 테스트 코드의 가독성이 떨어진다.
결론적으로 static method를 mocking 한다면 설계가 잘못된 것은 아닌지 의심해 볼 수 있습니다.
2. 외부로 부터 현재 시간을 파라미터로 받는것
간단합니다. 그냥 외부에서 호출해서 주입해주는겁니다.
다만 이방법도 문제가 있습니다. 서비스 레벨로 올리게 된다면 서비스를 테스트 할 수 없게 됩니다.
결국 바운더리를 올리는 것일 뿐, 테스트는 할 수 없습니다.
3. 외부로부터 의존성을 주입받자
의존 역전 원리를 이용해서 변하는 값을 추상화 시켜 의존하게 하여 해결할 수 있습니다.
- 결론적으로 변하는 값에 대한 가장 괜찮은 접근법은 런타임 의존성과 컴파일 타임 의존성을 다르게 하는것.
문제점을 다시 되짚어보면, 변하는 값인 LocalDate.now()에 의존하게 되는데 이 값은 테스트를 할 수가 없고,
결국, 계속 밖으로 드러내도 테스트 하지 못하고, 어디선가 폭탄 돌리기를 할 뿐 해결하지 못하는 문제였습니다.
인터페이스를 이용하여 추상화하여, 런타임 의존성과 컴파일 타임 의존성을 다르게 하여 이 문제를 해결할 수 있습니다.
- 작성할 코드량은 늘어나지만, 좋은 코드의 원칙인 테스트하기 좋게 됩니다. \
public class BookGroup extends BaseTimeColumn { public void checkCanJoin(TimeHolder timeHolder) { LocalDate currentDate = LocalDate.now(timeHolder.getCurrentClock()) ; if (currentDate.isAfter(this.endDate)) { throw new ExpiredJoinGroupPeriodException(); } checkMemberCount (); } }
- TimeHolder 의존성을 주입받아 Clock을 꺼내어 currentDate를 생성합니다.
- LocalDate, LocalDateTime의 now(Clock clock) 메소드는 이를 지원합니다
시간을 주입받기 위한 인터페이스 정의
public interface TimeHolder { Clock getCurrentClock(); }
프로덕션 코드에서 런타임에 사용할 구현체 정의
@Component public class StandardTimeHolder implements TimeHolder { @Override public Clock getCurrentClock() { return Clock.systemDefaultZone(); } @Override public long getMillis() { return Clock.systemUTC().millis(); } }
테스트 코드에서 테스트 런타임에 사용할 구현체 정의
public class TestTimeHolder implements TimeHolder { private Clock clock; public TestTimeHolder(Clock clock) { this.clock = clock; } public TestTimeHolder (LocalDate localDate) { ZoneId zoneId = Zoneld.systemDefault() ; Instant instant = localDate.atStart0fDay(zoneld).toInstant() this.clock = Clock.fixed( instant, zoneld); } @Override public Clock getCurrentClock() { return this.clock; } @Override public long getMillis() { return this.clock.millis(); } }
- 두 생성자중 원하는 생성자를 이용해서 시간을 조작할 수 있게 됩니다.
런타임에는 Service에서 StandardTimeHolder를 이용해서, 현재 시간을 주입받고,
테스트시에는 TestTimeHolder를 이용해서 원하는 시간을 주입하였습니다.
의존성을 추상화하여 외부로부터 주입받는다면 테스트가 불가능한 문제를 해결할 수 있게 됩니다.
UUID도 테스트가 가능하다
이렇게 의존성을 추상화하고, 외부에서 주입받게 한다면 마찬가지로 다른 랜덤값들이나 UUID도 테스트가 가능합니다.
추상화된 인터페이스 정의
public interface UUIDGenerator { UUID generate(); String generateToString(); UUID fromString(String uuidString); }
프로덕션 코드에서 런타임에 사용할 구현체 정의
@Component public class DefaultUUIDGenerator implements UUIDGenerator { @Override public UUID generate() { return UUID.randomUUID(); } @Override public String generateToString() { return UUID.randomUUID().toString(); } @Override public UUID fromString(String uuidString) { return UUID.fromString(uuidString); } }
테스트 코드에서 런타임에 사용할 구현체 정의
public class TestUUIDGenerator implements UUIDGenerator { private final String uuid; public TestUUIDGenerator (String uuid) { this.uuid = uuid; } @Override public UUID generate() { return UUID.fromString(uid); } @Override public String generateToString() { return uuid; } }
어렵지만, 객체지향을 잘 이용한다면 이렇게 복잡한 문제도 해결할 수 있는것 같습니다.
참조
'테스트 > JUnit' 카테고리의 다른 글
JUnit5 리스트가 정렬 조건에 맞게 정렬되었는지 검증하는법 (0) | 2023.03.04 |
---|---|
Mockito Stub 작성 시 주의 사항 (0) | 2023.01.28 |
Mock vs. Stub vs. Spy (0) | 2023.01.28 |
Java Collection, List, Array 비교, 검증 (0) | 2023.01.25 |
@WebMvcTest Security 401 403 응답 해결방법 - csrf (0) | 2023.01.25 |