Now in Android の ViewModelのテストコードを読めるようになろう

GANMA!のAndroidアプリの開発をしています。豊川です。

株式会社FLINTERSは2024年1月に10周年を迎えます。それを記念して全社員でブログリレーする企画を行なっています。こちらはその83日目のブログになります。

## 目的
Androidアプリ開発において、ViewModelのテストを書きたいが、どのように書けばいいかわからない、という方もいると思います。

その場合、Android にはNow in Android という公式が出しているサンプルのアプリがシンプルかつわかりやすいため、それを参照するのがおすすめなのですが、それでも初学者の方には慣れない書き方などが多くあり、読むのに苦労することがあります。

そこで本記事では、実際のテストコードを見ながら、そのテストコードで何が行われているのかを解説します。

本記事の目的は [Now in Android](https://github.com/android/nowinandroid)のViewModelのテストコードで何が行われているかを理解できるようになることです。
 
具体的には下記のテストコードを理解できるようになることをゴールとしています

実際のファイル: [ForYouViewModelTest](https://github.com/android/nowinandroid/blob/main/feature/foryou/src/test/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/ForYouViewModelTest.kt)

```kotlin:ForYouViewModelTest.kt
class ForYouViewModelTest {
    @get:Rule
    val mainDispatcherRule = MainDispatcherRule()

    private val syncManager = TestSyncManager()
    private val analyticsHelper = TestAnalyticsHelper()
    private val userDataRepository = TestUserDataRepository()
    private val topicsRepository = TestTopicsRepository()
    private val newsRepository = TestNewsRepository()
    private val userNewsResourceRepository = CompositeUserNewsResourceRepository(
        newsRepository = newsRepository,
        userDataRepository = userDataRepository,
    )

    private val getFollowableTopicsUseCase = GetFollowableTopicsUseCase(
        topicsRepository = topicsRepository,
        userDataRepository = userDataRepository,
    )
    private val savedStateHandle = SavedStateHandle()
    private lateinit var viewModel: ForYouViewModel

    @Before
    fun setup() {
        viewModel = ForYouViewModel(
            syncManager = syncManager,
            savedStateHandle = savedStateHandle,
            analyticsHelper = analyticsHelper,
            userDataRepository = userDataRepository,
            userNewsResourceRepository = userNewsResourceRepository,
            getFollowableTopics = getFollowableTopicsUseCase,
        )
    }

    // region ここからはテストケース
    @Test
    fun stateIsInitiallyLoading() = runTest {
        assertEquals(
            OnboardingUiState.Loading,
            viewModel.onboardingUiState.value,
        )
        assertEquals(NewsFeedUiState.Loading, viewModel.feedState.value)
    }

    @Test
    fun stateIsLoadingWhenFollowedTopicsAreLoading() = runTest {
        val collectJob1 =
            launch(UnconfinedTestDispatcher()) { viewModel.onboardingUiState.collect() }
        val collectJob2 = launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() }

        topicsRepository.sendTopics(sampleTopics)

        assertEquals(
            OnboardingUiState.Loading,
            viewModel.onboardingUiState.value,
        )
        assertEquals(NewsFeedUiState.Loading, viewModel.feedState.value)

        collectJob1.cancel()
        collectJob2.cancel()
    }

    // 以下省略
    ...

    // endregion
}

// region テスト用の固定値のデータを用意している
private val sampleTopics = listOf(
    ...省略
)

private val sampleNewsResources = listOf(
    ...省略
)
// endregion
```

それではコードを読んでいきましょう。


## テスト実行前の準備
テストクラスではまず、テストの実行前に準備するものがあります。  

今回のテストクラスでは`@get:Rule`, `@Before`がテストクラスの準備にあたるため、そこを確認していきます。

### @get:Rule

```kotlin:
class ForYouViewModelTest {
    @get:Rule
    val mainDispatcherRule = MainDispatcherRule()
    ... 省略
}
```

上記のコードで行なっていることは、テストケース実行時にKotlin CoroutineのDispatcherをUnconfinedTestDispatcherを使用する、ということを`@get:Rule`で設定する、と言うものです。
Dispatcherは非同期処理をどこで実行するか、というのを表すコンポーネントです。

コードをより詳細に確認していきましょう。
```kotlin:
class ForYouViewModelTest {
    @get:Rule
    val mainDispatcherRule = MainDispatcherRule()
    ... 省略
}

class MainDispatcherRule(
    private val testDispatcher: TestDispatcher = UnconfinedTestDispatcher(),
) : TestWatcher() {
    override fun starting(description: Description) {
        Dispatchers.setMain(testDispatcher)
    }

    override fun finished(description: Description) {
        Dispatchers.resetMain()
    }
}
```

上記のコードでは、
1. MainDispatcherRuleで
	- `override fun starting`でテスト実行前にMainスレッドで`UnconfinedTestDispatcher`を使用するように設定
	- `override fun finished`テスト実行後にMainスレッドで使うDispatcherを元に戻す
	- という設定を定義
2. @get:Ruleでそれを適用する

ということをしています。
`UnconfinedTestDispatcher`を使用している理由は非同期処理がすぐに実行され、テストをシンプルにすることができるからです。  
詳細は下記の記事で確認できます。
[Android での Kotlin コルーチンのテスト#UnconfinedTestDispatcher](https://developer.android.com/kotlin/coroutines/test?hl=ja#unconfinedtestdispatcher)

:::note info
**`@Before`や`@After`ではだめなのか？**
`@Before`や`@After`でも同様の設定は可能です。
ただViewModelのTestではこの設定はほぼ必ず使用するため、`MainDispatcherRule`を作成し、共通化しているのだと思います。
:::

### @Before
次に`@Before`をみていきましょう。該当するコードは下記です。
```kotlin:
    @Before
    fun setup() {
        viewModel = ForYouViewModel(
            syncManager = syncManager,
            savedStateHandle = savedStateHandle,
            analyticsHelper = analyticsHelper,
            userDataRepository = userDataRepository,
            userNewsResourceRepository = userNewsResourceRepository,
            getFollowableTopics = getFollowableTopicsUseCase,
        )
    }
```

ここではテスト対象のクラスのインスタンスを生成しています。  
インスタンスの生成に必要なクラスの定義は下記で行なっています。

```kotlin:
    private val syncManager = TestSyncManager()
    private val analyticsHelper = TestAnalyticsHelper()
    private val userDataRepository = TestUserDataRepository()
    private val topicsRepository = TestTopicsRepository()
    private val newsRepository = TestNewsRepository()
    private val userNewsResourceRepository = CompositeUserNewsResourceRepository(
        newsRepository = newsRepository,
        userDataRepository = userDataRepository,
    )

    private val getFollowableTopicsUseCase = GetFollowableTopicsUseCase(
        topicsRepository = topicsRepository,
        userDataRepository = userDataRepository,
    )
    private val savedStateHandle = SavedStateHandle()
    private lateinit var viewModel: ForYouViewModel
```

ForYouViewModelのコンストラクタを見てみると、用意しているクラスとコンストラクタの引数が一致していることがわかると思います。
```kotlin:ForYouViewModel.kt
@HiltViewModel
class ForYouViewModel @Inject constructor(
    private val savedStateHandle: SavedStateHandle,
    syncManager: SyncManager,
    private val analyticsHelper: AnalyticsHelper,
    private val userDataRepository: UserDataRepository,
    userNewsResourceRepository: UserNewsResourceRepository,
    getFollowableTopics: GetFollowableTopicsUseCase,
) : ViewModel() {
... 省略
```

上記の`ForYouViewModel`のコンストラクタとテストコードのフィールドを比較すると、テストコードのフィールドに定義されているものがコンストラクタの引数と一致していることが分かります。

:::note info
**Test? Fake?**
Now in Android を読んでいると、prefix(クラス名の一番最初)に`Test`, `Fake`がついているクラスがいくつか出てきます。
これは testdouble と呼ばれるもので、テストを書く際に使用されるものです。

詳しい説明はここでは省きますが、興味がある方は下記の記事を参考にしてみてください
- https://qiita.com/kaleidot725/items/075934e8e6be902a7fe1

一見すると `Test`, `Fake`の使い分けがわかりづらいですが、Now in Android では 下記のように使い分けられています。
- Fake
  - androidTestで使われる testdouble
  - HiltによってDIされる
    - `core/data-test/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/test/TestDataModule.kt`
- Test
  - test(Unit test)で使用される
    - XXXViewModelTest.ktや XXXUseCaseTest.ktなどで使われる
  - Testクラスでインスタンス化される
    - `feature/foryou/src/test/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/ForYouViewModelTest.kt`
:::

## テストケース
ようやくテストケースまで到達できました。
ここでは`stateIsLoadingWhenFollowedTopicsAreLoading`を見ていきましょう。

```kotlin: ForYouViewModel.kt
    @Test
    fun stateIsLoadingWhenFollowedTopicsAreLoading() = runTest {
        val collectJob1 =
            launch(UnconfinedTestDispatcher()) { viewModel.onboardingUiState.collect() }
        val collectJob2 = launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() }

        topicsRepository.sendTopics(sampleTopics)

        assertEquals(
            OnboardingUiState.Loading,
            viewModel.onboardingUiState.value,
        )
        assertEquals(NewsFeedUiState.Loading, viewModel.feedState.value)

        collectJob1.cancel()
        collectJob2.cancel()
    }
```

### 関数の定義
最初に関数の定義確認します。
```kotlin: ForYouViewModel.kt
@Test
fun stateIsLoadingWhenFollowedTopicsAreLoading() = runTest {
```

上記のコードはテストケースを定義しています。
`@Test`はでこの関数がテストケースである、ということを定義し、`runTest`は非同期処理のテストを行うために使用されるもので、テストで使用するコルーチンスコープを渡します。

### collectJob
テストケースの中に`collectJob1`, `collectJob2`というものがあります。
これはなぜ必要なのでしょうか？

```kotlin: ForYouViewModel.kt
val collectJob1 =
    launch(UnconfinedTestDispatcher()) { viewModel.onboardingUiState.collect() }
val collectJob2 = launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() }
```

上記のコードは一見テストケースと関係がないように見えますが、これがないとテストケースの中で`viewModel.onboardingUiState`や`viewModel.feedState`が適切に取得できなくなってしまいます。

詳細の説明は省きますが、StateFlowの変化をテストする場合は`launch(UnconfinedTestDispatcher()) { viewModel.テスト対象のStateFlow.collect() }`が必要、ということを覚えておきましょう。

詳細は下記で確認できます。
参考: https://developer.android.com/kotlin/flow/test#statein

### テストケースで何をテストしているか
最後にこのテストケースで何をテストしているのかを確認します。
コードは下記です。

```kotlin: ForYouViewModel.kt

topicsRepository.sendTopics(sampleTopics)

assertEquals(
    OnboardingUiState.Loading,
    viewModel.onboardingUiState.value,
)
assertEquals(NewsFeedUiState.Loading, viewModel.feedState.value)
```

ここでは `topicsRepository.sendTopics(sampleTopics)`が実行された場合に
1. viewModel.onboardingUiStateが OnboardingUiState.Loadingとなり
2. viewModel.feedState が NewsFeedUiState.Loadingとなる

ということを確認しています。

:::note info
今回はForYouViewModelでtopicsRepositoryのFlowを購読しているため、TopicRepositoryの関数を実行していますが、一般的にはViewModelの関数を実行し、Stateが適切に切り替わる、と言うことをテストすることが多いと思います。
:::

## まとめ
### テストの前準備
- `@get:Rule`で`MainDispatcherRule`を使用し、テストケース内で`UnconfinedTestDispatcher`を使用するよう設定、Kotlin Coroutineのテストを簡素化している
- `@Before` アノテーションでテスト前に`ForYouViewModel`のインスタンスを生成。

### テストケース
- 重要なのは、`StateFlow` の変化を監視するために `launch(UnconfinedTestDispatcher()) { viewModel.対象StateFlow.collect() }` を使用すること。

## 最後に
本記事を通じて、Now in Androidのテストで何をしているか理解する助けになれれば幸いです。