ticktakclockの日記

技術ポエムを綴ったりします。GitHub idも同じです (@ticktakclock)

ViewModelのテストを書く

こんにちは、tkyです。

テスト、書いてますか? 僕はぼちぼち書いてます。

今日はViewModelのテストを書いてみたいと思います。

サンプリリポジトリです。サンプルではGitHubAPIにアクセスする簡単なサンプルにテストを書いてみた感じです。

github.com

本稿ではこの2つを扱いたいと思います。

  • LiveDataのアサート
  • Coroutinesを含むテスト

モックライブラリにはMockKを使っています。mockkの使い方については触れません。

LiveDataのアサート

例えば『View(ActivityやFragment)にデータを渡せているか』をテストしたいときに用います。

        val repository = mockk<ProjectRepository>()
        coEvery { repository.getProjects(any()) } returns emptyList()

        val observer = mockk<Observer<List<Project>>>(relaxed = true)
        val viewModel = ProjectListViewModel(repository, testDispatcherProvider)
        viewModel.projects.observeForever(observer)

        viewModel.onResume()

        verify { observer.onChanged(emptyList()) }

ポイントはここ。mockkで作成したObserverをLiveDataに設定します。

        val observer = mockk<Observer<List<Project>>>(relaxed = true)
        viewModel.projects.observeForever(observer)

LiveDataに値が設定されたときにobserver.onChange()が呼ばれることを利用してこのonChange()をアサートします。

        verify { observer.onChanged(emptyList()) }

Coroutinesを含むテスト

ほぼCoroutinesを使うケースになるかと思いますが、

  • CoroutinesTestRuleを作成
  • TestCoroutineScopeでテストを実行

という流れでやっていきます。

@ExperimentalCoroutinesApi
class CoroutinesTestRule(
    val testDispatcher: TestCoroutineDispatcher = TestCoroutineDispatcher()
) : TestWatcher() {
    override fun starting(description: Description?) {
        super.starting(description)
        Dispatchers.setMain(testDispatcher)
    }

    override fun finished(description: Description?) {
        super.finished(description)
        Dispatchers.resetMain()
        testDispatcher.cleanupTestCoroutines()
    }
}
     // このPrivate functionを作っておくと `coroutinesTestRule.testDispatcher.runBlockingTest` を毎回書かなくて良くて便利です
    private fun test(block: suspend TestCoroutineScope.() -> Unit) =
        coroutinesTestRule.testDispatcher.runBlockingTest {
            block()
        }

    @Test
    fun onResume_repositoryのAPIが叩ける() = test {
        val repository = mockk<ProjectRepository>()
        coEvery { repository.getProjects(any()) } returns emptyList()

        val viewModel = ProjectListViewModel(repository, testDispatcherProvider)
        viewModel.onResume()

        coVerify(exactly = 1) { repository.getProjects(any()) }
    }

MockkでCoroutines用のモック(coEvery)とアサーション(coVerify)を使うだけです。 上記の場合『ライフサイクルのonResume()が呼ばれたとき、repository.getProjects()が呼ばれることをアサート』することになります。

ポイントはViewModelのコンストラクタにCoroutinesのDispatcherをDIしていることです。

ViewModelではこの様になっています。

    fun onResume() {
        viewModelScope.launch(dispatcherProvider.io()) {  //  普通はviewModelScope.launch(Dispatchers.IO)  で良いのだが・・・
            try {
                val response = repository.getProjects("ticktakclock")
                _projects.value = response
            } catch (e: Exception) {
                e.printStackTrace()
                _projects.value = emptyList()
            }
        }
    }

なぜこのようなことをしているのかというと、 テスト用のコルーチンスコープで動くIOスレッドとViewModelで動くDispatchers.IOのスレッドが異なるため、 テストのcoVerify{}が正しく動作しないことがあるためです。LiveDataも同様に正しく動作しないことがあります。

理由については上記の通りで、図解するとこのようなイメージです。

f:id:ticktakclock:20200818042912p:plain:w500

この図解はAndroid Dev Summit '19にて説明されたもので、詳しくはこちらのアーカイブを見ていただいたらよいかと思います。 www.youtube.com

まとめ

  • livedataとCoroutinesのテストがかけるようになった
  • Coroutinesのテストのテストを見越してDispatcherもDIしておくと後々楽になるかも