この記事はKotlin Advent Calendar 2022の18日目の記事です。
Kotlin Coroutinesを使ったコードのテストを書く場合、Dispatcherを差し替えることで仮想時間を使い、実際より短い時間でテストを実行したり、実行のタイミングを細かく制御することができます。
テスト用のDispatcherとして、 StandardTestDispatcher
と UnconfinedTestDispatcher
の2つが用意されています。
今回はこの2つの違いと使い分けについて紹介します。
CoroutineDispatcher
はCoroutineContext
の要素の一つで、主に実行スレッドの制御を行います。
本番環境ではUI用のDispatchers.Main
やIO用のDispatchers.IO
等を状況に合わせて利用します。
テスト時はDispatcher
をStandardTestDispatcher
とUnconfinedTestDispatcher
のどちらかを選んで利用します。
どちらを利用した場合も、TestCoroutineScheduler
が利用され、delay
等のテストも実際より短い時間で実行することができます。
suspend fun delayFunction(): Int {
delay(10_000) // 10秒待つ
return 1 + 1
}
@Test
fun test() = runTest(StandardTestDispatcher()) {
val result = delayFunction() // テスト時は10秒かからない
assertThat(result).isEqualTo(2)
}
runTest
では、特に指定しない限りStandardTestDispatcher
が使われます。
StandardTestDispatcher
を使った場合、起動したKotlin Coroutinesは即座に実行されず、保留状態になります。
@Test
fun test() = runTest(StandardTestDispatcher()) {
var x = 0
launch {
x = 1
delay(100)
x = 2
}
// launchは実行されてないのでxはまだ0
assertThat(x).isEqualTo(0)
}
advanceUntilIdle
を実行することで、保留されてるCoroutinesを全て実行することができます。
@Test
fun test() = runTest(StandardTestDispatcher()) {
var x = 0
launch {
x = 1
delay(100)
x = 2
}
// launchは実行されてないのでxはまだ0
assertThat(x).isEqualTo(0)
// 保留されているCoroutinesを全て実行する
advanceUntilIdle()
assertThat(x).isEqualTo(2)
}
他に、runCurrent
を使うことで現在保留中のCoroutinesのみを実行することができます。
@Test
fun test() = runTest(StandardTestDispatcher()) {
var x = 0
launch {
x = 1
delay(100)
x = 2
}
// launchは実行されてないのでxはまだ0
assertThat(x).isEqualTo(0)
// 現在保留中のCoroutinesのみを実行する
runCurrent()
assertThat(x).isEqualTo(1)
}
advanceTimeBy
を使うことで、指定時間まで実行することができます。
@Test
fun test() = runTest(StandardTestDispatcher()) {
var x = 0
launch {
x = 1
delay(100)
x = 2
}
// launchは実行されてないのでxはまだ0
assertThat(x).isEqualTo(0)
// 50ms後まで実行する
advanceTimeBy(50)
assertThat(x).isEqualTo(1)
}
UnconfinedTestDispatcher
を使った場合、起動したKotlin Coroutinesはできる限り実行しようとします。
@Test
fun test() = runTest(UnconfinedTestDispatcher()) {
var x = 0
launch {
x = 1
delay(100)
x = 2
}
// launchは起動され、x = 1まで実行される
assertThat(x).isEqualTo(1)
}
さらにx = 2
まで呼び出すには、StandardTestDispatcher
と同様advanceUntilIdle
等を使うことで実現できます。
@Test
fun test() = runTest(UnconfinedTestDispatcher()) {
var x = 0
launch {
x = 1
delay(100)
x = 2
}
// launchは起動され、x = 1まで実行される
assertThat(x).isEqualTo(1)
// 保留されているCoroutinesを全て実行する
advanceUntilIdle()
assertThat(x).isEqualTo(2)
}
それぞれの挙動の違いは理解できましたが、どのように使い分けるのが良いのでしょうか?
いくつか具体的な例をもとに考えてみます。
以下のような単純なsuspend function / Flowに対するテストは、StandardTestDispatcher
でもUnconfinedTestDispatcher
でも動作に変わりはありません。
suspend fun delayFunction(): Int {
delay(10_000)
return 1 + 1
}
@Test
fun test() = runTest(StandardTestDispatcher()) {
val actual = delayFunction()
assertThat(actual).isEqualTo(2)
}
fun Flow<*>.mapToString(): Flow<String> {
return map { it.toString() }
}
@Test
fun test() = runTest(StandardTestDispatcher()) {
val list = flowOf(1, 2, 3)
.mapToString()
.toList()
assertThat(list).isEqualTo(listOf("1", "2", "3"))
}
単純にrunTest
を呼び出し、デフォルトのStandardTestDispatcher
を使う形で良いと思います。
以下のようにボタンクリック時に値を1に更新し、さらに1秒後に2に更新するViewModelを考えます。
class SampleViewModel : ViewModel() {
private val _count = MutableStateFlow(0)
val count = _count.asStateFlow()
fun handleClick() {
viewModelScope.launch {
_count.value = 1
delay(1000)
_count.value = 2
}
}
}
viewModelScope
を使っている場合、Dispatcher
はDispatchers.setMain
を使って差し替えます。
StandardTestDispatcher
を使っている場合、値を1に更新するためにrunCurrent
を実行する必要があります。
@Test
fun test() {
val dispatcher = StandardTestDispatcher()
Dispatchers.setMain(dispatcher)
val viewModel = SampleViewModel()
viewModel.handleClick()
// 保留中のCoroutinesを実行する
dispatcher.scheduler.runCurrent()
assertThat(viewModel.count.value).isEqualTo(1)
dispatcher.scheduler.advanceTimeBy(1001)
assertThat(viewModel.count.value).isEqualTo(2)
}
UnconfinedTestDispatcher
を使えば、このrunCurrent
は省略することができます。
@Test
fun test() {
val dispatcher = UnconfinedTestDispatcher()
Dispatchers.setMain(dispatcher)
val viewModel = SampleViewModel()
viewModel.handleClick()
// dispatcher.scheduler.runCurrent()は不要
assertThat(viewModel.count.value).isEqualTo(1)
dispatcher.scheduler.advanceTimeBy(1001)
assertThat(viewModel.count.value).isEqualTo(2)
}
ViewModelのテストをする際に、起動したCoroutinesを保留して何かを確認することはないと思うので、UnconfinedTestDispatcher
を使ったほうがシンプルに書けます。
viewModelScope
はDispatchers.Main.immediate
が使われており、本番環境でもlaunch
は即座に起動されます。
そのため、UnconfinedTestDispatcher
を使ったほうがより本番環境に近いテストができるとも言えるでしょう。
Googleが出しているサンプルアプリ、Now In Androidでも UnconfinedTestDispatcher
を積極的に使っているようでした。(参考: MainDispatcherRule, TestDispatcherModule)
StandardTestDispatcher
と UnconfinedTestDispatcher
の違いと使い方を見てきました。
そこまで大きな差はありませんが、基本的にUnconfinedTestDispatcher
を使っていくほうがシンプルにテストが書けます。
StandardTestDispatcher
を使うとより細かく実行順序を制御できますが、そこまで必要になるケースはほとんど無いと思います。
ぜひ参考にしてみてください!