GANMA!のAndroidアプリの開発をしています。豊川です。
株式会社FLINTERSは2024年1月に10周年を迎えます。それを記念して全社員でブログリレーする企画を行なっています。こちらはその83日目のブログになります。
目的
Androidアプリ開発において、ViewModelのテストを書きたいが、どのように書けばいいかわからない、という方もいると思います。
その場合、Android にはNow in Android という公式が出しているサンプルのアプリがシンプルかつわかりやすいため、それを参照するのがおすすめなのですが、それでも初学者の方には慣れない書き方などが多くあり、読むのに苦労することがあります。
そこで本記事では、実際のテストコードを見ながら、そのテストコードで何が行われているのかを解説します。
本記事の目的は Now in AndroidのViewModelのテストコードで何が行われているかを理解できるようになることです。
具体的には下記のテストコードを理解できるようになることをゴールとしています
実際のファイル: ForYouViewModelTest
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()
}
}
上記のコードでは、
- MainDispatcherRuleで
-
override fun starting
でテスト実行前にMainスレッドでUnconfinedTestDispatcher
を使用するように設定 -
override fun finished
テスト実行後にMainスレッドで使うDispatcherを元に戻す - という設定を定義
-
- @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のコンストラクタを見てみると、用意しているクラスとコンストラクタの引数が一致していることがわかると思います。
@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
- test(Unit test)で使用される
テストケース
ようやくテストケースまで到達できました。
ここではstateIsLoadingWhenFollowedTopicsAreLoading
を見ていきましょう。
@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()
}
関数の定義
最初に関数の定義確認します。
@Test
fun stateIsLoadingWhenFollowedTopicsAreLoading() = runTest {
上記のコードはテストケースを定義しています。
@Test
はでこの関数がテストケースである、ということを定義し、runTest
は非同期処理のテストを行うために使用されるもので、テストで使用するコルーチンスコープを渡します。
collectJob
テストケースの中にcollectJob1
, collectJob2
というものがあります。
これはなぜ必要なのでしょうか?
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
テストケースで何をテストしているか
最後にこのテストケースで何をテストしているのかを確認します。
コードは下記です。
topicsRepository.sendTopics(sampleTopics)
assertEquals(
OnboardingUiState.Loading,
viewModel.onboardingUiState.value,
)
assertEquals(NewsFeedUiState.Loading, viewModel.feedState.value)
ここでは topicsRepository.sendTopics(sampleTopics)
が実行された場合に
- viewModel.onboardingUiStateが OnboardingUiState.Loadingとなり
- viewModel.feedState が NewsFeedUiState.Loadingとなる
ということを確認しています。
今回はForYouViewModelでtopicsRepositoryのFlowを購読しているため、TopicRepositoryの関数を実行していますが、一般的にはViewModelの関数を実行し、Stateが適切に切り替わる、と言うことをテストすることが多いと思います。
まとめ
テストの前準備
-
@get:Rule
でMainDispatcherRule
を使用し、テストケース内でUnconfinedTestDispatcher
を使用するよう設定、Kotlin Coroutineのテストを簡素化している -
@Before
アノテーションでテスト前にForYouViewModel
のインスタンスを生成。
テストケース
- 重要なのは、
StateFlow
の変化を監視するためにlaunch(UnconfinedTestDispatcher()) { viewModel.対象StateFlow.collect() }
を使用すること。
最後に
本記事を通じて、Now in Androidのテストで何をしているか理解する助けになれれば幸いです。