diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 77f80c6d1..491008758 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -77,6 +77,7 @@ wearComposeMaterial3 = "1.5.0-rc02" wearOngoing = "1.0.0" wearToolingPreview = "1.0.0" webkit = "1.14.0" +turbine = "0.13.0" [libraries] accompanist-adaptive = "com.google.accompanist:accompanist-adaptive:0.37.3" @@ -189,6 +190,7 @@ play-services-wearable = { module = "com.google.android.gms:play-services-wearab validator-push = { module = "com.google.android.wearable.watchface.validator:validator-push", version.ref = "validatorPush" } wear-compose-material = { module = "androidx.wear.compose:compose-material", version.ref = "wearComposeMaterial" } wear-compose-material3 = { module = "androidx.wear.compose:compose-material3", version.ref = "wearComposeMaterial3" } +turbine = { module = "app.cash.turbine:turbine", version.ref = "turbine" } [plugins] android-application = { id = "com.android.application", version.ref = "androidGradlePlugin" } diff --git a/kotlin/build.gradle.kts b/kotlin/build.gradle.kts index 12feddf2d..d5e091dbb 100644 --- a/kotlin/build.gradle.kts +++ b/kotlin/build.gradle.kts @@ -58,4 +58,5 @@ dependencies { implementation(libs.kotlinx.coroutines.android) testImplementation(libs.kotlinx.coroutines.test) testImplementation(libs.junit) + testImplementation(libs.turbine) } diff --git a/kotlin/src/test/java/com/android/example/flow/test/MainDispatcherRule.kt b/kotlin/src/test/java/com/android/example/flow/test/MainDispatcherRule.kt new file mode 100644 index 000000000..6b27af9d2 --- /dev/null +++ b/kotlin/src/test/java/com/android/example/flow/test/MainDispatcherRule.kt @@ -0,0 +1,39 @@ +/* + * Copyright 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.example.flow.test + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestDispatcher +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.junit.rules.TestWatcher +import org.junit.runner.Description + +@ExperimentalCoroutinesApi +class MainDispatcherRule( + val testDispatcher: TestDispatcher = UnconfinedTestDispatcher(), +) : TestWatcher() { + override fun starting(description: Description) { + Dispatchers.setMain(testDispatcher) + } + + override fun finished(description: Description) { + Dispatchers.resetMain() + } +} diff --git a/kotlin/src/test/java/com/android/example/flow/test/RepositoryTest.kt b/kotlin/src/test/java/com/android/example/flow/test/RepositoryTest.kt new file mode 100644 index 000000000..272487ba3 --- /dev/null +++ b/kotlin/src/test/java/com/android/example/flow/test/RepositoryTest.kt @@ -0,0 +1,86 @@ +/* + * Copyright 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.example.flow.test + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Test + +private const val ITEM_1 = "item1" +private val ALL_MESSAGES = listOf("message1", "message2") + +interface MyRepository { + fun observeCount(): Flow + fun observeChatMessages(): Flow> +} + +// [START android_snippets_kotlin_flow_test_fake_repository] +class MyFakeRepository : MyRepository { + override fun observeCount() = flow { + emit(ITEM_1) + } + + override fun observeChatMessages(): Flow> = flow { + emit(ALL_MESSAGES) + } +} +// [END android_snippets_kotlin_flow_test_fake_repository] + +class MyUnitUnderTest(private val myRepository: MyRepository) + +class RepositoryTest { + + @Test + fun myTest() { + // [START android_snippets_kotlin_flow_test_my_test] + // Given a class with fake dependencies: + val sut = MyUnitUnderTest(MyFakeRepository()) + // Trigger and verify + // ... + // [END android_snippets_kotlin_flow_test_my_test] + } + + @Test + fun myRepositoryTest_first() = runTest { + // [START android_snippets_kotlin_flow_test_repository_test_first] + // Given a repository that combines values from two data sources: + val repository = MyFakeRepository() + + // When the repository emits a value + val firstItem = repository.observeCount().first() // Returns the first item in the flow + + // Then check it's the expected item + assertEquals(ITEM_1, firstItem) + // [END android_snippets_kotlin_flow_test_repository_test_first] + } + + @Test + fun myRepositoryTest_toList() = runTest { + // [START android_snippets_kotlin_flow_test_repository_test_to_list] + // Given a repository with a fake data source that emits ALL_MESSAGES + val repository = MyFakeRepository() + val messages = repository.observeChatMessages().toList() + + // When all messages are emitted then they should be ALL_MESSAGES + assertEquals(ALL_MESSAGES, messages[0]) + // [END android_snippets_kotlin_flow_test_repository_test_to_list] + } +} diff --git a/kotlin/src/test/java/com/android/example/flow/test/TurbineTest.kt b/kotlin/src/test/java/com/android/example/flow/test/TurbineTest.kt new file mode 100644 index 000000000..c8e8205bc --- /dev/null +++ b/kotlin/src/test/java/com/android/example/flow/test/TurbineTest.kt @@ -0,0 +1,44 @@ +/* + * Copyright 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.example.flow.test + +import app.cash.turbine.test +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Test + +class TurbineTest { + @Test + fun usingTurbine() = runTest { + // [START android_snippets_kotlin_flow_test_using_turbine] + val dataSource = FakeDataSource() + val repository = Repository(dataSource) + + repository.scores().test { + // Make calls that will trigger value changes only within test{} + dataSource.emit(1) + assertEquals(10, awaitItem()) + + dataSource.emit(2) + awaitItem() // Ignore items if needed, can also use skip(n) + + dataSource.emit(3) + assertEquals(30, awaitItem()) + } + // [END android_snippets_kotlin_flow_test_using_turbine] + } +} diff --git a/kotlin/src/test/java/com/android/example/flow/test/ViewModelTest.kt b/kotlin/src/test/java/com/android/example/flow/test/ViewModelTest.kt new file mode 100644 index 000000000..aa36234c1 --- /dev/null +++ b/kotlin/src/test/java/com/android/example/flow/test/ViewModelTest.kt @@ -0,0 +1,126 @@ +/* + * Copyright 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.example.flow.test + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test + +interface MyRepositoryVM { + fun scores(): Flow +} + +// [START android_snippets_kotlin_flow_test_my_view_model] +class MyViewModel(private val myRepository: MyRepositoryVM) : ViewModel() { + private val _score = MutableStateFlow(0) + val score: StateFlow = _score.asStateFlow() + + fun initialize() { + viewModelScope.launch { + myRepository.scores().collect { score -> + _score.value = score + } + } + } +} +// [END android_snippets_kotlin_flow_test_my_view_model] + +// [START android_snippets_kotlin_flow_test_fake_repository_viewmodel] +class FakeRepository : MyRepositoryVM { + private val flow = MutableSharedFlow() + suspend fun emit(value: Int) = flow.emit(value) + override fun scores(): Flow = flow +} +// [END android_snippets_kotlin_flow_test_fake_repository_viewmodel] + +@OptIn(ExperimentalCoroutinesApi::class) +class ViewModelTest { + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + // [START android_snippets_kotlin_flow_test_hot_fake_repository] + @Test + fun testHotFakeRepository() = runTest { + val fakeRepository = FakeRepository() + val viewModel = MyViewModel(fakeRepository) + + assertEquals(0, viewModel.score.value) // Assert on the initial value + + // Start collecting values from the Repository + viewModel.initialize() + + // Then we can send in values one by one, which the ViewModel will collect + fakeRepository.emit(1) + assertEquals(1, viewModel.score.value) + + fakeRepository.emit(2) + fakeRepository.emit(3) + assertEquals(3, viewModel.score.value) // Assert on the latest value + } + // [END android_snippets_kotlin_flow_test_hot_fake_repository] +} + +// [START android_snippets_kotlin_flow_test_my_view_model_with_state_in] +class MyViewModelWithStateIn(myRepository: MyRepositoryVM) : ViewModel() { + val score: StateFlow = myRepository.scores() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000L), 0) +} +// [END android_snippets_kotlin_flow_test_my_view_model_with_state_in] + +typealias HotFakeRepository = FakeRepository + +@OptIn(ExperimentalCoroutinesApi::class) +class LazilySharingViewModelTest { + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + // [START android_snippets_kotlin_flow_test_lazily_sharing_view_model] + @Test + fun testLazilySharingViewModel() = runTest { + val fakeRepository = HotFakeRepository() + val viewModel = MyViewModelWithStateIn(fakeRepository) + + // Create an empty collector for the StateFlow + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + viewModel.score.collect {} + } + + assertEquals(0, viewModel.score.value) // Can assert initial value + + // Trigger-assert like before + fakeRepository.emit(1) + assertEquals(1, viewModel.score.value) + + fakeRepository.emit(2) + fakeRepository.emit(3) + assertEquals(3, viewModel.score.value) + } + // [END android_snippets_kotlin_flow_test_lazily_sharing_view_model] +} diff --git a/kotlin/src/test/java/com/android/example/flow/test/fakes.kt b/kotlin/src/test/java/com/android/example/flow/test/fakes.kt new file mode 100644 index 000000000..9973288bb --- /dev/null +++ b/kotlin/src/test/java/com/android/example/flow/test/fakes.kt @@ -0,0 +1,39 @@ +/* + * Copyright 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.example.flow.test + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.map + +// [START android_snippets_kotlin_flow_test_repository_and_datasource] +interface DataSource { + fun counts(): Flow +} + +class Repository(private val dataSource: DataSource) { + fun scores(): Flow { + return dataSource.counts().map { it * 10 } + } +} + +class FakeDataSource : DataSource { + private val flow = MutableSharedFlow() + suspend fun emit(value: Int) = flow.emit(value) + override fun counts(): Flow = flow +} +// [END android_snippets_kotlin_flow_test_repository_and_datasource]