Blog

【Kotlin】suspend functionとテストの書き方

この記事は Kotlin Advent Calendar 2020 の15日目の記事です。

非同期処理を書く際に、kotlin coroutinesは使いやすく、非常に強力です。

一方で、単体テスト等を書くのには一定のハードルがあります。

今回は、suspend functionをテストしたり、モックする方法について紹介します。

時間のかかるsuspend functionをテストする

例えば、以下のような実行に10秒かかるsuspend functionがあったとします。

suspend fun sample(): Int {
    delay(10_000)
    return 10
}

これをテストする際に、runBlocking等を使ってしまうと、テストの実行に10秒かかってしまいます。

@Test
fun test() {
    val actual = runBlocking {
        sample()
    }
    assertThat(actual).isEqualTo(10)
}

こういったテストをたくさん書いていくと、テストの実行に時間がかかるようになってしまうため、テストは即座に完了することが望ましいとされています。

そのため、kotlin coroutinesにはkotlinx-coroutines-testというテストツールが用意されています。

このように依存関係を追加してください。

versionは使っているkotlin coroutinesのversionと合わせてください。

dependencies {
    testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:{version}'
}

そうすると、runBlockingTestが利用できるようになり、テストを即座に実行することができるようになります。

@Test
fun test() {
    runBlockingTest {
        val actual = sample()
        assertThat(actual).isEqualTo(10)
    }
}

中でcoroutinesをlaunchするメソッドをテストする

例えば、このように sample メソッドを呼び出した10秒後に isCalled の値を書き換えるクラスが合ったとします。

class Sample {
    private val dispatcher = Dispatchers.Main.immediate
    private val scope = CoroutineScope(dispatcher)

    var isCalled: Boolean = false

    fun sample() {
        scope.launch {
            delay(10_000)
            isCalled = true
        }
    }
}

このクラスをテストするためには、setMainをつかってDispatchers.MainTestCoroutineDispatcher差し替えることで、時間をかけずに正しくテストすることができます。

DelayController.advanceTimeByを使うことで、時間を操作することができます。

@ExperimentalCoroutinesApi
class SampleTest {
    private val dispatcher = TestCoroutineDispatcher()

    @Before
    fun setup() {
        Dispatchers.setMain(dispatcher)
    }

    @After
    fun clear() {
        Dispatchers.resetMain()
    }

    @Test
    fun test() {
        val sample = Sample()
        sample.sample()
        assertThat(sample.isCalled).isFalse()
        dispatcher.advanceTimeBy(10_000)
        assertThat(sample.isCalled).isTrue()
    }
}

DelayController.advanceUntilIdleを使うことで、保留中のタスクがすべて終わるまで待機してくれるので、こちらの書き方でも良いでしょう。

@Test
fun test() {
    val sample = Sample()
    sample.sample()
    assertThat(sample.isCalled).isFalse()
    dispatcher.advanceUntilIdle()
    assertThat(sample.isCalled).isTrue()
}

AndroidにおけるViewModelScopeも同じ方法で差し替えることができます。

以下のようなViewModelも上記のテストでカバーできます。

class SampleViewModel : ViewModel() {
    var isCalled: Boolean = false

    fun sample() {
        viewModelScope.launch {
            delay(10_000)
            isCalled = true
        }
    }
}

Dispatchers.Default等を使ってる場合

以下のように、Dispatchers.Main 以外のcontextで起動してる場合、上記のテストは動きません。

class Sample {
    private val scope = CoroutineScope(EmptyCoroutineContext)

    var isCalled: Boolean = false

    fun sample() {
        scope.launch(Dispatchers.Default) {
            delay(10_000)
            isCalled = true
        }
    }
}

こういった場合は、現状contextをDIできるようにしておく必要がありそうです。

class Sample(
    private val defaultDispatcher: CoroutineDispatcher = Dispatchers.Default
) {
    private val scope = CoroutineScope(EmptyCoroutineContext)

    var isCalled: Boolean = false

    fun sample() {
        scope.launch(defaultDispatcher) {
            delay(10_000)
            isCalled = true
        }
    }
}
@Test
fun test() {
    val sample = Sample(dispatcher)
    sample.sample()
    assertThat(sample.isCalled).isFalse()
    dispatcher.advanceTimeBy(10_000)
    assertThat(sample.isCalled).isTrue()
}

suspend functionをモックする

mockkを使うことで、suspend functionをモックすることができます。

dependencies {
    testImplementation "io.mockk:mockk:{version}"
}

例えば以下のような、DataSource から取得した値に +1 するクラスが合ったとします。

class Sample(
    private val dataSource: DataSource
) {
    suspend fun sample(): Int {
        return dataSource.getData() + 1
    }
}

interface DataSource {
    suspend fun getData(): Int
}

この DataSource はモックしつつ、Sampleのテストを書きたい場合、coEveryを使ってこのように書くことができます。

@Test
fun test() {
    runBlockingTest {
        val dataSource: DataSource = mockk()

        // `dataSource.getData()` の返り値を定義
        coEvery {
            dataSource.getData()
        } returns 10

        val sample = Sample(dataSource)
        val actual = sample.sample()
        assertThat(actual).isEqualTo(11)
    }
}

coVerify を使って、メソッドが呼び出されたかのテストをすることもできます。

@Test
fun test() {
    runBlockingTest {
        val dataSource: DataSource = mockk()

        coEvery {
            dataSource.getData()
        } returns 10

        val sample = Sample(dataSource)
        sample.sample()

        // 1回 `dataSource.getData()` が呼び出されたことを確認
        coVerify(exactly = 1) {
            dataSource.getData()
        }
    }
}

時間がかかるsuspend functionを定義する

以下のような1秒たったらtimeoutし、-1を返すメソッドをテストしたいとします。

class Sample(
    private val dataSource: DataSource
) {
    suspend fun sample(): Int {
        return withTimeoutOrNull(1_000) {
            dataSource.getData() + 1
        } ?: -1
    }
}

以下のように、coAnswers を使うことで時間のかかるsuspend functionを定義することができます。

@Test
fun test() {
    runBlockingTest {
        val dataSource: DataSource = mockk()

        // 5秒後に値を返すよう定義
        coEvery {
            dataSource.getData()
        } coAnswers {
            delay(5_000)
            10
        }

        val sample = Sample(dataSource)
        
        val actual = sample.sample()
        assertThat(actual).isEqualTo(-1)
    }
}

以下のようにDelayController.currentTimeを使うことで、かかった時間を測定することもできます。

@Test
fun test() {
    runBlockingTest {
        val dataSource: DataSource = mockk()

        // 5秒後に値を返すよう定義
        coEvery {
            dataSource.getData()
        } coAnswers {
            delay(5_000)
            10
        }

        val sample = Sample(dataSource)
        val startTime = currentTime
        sample.sample()
        val endTime = currentTime

        // 1秒しか経ってないこと確認
        assertThat(endTime - startTime).isEqualTo(1_000)
    }
}

まとめ

今回はsuspend functionに関するテストの書き方を紹介しました。

本来時間がかかる処理であっても、テストでは必ず即座に実行されるよう工夫することで、テストにかかる時間を短縮することができます。

また、以前flowのテストについて紹介しています。よかったら参考にしてみてください。

kotlin coroutines flowのテストを快適に書く

非同期処理は難しく、またそれに対するテストも複雑になりがちです。

担保したいことは何なのかを意識しつつ、適切な粒度で書いていくことが大事だと考えています。

- 2021/01/31追記-

Kotlin Coroutinesの解説本をZennにて販売しています。より詳しく学びたい方は、こちらも合わせて確認してみて下さい。

詳解 Kotlin Coroutines [2021] | Zenn

人気の記事

Jetpack Compose, React, Flutter, SwiftUIを比較する

Jetpack ComposeとViewModelについて考える

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

ViewModelでString resourcesを扱いたい

Jetpack Composeのコンポーネントはなぜ返り値がないのか

MVVMでモデルに処理を寄せる【Android】