이 글에는 정답이 없고, 단순히 제가 느낀 생각만을 정리했습니다.
배경
그렇다면 단위 테스트 코드를 작성할 때, 언제 모킹을 하는 것이 좋고, 언제 모킹을 피하는 것이 좋은지에 대한 의문이 들게 되었다.
그래서 해당 사례를 예시로 모킹을 하는 것이 좋은 상황과 모킹을 굳이 하지 않아도 될만한 상황을 고르는 나름의 기준을 작성해보고자 한다..!
@Override
public LocalDateTime generateExpirationDate(LocalDateTime now, Cabinet cabinet,
List<LentHistory> activeLentHistories) {
log.info("Called generateExpirationDate now: {}, cabinet: {}, activeLentHistories: {}",
now, cabinet, activeLentHistories);
if (!DateUtil.isSameDay(now)) {
throw new IllegalArgumentException("현재 시각이 아닙니다.");
}
LentType lentType = cabinet.getLentType();
switch (lentType) {
case PRIVATE:
return now.plusDays(getDaysForLentTermPrivate());
case SHARE:
// 이 부분이 없으면 IndexOutOfBoundsException 에러 발생!!
// if (activeLentHistories.isEmpty()) {
// return DateUtil.getInfinityDate();
// }
LentHistory lentHistory = activeLentHistories.get(0);
return generateSharedCabinetExpirationDate(now,
cabinet.getStatus(), lentHistory);
case CLUB:
return DateUtil.getInfinityDate();
}
throw new IllegalArgumentException("대여 상태가 잘못되었습니다.");
}
Java
복사
LentPolicy.java의 generateExpirationDate() 메소드 - 오류가 있는 코드
만약 lentType이 SHARE이고, activeLentHistories 리스트의 길이가 0이라면, 원래 의도는 무한대로 설정한 시간이 반환되어야 하지만 코드에 문제가 있어 activeLentHistories.get(0)를 호출할 때 IndexOutOfBoundsException 에러가 발생한다.
@Test
@DisplayName("성공: 만료시간무한 설정 - 공유사물함 최초 대여 - AVAILABLE")
void 성공_공유사물함_최초_대여_generateExpirationDate() {
// List를 Mockito.mock 객체로 대체
List<LentHistory> activeLentHistories = mock(List.class);
Cabinet cabinet = mock(Cabinet.class);
given(cabinet.getLentType()).willReturn(LentType.SHARE);
given(cabinet.getStatus()).willReturn(CabinetStatus.AVAILABLE);
LocalDateTime expiredDate = lentPolicy.generateExpirationDate(LocalDateTime.now(),
cabinet, activeLentHistories);
assertEquals(expiredDate, DateUtil.getInfinityDate());
}
Java
복사
LentPolicyTest.java - 잘못 작성된 테스트
그렇기 때문에 이를 테스트하는 테스트 코드에서는 lentPolicy.generateExpirationDate() 안에서 activeLentHistories.get(0)를 호출할 때 IndexOutOfBoundsException 에러가 발생해야 한다.
원래라면 이 테스트를 돌릴 때 이 에러가 나서, 소스 코드의 문제점을 파악하고 수정했어야 했다…!
그런데 이 테스트 코드에서는 List를 모킹된 객체로 대체해서 사용한다.
그렇기 때문에 activeLentHistories.get(0)를 호출하더라도 에러가 발생하지 않고, null이 반환된다.
따라서 최종적으로 원래 의도대로 무한대로 설정한 시간이 반환되어 버그를 발견하지 못했다.
@Test
@DisplayName("성공: 만료시간무한 설정 - 공유사물함 최초 대여 - AVAILABLE")
void 성공_공유사물함_최초_대여_generateExpirationDate() {
List<LentHistory> activeLentHistories = new ArrayList<>();
Cabinet cabinet = mock(Cabinet.class);
given(cabinet.getLentType()).willReturn(LentType.SHARE);
LocalDateTime expiredDate = lentPolicy.generateExpirationDate(LocalDateTime.now(),
cabinet, activeLentHistories);
assertEquals(expiredDate, DateUtil.getInfinityDate());
}
Java
복사
LentPolicyTest.java - 수정된 테스트 코드
그래서 List를 모킹하지 않고, 실제 구현체인 ArrayList를 이용하여 테스트를 수정하였다.
실제 객체이므로 activeLentHistories.get(0)를 호출하게 되면 에러가 발생한다. (물론 에러가 발생하는 코드는 수정했다.)
만약 테스트 코드가 이런식으로 작성되어 있었다면, 미연에 버그를 발견할 수 있었을 것이다..!
여기서 오늘의 주제에 대한 의문이 든다.
실제 객체를 이용하지 않고, List를 모킹해서 작성하는 것이 단위 테스트의 취지에 더 맞지 않나는 생각도 든다.
만약 그렇다는 가정하에, List를 모킹하면서 올바르게 테스트 코드를 작성하려면 어떻게 해야할까?
@Test
@DisplayName("성공: 만료시간무한 설정 - 공유사물함 최초 대여 - AVAILABLE")
void 성공_공유사물함_최초_대여_generateExpirationDate() {
List<LentHistory> activeLentHistories = mock(List.class);
Cabinet cabinet = mock(Cabinet.class);
given(cabinet.getLentType()).willReturn(LentType.SHARE);
given(activeLentHistories.isEmpty()).willReturn(true); // 추가된 부분
LocalDateTime expiredDate = lentPolicy.generateExpirationDate(LocalDateTime.now(),
cabinet, activeLentHistories);
assertEquals(expiredDate, DateUtil.getInfinityDate());
}
Java
복사
사실 이 코드를 작성하는 것은 불가능하다.
왜냐하면 문제 있는 소스 코드를 작성할 당시에는 activeLentHistories가 비었다면, 무한대 시간을 반환해야 한다는 것을 인지하지 못했기 때문이다.
적어도 이번 케이스에 대해서는 List를 모킹하는 것은 정답에 가깝지 않다는 생각이 든다…!
그렇다면 언제 모킹을 해야할까?
이번 이슈를 겪으면서, 그리고 지금까지 테스트 코드를 짜면서 느꼈던 생각들을 바탕으로 나름대로 기준을 제시해보겠다.
1. 신뢰할만한 유틸리티 라이브러리는 굳이 모킹하지 않는다.
단위 테스트의 목적이 독립된 환경에서 특정 메소드같은 개별 코드 조각들을 테스트하는 것이기 때문에 테스트에 대상이 되는 것에 모든 종속성을 모킹하는 것이 이상적이라고들 얘기한다.
하지만 모든 경우에 모킹이 필수는 아니라고 생각한다.
java.util.ArrayList는 이미 많은 검증을 받은 라이브러리이다.
자바로 개발하는 프로그래머를 붙잡고 아무나 물어보더라도 신뢰할 수 있는 유틸리티 라이브러리라고 대답할 것이다.
이를 모킹해서 얻을 수 있는 독립성보다, 이를 모킹하기 위해서 기울여야하는 주의가 더 크지 않나는 생각을 이번 이슈를 겪으면서 느끼게 되었다.
2. 테스트 대상이 우리가 만든 다른 소스 코드에 의존하는 경우 필수로 모킹한다.
안타까운 얘기이지만, 사실 우리가 짠 코드는 그렇게 신뢰성이 높은 코드는 아니다..
사실 그렇기 때문에 테스트를 작성하는 것이기도 하다.
그런데 우리가 테스트하려고 하는 코드가 또 다른 소스 코드에 의존하고 있는 경우에, 의존하고 있는 그 소스 코드 역시 테스트가 필요한 검증받지 못한 코드이다.
그렇기 때문에 그런 검증받지 못한 코드를 의존하게 된다면, 버그가 발생했을 때 정확히 버그가 어디서 발생하는 지 알 수 없게 되고, 문제가 없는 코드를 디버깅하느라 시간을 보내게 되는 경우가 발생할 수 있을 것이다.
@Test
@DisplayName("성공: 만료시간무한 설정 - 공유사물함 최초 대여 - AVAILABLE")
void 성공_공유사물함_최초_대여_generateExpirationDate() {
List<LentHistory> activeLentHistories = new ArrayList<>();
Cabinet cabinet = mock(Cabinet.class);
given(cabinet.getLentType()).willReturn(LentType.SHARE);
LocalDateTime expiredDate = lentPolicy.generateExpirationDate(LocalDateTime.now(),
cabinet, activeLentHistories);
assertEquals(expiredDate, DateUtil.getInfinityDate());
}
Java
복사
LentPolicyTest.java - 수정된 테스트 코드 (위에서와 동일)
아까 위에서 수정했던 generateExpirationDate 테스트 코드이다.
여기서 Cabinet은 우리가 작성한 도메인 클래스이다.
이는 충분히 검증받지 못했기 때문에 만약 다른 소스 코드에서 이를 의존하고 있고, 이 소스 코드를 테스트하고자 한다면 필수적으로 모킹하여 사용해야 한다고 생각한다..!
3. 테스트 대상이 외부 시스템에 의존하는 경우에도 필수로 모킹한다.
사실 이건 단위 테스트를 할 때 당연한 얘기이면서, 2번과 비슷한 이유이다.
외부 시스템은 우리가 신뢰하지 못할 뿐더러, 통제할 수도 없다.
그렇기 때문에 테스트 코드에서 외부 시스템에 직접 의존하게 된다면 외부 시스템에서 발생하는 버그인지, 우리의 소스 코드의 문제인지 쉽게 파악하기가 어려울 수 있다.
모든 테스트는 언제 수행해도 동일한 결과가 반환되어야 한다. 라는 테스트의 기본 원칙을 위배할 확률이 굉장히 높아진다.
마치며
사실 오늘 작성한 글이 당연하게 느껴질 수 있을 것이다. (실제로 당연한 얘기니까!)
하지만 모킹을 할 때 조금 더 책임감을 가질 필요가 있지 않을까 생각한다.
여기서 말하는 책임감은 내가 모킹하는 이 객체가 내가 테스트하려고 하는 코드에서 사용될 때, 이 객체에 동작으로 인해 어떤 일이 일어나게 되는지를 인지하는 것이다.
소스 코드가 잘못되는 것보다 테스트 코드가 잘못되는 것이 더 치명적인 일일 수 있다.
테스트 코드가 잘못되어서 잘못된 코드를 통과 시켜버린다면, 버그를 고칠 수 있는 기회를 놓치게 되는 것이기 때문이다.
그렇기 때문에 아무 생각없이 일단 모킹하자~ 가 되버린다면 분명히 가까운 미래에 외통수로 돌아오게 될 것이라는 것을 느끼게 된 시간이었다..!