TEST

[TEST] TestCoverage 100%에 도달해보자.

EJUN 2025. 5. 20. 13:10

 

문득 진행했던 프로젝트명이 TDD인데 테스트 커버리지 0%인 걸 보고 그래도 이름에 맞게 커버리지 80%까지는 올려보자 라는 생각으로 작성을 시작했다.

하지만..

테스트 코드를 작성하다 보면 자주 겪는 문제 중 하나는 바로 시간 관련 로직을 처리할 때 발생합니다.

특히, LocalDate.now()와 같은 시간을 직접적으로 로직 내에서 사용하면 테스트 코드가 실행되는 날마다 값이 달라져 테스트가 실패하는 문제가 발생합니다.

시간에 의존적인 테스트

 

예를 들어 다음과 같은 코드가 있습니다:

if (LocalDate.now() == todo.deadline) {
    // 특정 로직 수행
}

이 코드의 문제점은 테스트가 실행되는 날짜가 매번 바뀌기 때문에, 실행하는 날에 따라 테스트 결과가 달라질 수 있다는 것입니다.

처음의 해결 방법 (코틀린 인자 기본값 사용)

이 문제를 처음 해결할 때는 코틀린의 기능인 인자에 기본값을 주는 방식을 이용했습니다.

fun checkDeadline(todo: Todo, today: LocalDate = LocalDate.now()): Boolean {
    return today == todo.deadline
}

이 방법은 간편했지만, 다음과 같은 한계가 있었습니다:

  • 코틀린에서는 간단히 사용할 수 있으나, 자바에서 해당 기능을 사용할 수 없습니다.
  • 테스트 코드의 가독성과 유지보수 측면에서도 더 나은 해결책이 필요했습니다.
개선된 방법 (TimeProvider Interface 사용)

그래서 보다 범용적으로 사용할 수 있도록 TimeProvider 인터페이스를 정의하여 추상화했습니다.

이를 통해 프로덕션 코드와 테스트 코드에서 각각 다른 구현을 주입할 수 있게 만들었습니다.

TimeProvider 인터페이스 정의

interface TimeProvider {
    fun nowDate(): LocalDate
    fun nowDateTime(): LocalDateTime
}

실제 프로덕션 환경 구현

@Component
class ProdDateProvider : TimeProvider {
    override fun nowDate() = LocalDate.now()
    override fun nowDateTime(): LocalDateTime = LocalDateTime.now()
}

테스트 환경 구현 (Stub 활용)

class StubDateProvider(
    private val today: LocalDate
) : TimeProvider {
    override fun nowDate(): LocalDate = today
    override fun nowDateTime(): LocalDateTime = today.atStartOfDay()
}

이렇게 하면 테스트 코드에서 항상 같은 날짜를 주입하여 결과가 일정하게 유지되도록 할 수 있습니다.

사용 예시

val stubDateProvider = StubDateProvider(LocalDate.of(2025, 5, 19))
val result = checkDeadline(todo, stubDateProvider.nowDate())

result shouldBe true
결론 및 느낀 점

TimeProvider 인터페이스를 사용함으로써 시간에 의존적인 테스트의 신뢰성을 확보할 수 있었고, 자바와 코틀린 모두에서 활용 가능한 범용적인 구조를 도입함으로써 코드의 유지보수성과 확장성 또한 향상시킬 수 있었습니다.

무엇보다도 테스트 코드가 명확하고 예측 가능해진다는 장점이 있었습니다.

 

 

Kotlin 모든 분기처리를 했지만 미달로 뜨는 이유?

 

테스트코드 작성이 생각보다 수월하게 진행이 되고있었는데 브랜치커버리지에서 분기처리를 다했음에도 불구하고 커버가 안되는 현상이 있었다..

todoCreator.generatorTodo(
    todo,
    member,
    issueEventRequest?.issueNumber?.get()
        ?.takeIf { timeProvider.nowDate() == todo.deadline }
)

바로 이 코드에서 

위 부분이 커버가 되지 않았습니디(jacoco기준)

테스트는 이렇게 작성했습니다
  • issueEventRequest == null
  • issueEventRequest.issueNumber == null
  • issueEventRequest.issueNumber.get() == null
  • todo.deadline != 오늘
  • todo.deadline == 오늘 && issueNumber 있음

모든 케이스를 커버했다고 생각했는데...

Jacoco 커버리지에서 해당 줄이 여전히 회색으로 표시되고 있었습니다.

 

원인을 찾던 중 바로 코틀린이 생성한 바이트코드에 대한 테스트를 못했기 떄문입니다.

문제의 코드를 자바로 디컴파일 해보면

if (issueEventRequest != null) {
   CompletableFuture var10003 = issueEventRequest.getIssueNumber();
   if (var10003 != null) {
      var25 = (Integer)var10003.get();
      if (var25 != null) {
         Integer var15 = var25;
         int it = ((Number)var15).intValue();
         TodoCreator var19 = var10000;
         int var20 = false;
         boolean var21 = Intrinsics.areEqual(LocalDate.now(), todo.getDeadline());
         var10000 = var19;
         var10001 = todo;
         var10002 = member;
         var25 = var21 ? var15 : null;
         break label21;
      }
   }
}

위처럼 나오는데 자세히 보면

if 문이 네 단계로 중첩되어 있고, 각 조건문의 true/false 분기가 모두 별도로 존재합니다.

결과적으로 테스트 커버리지 도구는 이 네 조건의 모든 조합을 통과해야 해당 라인을 100% 커버로 인식한다는 특징이 있었습니다.

 

이쯤에서 저는 궁금했습니다.

???: “엘비스 연산자나 null-safe 체이닝은 결국 null 여부만 판단하는 건데, 왜 이게 테스트 커버리지 100%로 인정되지 않지?” 

 

jacoco와 같은 coverage tool는 

 

  • 해당 라인에 존재하는 조건(if)모든 분기 경로(true/false)모두 실행해야 fully covered로 인식함
  • ?. 연산자도 실제로는 if (x != null)이므로, true와 false 경로가 둘 다 존재함
  • 체이닝된 ?.는 각각의 if로 분해되며, 이 각각이 독립된 분기 조건으로 취급됨

이런 특징을 가지고 있습니다.

그래서 위 코드를 테스트할 때 issueEventRequest == null만 확인하면,

issueEventRequest.getIssueNumber() != null인 분기는 여전히 "미실행"으로 남아서, 해당 줄 전체는 커버되지 않게 보였습니다.

 

issueEventRequest != null
issueEventRequest.issueNumber != null
issueNumber.get() != null
todo.deadline == today

 

커버리지를 100%로 인식받으려면 이 모든 분기문을 true/false 양쪽 모두 실행해야 합니다.

그런데 코드는 이 조건을 한 줄로 체이닝했기 때문에, coverage tool은 이 줄 전체에 대해 조합된 2⁴ = 16가지 분기 시나리오 중 일부만 수행됐다면 ‘partial coverage’로 판단하는 것입니다.

 

그래서 저는 이걸 해결하는 방법은 if-else문을 통해 명확하게 분리를 하는 방법을 선택했습니다.

물론 이렇게 하면 코드가 좀 더 복잡해지고 코틀린스럽지 않다는 문제는 있었습니다.

하지만 저는 이번 목표가 Test Coverage 100%을 도달하는 것이기 때문에 구조 변경을 선택했습니다.

 

val memberTodoLists = todoRequest.map { todo ->
    var validIssueNumber: Int? = null
    if (issueEventRequest != null) {
        val future = issueEventRequest.issueNumber
        val issueNumber = future.get()
        if (issueNumber != null)
            if (timeProvider.nowDate() == todo.deadline) {
                validIssueNumber = issueNumber
            }
    }
    todoCreator.generatorTodo(
        todo,
        member,
        validIssueNumber
    )
}

위의 코드처럼 조건을 나누면, coverage tool은 각 if 또는 &&의 각 조건에 대해 true/false가 발생했는지 따지기 때문에 각 조건마다 true/false가 한번씩만 나오면 커버가 됐다고 판단합니다.

예를 들어:

  • issueNumber != null → true, false
  • timeProvider.nowDate() == deadline → true, false

이렇게 두 조건의 각각 true/false 조합이면, 4가지만 커버하면 됩니다.

jacoco 커버리지

하지만 이렇게 하는건 너무 지저분해서 이때 코틀린의 특징을 이용해 엘비스는 아니지만 엘비스처럼 동작하는 확장함수를 만드는 건 어떨까라는 생각이 들었다.

 

inline fun <T, R> T?.runIfNotNull(block: (T) -> R?): R? =
    if (this != null) block(this) else null

이런식으로 좀 더 명확하게 if-else분기를 확실히 표현을 할 수 있으므로 기존의 분기처리 테스트로도 충분히 아래와 같이 커버가 되는 것을 확인할 수 있다.

runIfNotNull확장함수를 통해 코드의 간결성 유지

 

 

테스트 커버리지 100% 달성!!! 

 

사실 테스트 코드를 작성하게 된 건 프로젝트 이름이 TDD인데 테스트코드가 없다는게 이상해서 작성을 시작했다.

이 이유뿐만 아니라 클린코드책에서 테스트 커버리지는 무조건 100%을 해야한다는 걸 봤을 때 "그걸 어떻게해?" 라는 생각을 하지도 않고 가졌었다.

 

우선 domain 즉, 비즈니스 로직만 확실하게 100으로 잡고 가자라는 마음으로 시작했지만 커버리지가 80%가 넘어가자 기왕 할 거 100%찍어보자 라는 생각을 가졌다.

 

결국,,

커버리지 100%

100%를 달성했다.

 

테스트를 작성하면서 가장 좋았던 점은 코드를 변경해도 사이드이펙트에 대한 걱정을 하지않아도 테스트를 통해서 바로 캐치할 수 있다는 점이 좋았다.

 

👉 TDD 저장소 [ https://github.com/ToDeveloperDo/TDD-be ]