개발을 하다보면 현재 시간이나, 랜덤 값이 필요한 로직이 분명 필요합니다.
프로젝트를 진행하면서, 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 |