ソース記事はこちら
コルーチンを使うコードのテスト方法について議論しよう。我々の解法をテストし、並列なコルーチンの解法が、suspend
関数の解法よりも速く、チャネルの解法が、単純な"進捗"の解法よりも速いことが確認できればと思う。
それではGitHubサービスをモックし、このサービスが与えられたタイムアウトの後、結果を返すようにしよう。
リポジトリ一覧要求 - 1000ms遅延以内で、答えを返す
repo-1 - 1000 ms 遅延
repo-2 - 1200 ms 遅延
repo-3 - 800 ms 遅延
その後、suspend
関数でのシーケンシャルな解法は、4000ms前後かかるはずである。
4000 = 1000 + (1000 + 1200 + 800)
そして並列の解法は、2200ms前後かかるはずである。
2200 = 1000 + max(1000, 1200, 800)
進捗を表示する解法のために、タイムスタンプつきで中間結果を確認することもできる。
対応するテストデータは、test/contributors/testData.kt
内に定義され、Request4SuspendKtTest
,... Request7ChannelKtTest
ファイルには、モックサービス呼び出しを使う、簡単なテストが含まれている。
しかし、ここに二つの問題がある。
- これらのテストは実行するのに時間がかかる。それぞれのテストは2から4秒前後かかり、それぞれの時間結果を待たなければならない。そのようなアプローチは非効率である。
- 解法が実行される精密な時間を当てにすることはできない。なぜなら、準備しはじめ、コード等を実行するさらに追加の時間がかかるからだ。追加の定数を加えることはできるだろうが、そうはいっても、機器ごとに異なるだろう。留意すべきは、モックサービスの遅延は、違いを見るために、この定数よりも高くなければならない。定数が0.5秒の場合、遅延を0.1秒にするのは十分ではない。
より良い方法は、特別なフレームワークを使って、同じコードを何度か実行する(それはさらに全体時間がかかる)間にタイミングをテストすることであるが、それは理解し準備するのに複雑である。
しかしこの場合、非常に簡単なことをテストしたい。つまりテストの遅延が与えられた解法は、あるものは他よりも速いという、想定したようにふるまうことである。まだ現実世界の性能テストには興味はない。
これらの問題を修正するには、仮想の時間を使うことができる。そのために、特別なテストディスパッチャーを使う必要がある。それは開始から経過する仮想時間を追跡し続け、リアルタイムにすぐにすべてを実行する。このディスパッチャーでコルーチンを実行するとき、delay
はすぐに返却され、仮想時間が進む。
このメカニズムを使うテストは速く実行できるが、さらに仮想時間で異なる瞬間に何が起きるか、チェックすることもできる。全体の実行時間は、劇的に削減される。
どのようにして仮想時間を使えるようにするか?runBlocking
呼び出しをrunBlockingTest
で置き換える。runBlockingTest
は引数として、TestCoroutineScope
への拡張ラムダを持つ。特別なスコープ内部でsuspend
関数内のdelay
が呼び出されると、delay
は現実世界の遅延の代わりに、仮想時間を増加させる。
@Test
fun testDelayInSuspend() = runBlockingTest {
val realStartTime = System.currentTimeMillis()
val virtualStartTime = currentTime
foo()
println("${System.currentTimeMillis() - realStartTime} ms") // ~ 6 ms
println("${currentTime - virtualStartTime} ms") // 1000 ms
}
suspend fun foo() {
delay(1000) // 遅延無しで自動的に進む
println("foo") // foo()が呼び出されると、真面目に実行する
}
上記の例で、Dispatchers.Default
コンテキストを持つlaunch
を呼んでみることもでき、テストが失敗することが確認できる。つまりジョブがまだ完全に終了していないという例外が返ってくる。
この方法でloadContributorsConcurrent
関数を、それが子のコルーチンを継承されたコンテキストで開始する場合だけ、Dispatchers.Default
ディスパッチャーの使用を修正することなく、テストすることができる。関数を定義するときよりも、呼び出すときに、ディスパッチャーのようなコンテキストを指定することができる。その方がフレキシブルでありテストしやすい。
留意すべきは、仮想時間をサポートするテスト用のAPIは、実験的であり、将来変更されるかもしれない。デフォルトでは使う場合にコンパイラ警告が表示される。この警告を抑止するには、テスト関数化、テストを含むクラス全体にアノテーションをつける必要がある。テストクラスまたは関数に@OptIn(ExperimentalCoroutinesApi::class)
を加える。そのようなアノテーションを追加することにより、そのAPIは変更があるかもしれず、必要な場合(多くの場合自動的に)、使用を更新する準備があることをを理解していると強調することができる。
実験的APIを使っていることを申告するコンパイラ引数を追加する必要もある。
compileTestKotlin {
kotlinOptions {
freeCompilerArgs += "-Xuse-experimental=kotlin.Experimental"
}
}
このチュートリアルに対応するプロジェクトでは、これはすでにgradleスクリプトに追加されている。
課題
tests/tasks/
内の次のすべてのテストをリファクタリングし、実時間の代わりに仮想時間を使うようにすること。
Request4SuspendKtTest.kt
Request5ConcurrentKtTest.kt
Request6ProgressKtTest.kt
Request7ChannelsKtTest.kt
全体の実行時間をこのリファクタリング前の時間と比較すること。
ヒント
次のようにrunBlocking
呼び出しをrunBlockingTest
に、System.currentTimeMillis()
をcurrentTime
に置き換える。
@Test
fun test() = runBlockingTest {
val startTime = currentTime
// action
val totalTime = currentTime - startTime
// testing result
}
正確な仮想時間をチェックするアサーションコメントを解除すること。そして@UseExperimental(ExperimentalCoroutinesApi::class)
を忘れずに追加しよう。
解法
こちらが並列のケースの解法である。
fun testConcurrent() = runBlockingTest {
val startTime = currentTime
val result = loadContributorsConcurrent(MockGithubService, testRequestData)
Assert.assertEquals("Wrong result for 'loadContributorsConcurrent'", expectedConcurrentResults.users, result)
val totalTime = currentTime - startTime
Assert.assertEquals(
"The calls run concurrently, so the total virtual time should be 2200 ms: " +
"1000 for repos request plus max(1000, 1200, 800) = 1200 for concurrent contributors requests)",
expectedConcurrentResults.timeFromStart, totalTime
)
}
"チャネル"を使った最後のバージョンの最初の中間結果は、"進捗"のバージョンに比較して速く入手でき、仮想時間でテストにおける違いを見ることができる。
残りの"suspend"と"進捗"タスクのテストは非常に似ている。つまり"solution"ブランチのプロジェクで見つけることができる。
仮想時間と実験的なテストパッケージを使うことについてのより多くの情報をここで見つけることができる。それがどのくらい効果があるか、フィードバックを共有させていただきたい!