前回に引き続きCoroutinesの単体テストの話です。
[https://iwsksky.hatenablog.com/entry/2020/12/09/014603:title]
今回はrunBlockingTest[1]について取り扱いたいと思います。
⚠理解が曖昧な状態で記述している可能性があります、間違いがあれば訂正お願いします。
環境
implementation "org.jetbrains.kotlin:kotlin-stdlib:1.4.20"
testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:1.3.9"
runBlockingTestとは
kotlinx-coroutines-testに含まれるAPIでrunBlocking同様に現在のスレッドをブロックしてコルーチンを起動することが可能。
@ExperimentalTime
@Test
fun exampleTest() = runBlocking {
val deferred = async {
println("SEC1 is " + measureTime { delay(10_000) })
println("SEC2 is " + measureTime { async { delay(10_000) }.await()})
}
deferred.await()
}
// sec1 is 10.0s
// sec2 is 10.0s
上記例ではrunBlockingに渡しているブロックで2度コルーチンが起動されておりそれぞれで10secディレイを入れているため以下のような出力になる。
次にrunBlockingをrunBlockingTestに変更した場合
ExperimentalTime
@Test
fun exampleTest() = runBlockingTest {
val deferred = async {
println("SEC1 is " + measureTime { delay(10_000) })
println("SEC2 is " + measureTime { async { delay(10_000) }.await()})
}
deferred.await()
}
// sec1 is 8.78ms
// sec2 is 1.51ms
delayが即時実行され処理が完了していることがわかる。
runBlockingTestの用途
runBlockingTestが導入された背景はTesting Coroutines on Android (Android Dev Summit '19)[2]とKotlinConf 2019: Testing with Coroutines by Sean McQuillan[3]が詳しい。Android Dev Summitでは良い単体テストは速く(fast)、信頼性があり(reliable)、独立している(isolated)と言われており、runBlockingTestは特に速さと信頼性の観点で導入されたように思う。
ではrunBlockingTestを利用したいのはどういう場合だろうか。
普段ViewModelが呼び出すRepositoryのsuspend関数は実行結果をmockすることが多くあまりピンと来なかったが、KotlinConfのプレゼンテーションではsuspend関数のタイムアウト処理をテストするケースが例として挙げられていた。例えば以下のようなタイムアウトが設定されたsuspend関数があった場合、これをrunBlockingで実行すると5秒待つ必要がある。
suspend fun foo(resultDeferred: Deferred<Foo>) {
try {
withTimeout(5_000) {
resultDeferred.await()
}
} catch (e: Exception) {
println("e: ${e}")
throw FooException()
}
}
@ExperimentalCoroutinesApi
@Test(expected = FooException::class)
fun testFooWithTimeout() = runBlocking {
val uncompleted = CompletableDeferred<Foo>() // this Deferred<Foo> will never complete
foo(uncompleted)
}
一方同じ処理をrunBlockingTestで書くと以下のようになり即座にテストが通る、またDelayControllerによりCoroutineDispatcherのvirtual timeを変更することでtimeOutが絡むテストが容易に記述できる。
// success
@ExperimentalCoroutinesApi
@Test(expected = FooException::class)
fun testFooWithTimeout() = runBlockingTest {
val uncompleted = CompletableDeferred<Foo>()
launch {
foo(uncompleted)
}
advanceTimeBy(5_000)
uncompleted.complete(Foo("bar"))
}
// failure
@ExperimentalCoroutinesApi
@Test(expected = TitleRefreshError::class)
fun testFooWithTimeout() = runBlockingTest {
val uncompleted = CompletableDeferred<Foo>()
launch {
foo(uncompleted)
}
advanceTimeBy(6_000)
uncompleted.complete(Foo("bar"))
}
補足
ここまでrunBlockingTestの用途について書いてみたが現状ExperimentalCoroutinesApiであることに加えて、いくつかクリティカルに思えるissueが上がっていたため導入については要検討という印象である。続報に期待したい。
AbstractMethodError when use withTimeout
[https://github.com/Kotlin/kotlinx.coroutines/issues/2307:title]
2021/01/18時点で最新版1.4.2のkotlinx-coroutines-testを利用すると発生するエラー
Provided example test for withTimeout fails
[https://github.com/Kotlin/kotlinx.coroutines/issues/1390:title]
ReadMeのExample[5]をそのまま書くと発生するエラー
Replace TimeoutCancellationException with TimeoutException
上述のsuspend fun foo
でTimeoutExceptionをそのままthrowせずにtry/catchしてFooExceptionを投げ直している理由。TimeoutCancellationException is CancellationException, thus is never reported.
とのこと。
[https://github.com/Kotlin/kotlinx.coroutines/issues/1374:title]
Use Kotlin Coroutines in your Android App
[https://developer.android.com/codelabs/kotlin-coroutines#9:title]
runBlockingTest is experimental, and currently has a bug that makes it fail the test if a coroutine switches to a dispatcher that executes a coroutine on another thread. The final stable is not expected to have this bug.
参考
[2]
[https://www.youtube.com/watch?v=KMb0Fs8rCRs&t=469s&ab_channel=AndroidDevelopers:embed:cite]
[3]
[https://www.youtube.com/watch?v=hMFwNLVK8HU&feature=emb_title&ab_channel=JetBrainsTV:embed:cite]