Blog

テスト用Dispatcherの使い分け【Kotlin Coroutines】

この記事はKotlin Advent Calendar 2022の18日目の記事です。

Kotlin Coroutinesを使ったコードのテストを書く場合、Dispatcherを差し替えることで仮想時間を使い、実際より短い時間でテストを実行したり、実行のタイミングを細かく制御することができます。

テスト用のDispatcherとして、 StandardTestDispatcherUnconfinedTestDispatcher の2つが用意されています。

今回はこの2つの違いと使い分けについて紹介します。

テスト用Dispatcher

CoroutineDispatcherCoroutineContextの要素の一つで、主に実行スレッドの制御を行います。

本番環境ではUI用のDispatchers.MainやIO用のDispatchers.IO等を状況に合わせて利用します。

テスト時はDispatcherStandardTestDispatcherUnconfinedTestDispatcherのどちらかを選んで利用します。

どちらを利用した場合も、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

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

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に対するテスト

以下のような単純な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を使う形で良いと思います。

ViewModelに対するテスト

以下のようにボタンクリック時に値を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を使っている場合、DispatcherDispatchers.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を使ったほうがシンプルに書けます。

viewModelScopeDispatchers.Main.immediateが使われており、本番環境でもlaunchは即座に起動されます。

そのため、UnconfinedTestDispatcherを使ったほうがより本番環境に近いテストができるとも言えるでしょう。

Googleが出しているサンプルアプリ、Now In Androidでも UnconfinedTestDispatcher を積極的に使っているようでした。(参考: MainDispatcherRule, TestDispatcherModule)

まとめ

StandardTestDispatcherUnconfinedTestDispatcher の違いと使い方を見てきました。

そこまで大きな差はありませんが、基本的にUnconfinedTestDispatcherを使っていくほうがシンプルにテストが書けます。

StandardTestDispatcherを使うとより細かく実行順序を制御できますが、そこまで必要になるケースはほとんど無いと思います。

ぜひ参考にしてみてください!

人気の記事

kotlin coroutinesのFlow, SharedFlow, StateFlowを整理する

Jetpack ComposeとKotlin Coroutinesを連携させる

Layout Composableを使って複雑なレイアウトを組む【Jetpack Compose】

Jetpack ComposeのRippleエフェクトを深堀り、カスタマイズも

Jetpack ComposeとViewModelについて考える

Flow.combineの内部実装がすごい話