Kotest 비결정적 테스트(Non-deterministic Testing)

Non-deterministic Testing은 소프트웨어의 무결성을 검증하는 데 도움이 될 수 있다. 예를 들어, 병렬 프로세스, 외부 네트워크 호출, 다양한 시스템 자원에 대한 액세스 등과 같이 무작위성을 가지는 요소들을 포함하는 소프트웨어 시스템을 효과적으로 테스트할 수 있다.

Eventually

비결정적 코드를 테스트하는 것은 어려울 수 있다. 스레드, 시간 초과, 경쟁 조건, 이벤트 발생 시기의 예측 불가능성 등을 고려해야 할 수도 있다.

예를 들어, 비동기 파일 쓰기가 성공적으로 완료되었는지 테스트하는 경우 쓰기 작업이 완료되어 디스크에 플러시될 때까지 기다려야 한다.

이러한 문제에 대한 몇 가지 일반적인 접근 방식은 다음과 같다:

  • 작업이 완료되면 호출되는 콜백을 사용한다. 그런 다음 콜백을 사용하여 시스템 상태가 예상한 대로인지 확인할 수 있다. 하지만 모든 작업이 콜백 기능을 제공하는 것은 아니다.

  • Thread.sleep을 사용하여 스레드를 중지하거나, delay를 사용하여 함수를 일시 중단하고 작업이 완료될 때까지 기다린다. 속도가 빠르거나 느린 컴퓨터에서 작업이 완료될 수 있도록 절전 임계값을 충분히 높게 설정해야 한다. 또한, 빠른 컴퓨터에서 코드가 빠르게 완료되더라도 테스트가 시간 초과를 기다리게 된다.

  • 절전 후 재시도 및 절전 후 재시도가 있는 루프를 사용하되 반복 횟수를 추적하고, 특정 예외를 처리하고 다른 예외는 실패하고, 총 소요 시간이 최대를 초과하지 않았는지 확인하는 등의 상용구를 작성해야 한다.

  • 카운트다운 래치를 사용하고 비결정적 연산에 의해 래치가 해제될 때까지 스레드를 차단한다. 적절한 위치에 래치를 삽입할 수 있다면 이 방법은 잘 작동할 수 있지만 콜백과 마찬가지로 테스트할 코드가 래치와 통합되는 것이 항상 가능한 것은 아니다.

위의 솔루션에 대한 대안으로, kotest는 **“이 코드는 짧은 시간 후에 통과할 것으로 예상한다”**라는 일반적인 사용 사례를 해결하는 궁극적으로 함수를 제공한다.

결국 함수는 지정된 예외를 무시하고 지정된 람다를 주기적으로 호출하여 람다가 통과하거나 시간 초과에 도달하거나 너무 많은 반복이 경과할 때까지 작동한다. 이는 유연하며 비결정적 코드를 테스트하는 데 적합하다. 처리할 예외 유형, 람다의 성공 또는 실패 간주 방법, 리스너 사용 여부 등을 고려하여 최종적으로 사용자 정의할 수 있다.

API

결국 사용하는 방법에는 두 가지가 있다. 첫 번째는 Kotlin Duration 유형을 사용하여 단순히 기간을 제공한 다음 예외가 발생하지 않고 최종적으로 전달되어야 하는 코드를 입력하는 것이다.

예를 들어:

eventually(5.seconds) {
  userRepository.getById(1).name shouldBe "bob"
}

두 번째는 구성 블록을 제공하는 방법이다. 이 방법은 기간보다 더 많은 옵션을 설정해야 할 때 사용해야 한다. 또한 eventually를 여러 호출 간에 구성을 공유할 수 있다.

예를 들어:

val config = eventuallyConfig {
  duration = 1.seconds
  interval = 100.milliseconds
}

eventually(config) {
  userRepository.getById(1).name shouldBe "bob"
}

구성 옵션

기간 및 간격

기간(duration)은 테스트 통과를 계속 시도할 총 시간이다. 간격(interval)을 사용하면 테스트를 얼마나 자주 시도할지 지정할 수 있다. 따라서 기간을 5초로 설정하고 간격을 250ms로 설정하면 테스트는 최대 5000/250 = 20회까지 시도된다.

val config = eventuallyConfig {
  duration = 5.seconds
  interval = 250.milliseconds
}

또는, 간격을 고정된 숫자로 지정하는 대신 함수를 전달할 수 있다. 이렇게 하면 일종의 백오프를 수행하거나 필요한 다른 작업을 수행할 수 있다.

예를 들어, 100ms부터 시작하는 피보나치 증가 간격을 사용할 수 있다:

val config = eventuallyConfig {
  duration = 5.seconds
  intervalFn = 100.milliseconds.fibonacci()
}

초기 지연

일반적으로는 테스트 블록을 즉시 실행하기 시작하지만, 다음과 같이 initialDelay를 사용하여 첫 번째 반복 전에 초기 지연을 추가할 수 있다:

val config = eventuallyConfig {
  initialDelay = 1.seconds
}

재시도

시간으로 호출 횟수를 제한하는 것 외에도 반복 횟수로도 제한할 수 있다. 다음 예제에서는 작업을 10회 또는 8초가 만료될 때까지 재시도(retries)한다.

val config = eventuallyConfig {
  initialDelay = 8.seconds
  retries = 10
}

eventually(config) {
  userRepository.getById(1).name shouldBe "bob"
}

트랩(trap)할 예외 지정하기

기본적으로 eventually은 함수 내부에서 발생하는 모든 AssertionError를 무시한다(즉, Errorcatch하지 않는다는 의미이다). 좀 더 구체적으로 말하자면, eventually 특정 예외를 무시하고 다른 예외는 즉시 테스트에 실패하도록 지정할 수 있다. 우리는 이러한 예외를 예상된 예외(expected exceptions)라고 부른다.

예를 들어, 데이터베이스에 사용자가 존재해야 하는지 테스트할 때 사용자가 존재하지 않으면 UserNotFoundException이 발생할 수 있다. 결국에는 해당 사용자가 존재할 것이라는 것을 알고 있다. 그러나 IOException이 발생하면 단순히 타이밍보다 더 큰 문제가 있음을 의미하므로 계속 재시도하고 싶지 않는다.

이를 위해 UserNotFoundException을 억제할 예외로 지정하면 된다.

val config = eventuallyConfig {
  duration = 5.seconds
  expectedExceptions = setOf(UserNotFoundException::class)
}

eventually(config) {
  userRepository.getById(1).name shouldBe "bob"
}

예외 집합을 전달하는 대신 throw 예외를 전달하여 호출되는 함수를 제공할 수 있다. 이 함수는 예외를 무시해야 하는 경우 true을 반환하고, 예외를 버블 처리해야 하는 경우 false을 반환해야 한다.

val config = eventuallyConfig {
  duration = 5.seconds
  expectedExceptions = { it is UserNotFoundException }
}

eventually(config) {
  userRepository.getById(1).name shouldBe "bob"
}

Listeners

각 반복마다 호출될 리스너에 현재 반복 횟수 및 반복 실패의 원인이 된 예외를 첨부할 수 있다. 참고: 리스너는 호출 성공 시 실행되지 않는다.

val config = eventuallyConfig {
  duration = 5.seconds
  listener = { k, throwable -> println("Iteration $k, with cause $throwable") }
}

eventually(config) {
  userRepository.getById(1).name shouldBe "bob"
}

구성 공유

eventuallyConfig 빌더를 사용하면 eventually 구성을 쉽게 공유할 수 있다. 시스템에서 작업을 “느린” 작업과 “빠른” 작업으로 분류했다고 가정해 보겠다. 느린 작업과 빠른 작업에 대한 타이밍 값을 기억하는 대신 테스트 간에 공유할 몇 가지 객체를 설정하고 각 작업별로 사용자 지정할 수 있다. 이는 또한 프로듀서 결과의 현재 값과 반복 상태에 대한 통찰력을 제공하는 eventually의 리스너 기능을 보여주기 위한 완벽한 시간이다!

val slow = eventuallyConfig {
  duration = 5.minutes
  interval = 25.milliseconds.fibonacci()
  listener = { i, t -> logger.info("Current $i after ${t.times} attempts") }
}

val fast = slow.copy(duration = 5.seconds)

class FooTests : FunSpec({
  test("server eventually provides a result for /foo") {
    eventually(slow) {
      fooApi()
    }
  }
})

class BarTests : FunSpec({
  test("server eventually provides a result for /bar") {
    eventually(fast) {
      barApi()
    }
  }
})

Continually

결국의 두 가지 의미로, continually 코드 블록이 일정 시간 동안 성공하고 계속 성공하고 있다고 검증할 수 있다. 예를 들어, 마지막 패킷이 수신된 후 60초 동안 http 연결이 유지되는지 확인하고 싶을 수 있다. 60초 동안 sleep 모드로 전환한 다음에 확인할 수 있지만, 5초 후에 연결이 종료되면 테스트는 55초 동안 더 유휴 상태로 있다가 실패한다. 빨리 실패하는 것이 낫다.

class MyTests : ShouldSpec() {
  init {
    should("pass for 60 seconds") {
      continually(60.seconds) {
        // code here that should succeed and continue to succeed for 60 seconds
      }
    }
  }
}

continually 블록에 전달된 함수는 10ms마다 실행된다. 원하는 경우 폴링 간격을 지정할 수 있다:

class MyTests: ShouldSpec() {
  init {
    should("pass for 60 seconds") {
      continually(60.seconds, 5.seconds) {
        // code here that should succeed and continue to succeed for 60 seconds
      }
    }
  }
}

Until

비결정적 코드를 테스트할 때 흔히 “이 코드는 짧은 시간 후에 통과할 것으로 예상한다"라는 사용 사례가 있다.

예를 들어, 브로커가 메시지를 수신했는지 테스트하고 싶을 수 있다. 시간 제한을 설정하고 메시지가 수신될 때까지 반복적으로 폴링할 수 있지만 이렇게 하면 스레드가 차단된다. 또한 상용구를 추가하여 루프 코드를 작성해야 한다.

이에 대한 대안으로, kotest는 해당 함수가 참을 반환하거나 지정된 기간이 만료될 때까지 주기적으로 함수를 실행하는 until 함수를 제공한다.

until는 eventually에 해당하는 술어(predicate)이다.

기간(Duration)

브로커를 폴링하고 메시지 목록을 반환하는 함수가 있다고 가정해 보겠다. 메시지를 보낼 때 5초 이내에 브로커가 메시지를 수신하는지 테스트하고 싶다.

class MyTests : ShouldSpec() {

  private val broker = createBrokerClient()

  init {
    should("broker should receive a message") {
      sendMessage()
      until(5.seconds) {
        broker.poll().size > 0
      }
    }
  }
}

간격(Interval)

기본적으로 술어는 매초마다 확인된다. 호출 간 지연을 제어하는 간격을 지정할 수 있다. 다음은 동일한 예제이지만 이번에는 좀 더 공격적으로 250ms 간격을 고정했다.

class MyTests : ShouldSpec() {

  private val broker = createBrokerClient()

  init {
    should("broker should receive a message") {
      sendMessage()
      until(5.seconds, 250.milliseconds.fixed()) {
        broker.poll().size > 0
      }
    }
  }
}

각 실패 후 지연 시간을 늘리려면 피보나치 간격을 지정할 수도 있다.

class MyTests : ShouldSpec() {

  private val broker = createBrokerClient()

  init {
    should("broker should receive a message") {
      sendMessage()
      until(5.seconds, 100.milliseconds.fibonacci()) {
        broker.poll().size > 0
      }
    }
  }
}

Retry

재시도는 결국과 비슷하지만 일정 시간 동안 코드 블록을 시도하는 것이 아니라 최대 횟수만큼 코드 블록을 시도한다. 루프가 영원히 실행되는 것을 방지하기 위해 여전히 시간 초과 기간을 제공한다.

class MyTests: ShouldSpec() {
  init {
    should("retry up to 4 times") {
      retry(4, 10.minutes) {
        // ...
      }
    }
  }
}

추가 옵션으로는 실행 중 지연, 지수적 지연을 사용하는 배수(예: 1,2,4,8,16…), 특정 예외에 대해서만 반복하고 다른 예외에 대해서는 실패하려는 경우 예외 클래스가 포함된다.


참조




최종 수정 : 2024-04-21