非同期コードのテスト

非同期コードは複雑です。 誰もがそれを知っています。 非同期テストの作成はさらに困難です。 私は最近、不安定なテストを修正しました。非同期テストの作成に関する考えを共有したいと思います。



この記事では、非同期テストの一般的な問題-スレッドを特定の順序で配置し、個々のスレッドの個々の操作を他のスレッドの他の操作の前に実行する方法を分析します。 通常の状態では、異なるスレッドの強制実行順序を構築しようとはしません。これは、スレッドを使用する理由と矛盾しているためです。 ただし、テストの安定性を確保するには、テスト中に特定の順序が必要になる場合があります。







スロットルのテスト



Throttler(リミッター)は、特定のリソースで実行される同時操作(接続プール、ネットワークバッファー、プロセッサ集中型の操作など)の数を制限するクラスです。 他の同期ツールとは異なり、リミッターの役割は、クォータを超えるリクエストが待機せずにすぐに失敗するようにすることです。 ポート、スレッド、メモリなどのリソースを待機する代替手段があるため、迅速な完了が重要です。



デリミタの簡単な実装を次に示します(基本的にはセマフォクラスのラッパーです。実際には、待機、再試行などがあります)。

class ThrottledException extends RuntimeException("Throttled!") class Throttler(count: Int) { private val semaphore = new Semaphore(count) def apply(f: => Unit): Unit = { if (!semaphore.tryAcquire()) throw new ThrottledException try { f } finally { semaphore.release() } } }
      
      





単純な単体テストから始めましょう。1つのスレッドのリミッターをテストしています(テストにはspecs2ライブラリを使用します)。 このテストでは、リミッターに設定された同時コールの最大数よりも多くの連続したコールを発信できることを確認します(maxCount変数は低くなります)。 単一のスレッドを使用するため、デリミタをロードしないため、デリミタが「すばやく終了する」能力をテストしないことに注意してください。 実際、リミッターがロードされていない間は、操作を中断しないという事実のみをチェックします。

 class ThrottlerTest extends Specification { "Throttler" should { "execute sequential" in new ctx { var invocationCount = 0 for (i <- 0 to maxCount) { throttler { invocationCount += 1 } } invocationCount must be_==(maxCount + 1) } } trait ctx { val maxCount = 3 val throttler = new Throttler(maxCount) } }
      
      







リミッターを非同期でテストする



前のテストでは、単一のスレッドでは実行できないため、リミッターをロードしませんでした。 したがって、次のステップは、マルチスレッド環境でリミッターの動作を確認することです。



準備:

 val e = Executors.newCachedThreadPool() implicit val ec: ExecutionContext = ExecutionContext.fromExecutor(e) private val waitForeverLatch = new CountDownLatch(1) override def after: Any = { waitForeverLatch.countDown() e.shutdownNow() } def waitForever(): Unit = try { waitForeverLatch.await() } catch { case _: InterruptedException => case ex: Throwable => throw ex }
      
      





ExecutionContextオブジェクトは、Futureコンストラクトに使用されます。 waitForeverメソッドは、テストの終了前に、waitForeverLatchカウンターがゼロにリセットされるまでスレッドを保持します。 次の関数では、ExecutorServiceを閉じます。



リミッターのマルチスレッド動作をテストする簡単な方法は次のとおりです。

 "throw exception once reached the limit [naive,flaky]" in new ctx { for (i <- 1 to maxCount) { Future { throttler(waitForever()) } } throttler {} must throwA[ThrottledException] }
      
      





ここでは、maxCountに等しいフローを作成します。 各スレッドで、テストが終了するまで待機するwaitForever関数を呼び出します。 次に、別の操作を実行して、リミッターをバイパスします-maxCount +1。 この時点でThrottledExceptionが発生するはずです。 ただし、例外が予想されますが、例外は発生しません。 最後のリミッターコール(予想)は、将来のいずれかが始まる前に発生する可能性があります(これにより、この将来のこのインスタンスで例外がスローされますが、予想内ではありません)。



上記のテストの問題は、すべてのスレッドの開始がわからないため、リミッターが例外をスローすることを期待して、リミッターをバイパスしようとする前にwaitForever関数で待機することです。 これを修正するには、将来のすべてのスレッドが開始されるまで何らかの方法で待機する必要があります。 これは私たちの多くに馴染みのあるアプローチです:妥当な期間でsleepメソッドへの呼び出しを追加するだけです。

 "throw exception once reached the limit [naive, bad]" in new ctx { for (i <- 1 to maxCount) { Future { throttler(waitForever()) } } Thread.sleep(1000) throttler {} must throwA[ThrottledException] }
      
      





さて、今私たちのテストはほとんど常に合格します。 しかし、少なくとも2つの理由から、これは間違ったアプローチです。

テストの期間は、当社が設定した「合理的な期間」と正確に等しくなります。

マシンが非常に混雑している場合など、非常にまれな状況では、この妥当な期間では不十分な場合があります。



まだ疑問がある場合は、Googleで他の理由を探してください。

より正しいアプローチは、フローの開始(将来)と期待を同期することです。



java.util.concurrentパッケージのCountDownLatchクラスを使用します。

 "throw exception once reached the limit [working]" in new ctx { val barrier = new CountDownLatch(maxCount) for (i <- 1 to maxCount) { Future { throttler { barrier.countDown() waitForever() } } } barrier.await(5, TimeUnit.SECONDS) must beTrue throttler {} must throwA[ThrottledException] }
      
      





バリア同期にはCountDownLatchを使用します。 awaitメソッドは、ラッチカウンターがリセットされるまでメインスレッドをブロックします。 他のスレッドを開始する場合(これらの他のスレッドをフューチャとして指定します)、これらの各フューチャはバリアメソッドcountDownを呼び出して、ラッチカウンターの値を1つ減らします。 ラッチカウンターがゼロになると、すべてのフューチャはwaitForeverメソッド内に配置されます。

この時点で、リミッターがロードされ、maxCountに等しいスレッド数が含まれていることを確認しました。 別のスレッドがリミッターをトリガーしようとすると、例外がスローされます。 したがって、メインスレッドのリミッターの動作を確認できる決定的な実行順序を取得しました。 メインスレッドはこの時点で実行を継続できます(バリアカウンターがゼロになり、CountDownLatchが待機スレッドを解放します)。

予期せぬことが発生した場合の無限のブロックを避けるために、わずかに過大評価されたタイムアウトを使用します。 何かが起こると、テストは失敗します。 このタイムアウトはテストの期間に影響しません。予期しないことが起こらなければ、待機する必要がないためです。



最後に



非同期コードをテストする場合、特定のテストには特定の順序のスレッドが必要になることが非常に多くあります。 同期を適用しない場合、不安定なテストが行​​われますが、これはうまくいく場合もあれば、落ちる場合もあります。 Thread.sleepを使用すると、テストの不安定性は軽減されますが、問題は解決しません。 ほとんどの場合、テストでスレッドの順序を決定する必要がある場合、Thread.sleepの代わりにCountDownLatchを使用できます。 CountDownLatchの利点は、ストリームの待機(ホールド)をいつリセットするかを指定できることです。これにより、2つの重要な利点が得られます。 waitForever関数などの通常の待機でも、Thread.sleep(Long.MAX_VALUE)のようなものを使用できますが、信頼できないアプローチは常に避けるのが最善です。



Wix Webサイトビルダー開発者、

ドミトリー・コマノフ

元の記事: Wix Engineers Blog



All Articles