Kotest非決定的テスト(Non-deterministic Testing)

Non-deterministic Testingは、ソフトウェアの完全性を検証するのに役立つ。たとえば、並列プロセス、外部ネットワーク呼び出し、さまざまなシステムリソースへのアクセスなど、ランダム性や予測不能な要素を含むソフトウェアシステムを効果的にテストできる。

Eventually

非決定的なコードをテストするのは難しいことがある。スレッド、タイムアウト、競合状態、イベント発生タイミングの予測不能性などを考慮する必要がある。

たとえば、非同期ファイル書き込みが正常に完了したかをテストする場合、書き込み処理が完了してディスクにフラッシュされるまで待つ必要がある。

このような問題への一般的なアプローチはいくつかある。

  • 処理が完了したときに呼び出されるコールバックを使う。その後、コールバックでシステム状態が期待どおりかを確認できる。ただし、すべての処理がコールバック機能を提供しているわけではない。

  • Thread.sleepを使ってスレッドを停止するか、delayを使って関数を一時停止し、処理が完了するまで待つ。高速または低速のコンピュータでも処理が完了できるよう、スリープしきい値を十分に高く設定する必要がある。また、高速なコンピュータでコードがすぐ完了しても、テストはタイムアウトまで待つことになる。

  • スリープ後に再試行するループを使う。ただし、繰り返し回数を追跡し、特定の例外を処理し、他の例外では失敗し、総経過時間が最大値を超えていないか確認するなどの定型コードを書く必要がある。

  • カウントダウンラッチを使い、非決定的な処理によってラッチが解放されるまでスレッドをブロックする。適切な場所にラッチを挿入できるならうまく機能するが、コールバックと同様、テスト対象コードをラッチと統合できるとは限らない。

上記の解決策に対する代替として、Kotestは**「このコードは短時間後に通過すると期待する」**という一般的なユースケースを扱うeventually関数を提供している。

eventually関数は、指定された例外を無視しながら指定されたラムダを定期的に呼び出し、ラムダが通過するか、タイムアウトに達するか、反復回数が多すぎる状態になるまで動作する。柔軟であり、非決定的コードのテストに適している。処理する例外型、ラムダの成功または失敗の判断方法、リスナーを使うかどうかなどを考慮してカスタマイズできる。

API

eventuallyの使い方は2つある。1つ目は、KotlinのDuration型を使って単純に期間を指定し、その後に例外を発生させず最終的に通過すべきコードを書く方法である。

例:

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

2つ目は構成ブロックを提供する方法である。期間以外のオプションを設定する必要がある場合に使う。また、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"
}

トラップする例外を指定する

デフォルトでは、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

eventuallyのもう一つの意味として、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は指定された期間が満了するか関数がtrueを返すまで、関数を定期的に実行する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

retryeventuallyに似ているが、一定時間コードブロックを試すのではなく、最大回数までコードブロックを試す。ループが永久に実行されるのを防ぐため、依然としてタイムアウト期間を指定する。

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

追加オプションには、実行中の遅延、指数的遅延に使う倍率、たとえば1、2、4、8、16など、特定の例外に対してのみ反復し、他の例外では失敗したい場合の例外クラスなどがある。


参照