1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Posted at

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

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

目的

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

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

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

本記事の目的は Now in AndroidのViewModelのテストコードで何が行われているかを理解できるようになることです。

具体的には下記のテストコードを理解できるようになることをゴールとしています

実際のファイル: ForYouViewModelTest

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

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

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

コードをより詳細に確認していきましょう。

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

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

@Before

次に@Beforeをみていきましょう。該当するコードは下記です。

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

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

    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のコンストラクタを見てみると、用意しているクラスとコンストラクタの引数が一致していることがわかると思います。

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のコンストラクタとテストコードのフィールドを比較すると、テストコードのフィールドに定義されているものがコンストラクタの引数と一致していることが分かります。

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

詳しい説明はここでは省きますが、興味がある方は下記の記事を参考にしてみてください

一見すると 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を見ていきましょう。

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()
    }

関数の定義

最初に関数の定義確認します。

ForYouViewModel.kt
@Test
fun stateIsLoadingWhenFollowedTopicsAreLoading() = runTest {

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

collectJob

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

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

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

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

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

テストケースで何をテストしているか

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

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となる

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

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

まとめ

テストの前準備

  • @get:RuleMainDispatcherRuleを使用し、テストケース内でUnconfinedTestDispatcherを使用するよう設定、Kotlin Coroutineのテストを簡素化している
  • @Before アノテーションでテスト前にForYouViewModelのインスタンスを生成。

テストケース

  • 重要なのは、StateFlow の変化を監視するために launch(UnconfinedTestDispatcher()) { viewModel.対象StateFlow.collect() } を使用すること。

最後に

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

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?