테스트/JUnit

변하는 값을 테스트 하는 방법(LocalDateTime, UUID, Random)

ysk(0soo) 2023. 3. 21. 02:23

개발을 하다보면 현재 시간이나, 랜덤 값이 필요한 로직이 분명 필요합니다.

프로젝트를 진행하면서, 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));

}

하지만 이렇게 하게 되면 다음과 같은 단점이 있습니다.

  1. mocking하기 위한 추가적인 의존성
  2. try-with-resources로 반드시 자원 할당을 해줘야 한다는 점.
  3. 그로 인한 테스트 코드의 가독성이 떨어진다.

결론적으로 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 ();
    }
}

시간을 주입받기 위한 인터페이스 정의

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

어렵지만, 객체지향을 잘 이용한다면 이렇게 복잡한 문제도 해결할 수 있는것 같습니다.

참조