Skip to content

Commit 4eb89fd

Browse files
committed
Add snippets for testing Kotlin Flows
This commit adds a series of snippets from the Kotlin Flow testing documentation. It includes examples of how to test Repositories and ViewModels that expose Flows and StateFlows. The snippets are organized into two new test files: - RepositoryTest.kt - ViewModelTest.kt A MainDispatcherRule is also introduced to handle setting the main dispatcher in tests. Region-Tag: android_snippets_kotlin_flow_test_fake_repository Region-Tag: android_snippets_kotlin_flow_test_my_test Region-Tag: android_snippets_kotlin_flow_test_repository_test_first Region-Tag: android_snippets_kotlin_flow_test_repository_test_to_list Region-Tag: android_snippets_kotlin_flow_test_repository_and_datasource Region-Tag: android_snippets_kotlin_flow_test_continuously_collect Region-Tag: android_snippets_kotlin_flow_test_using_turbine Region-Tag: android_snippets_kotlin_flow_test_my_view_model Region-Tag: android_snippets_kotlin_flow_test_fake_repository_viewmodel Region-Tag: android_snippets_kotlin_flow_test_hot_fake_repository Region-Tag: android_snippets_kotlin_flow_test_my_view_model_with_state_in Region-Tag: android_snippets_kotlin_flow_test_lazily_sharing_view_model
1 parent 97d93a1 commit 4eb89fd

File tree

5 files changed

+323
-0
lines changed

5 files changed

+323
-0
lines changed

gradle/libs.versions.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ wearComposeMaterial3 = "1.5.0-rc02"
7777
wearOngoing = "1.0.0"
7878
wearToolingPreview = "1.0.0"
7979
webkit = "1.14.0"
80+
turbine = "0.13.0"
8081

8182
[libraries]
8283
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
189190
validator-push = { module = "com.google.android.wearable.watchface.validator:validator-push", version.ref = "validatorPush" }
190191
wear-compose-material = { module = "androidx.wear.compose:compose-material", version.ref = "wearComposeMaterial" }
191192
wear-compose-material3 = { module = "androidx.wear.compose:compose-material3", version.ref = "wearComposeMaterial3" }
193+
turbine = { module = "app.cash.turbine:turbine", version.ref = "turbine" }
192194

193195
[plugins]
194196
android-application = { id = "com.android.application", version.ref = "androidGradlePlugin" }

kotlin/build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,4 +58,5 @@ dependencies {
5858
implementation(libs.kotlinx.coroutines.android)
5959
testImplementation(libs.kotlinx.coroutines.test)
6060
testImplementation(libs.junit)
61+
testImplementation(libs.turbine)
6162
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
/*
2+
* Copyright (C) 2025 The Android Open Source Project
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.android.example.flow.test
18+
19+
import kotlinx.coroutines.Dispatchers
20+
import kotlinx.coroutines.ExperimentalCoroutinesApi
21+
import kotlinx.coroutines.test.TestDispatcher
22+
import kotlinx.coroutines.test.UnconfinedTestDispatcher
23+
import kotlinx.coroutines.test.resetMain
24+
import kotlinx.coroutines.test.setMain
25+
import org.junit.rules.TestWatcher
26+
import org.junit.runner.Description
27+
28+
@ExperimentalCoroutinesApi
29+
class MainDispatcherRule(
30+
val testDispatcher: TestDispatcher = UnconfinedTestDispatcher(),
31+
) : TestWatcher() {
32+
override fun starting(description: Description) {
33+
Dispatchers.setMain(testDispatcher)
34+
}
35+
36+
override fun finished(description: Description) {
37+
Dispatchers.resetMain()
38+
}
39+
}
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
/*
2+
* Copyright (C) 2025 The Android Open Source Project
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.android.example.flow.test
18+
19+
import app.cash.turbine.test
20+
import kotlinx.coroutines.ExperimentalCoroutinesApi
21+
import kotlinx.coroutines.flow.Flow
22+
import kotlinx.coroutines.flow.MutableSharedFlow
23+
import kotlinx.coroutines.flow.first
24+
import kotlinx.coroutines.flow.flow
25+
import kotlinx.coroutines.flow.map
26+
import kotlinx.coroutines.flow.toList
27+
import kotlinx.coroutines.launch
28+
import kotlinx.coroutines.test.UnconfinedTestDispatcher
29+
import kotlinx.coroutines.test.runTest
30+
import org.junit.Assert.assertEquals
31+
import org.junit.Test
32+
33+
private const val ITEM_1 = "item1"
34+
private val ALL_MESSAGES = listOf("message1", "message2")
35+
36+
interface MyRepository {
37+
fun observeCount(): Flow<String>
38+
fun observeChatMessages(): Flow<List<String>>
39+
}
40+
41+
// [START android_snippets_kotlin_flow_test_fake_repository]
42+
class MyFakeRepository : MyRepository {
43+
override fun observeCount() = flow {
44+
emit(ITEM_1)
45+
}
46+
47+
override fun observeChatMessages(): Flow<List<String>> = flow {
48+
emit(ALL_MESSAGES)
49+
}
50+
}
51+
// [END android_snippets_kotlin_flow_test_fake_repository]
52+
53+
class MyUnitUnderTest(private val myRepository: MyRepository)
54+
55+
class RepositoryTest {
56+
57+
@Test
58+
fun myTest() {
59+
// [START android_snippets_kotlin_flow_test_my_test]
60+
// Given a class with fake dependencies:
61+
val sut = MyUnitUnderTest(MyFakeRepository())
62+
// Trigger and verify
63+
// ...
64+
// [END android_snippets_kotlin_flow_test_my_test]
65+
}
66+
67+
@Test
68+
fun myRepositoryTest_first() = runTest {
69+
// [START android_snippets_kotlin_flow_test_repository_test_first]
70+
// Given a repository that combines values from two data sources:
71+
val repository = MyFakeRepository()
72+
73+
// When the repository emits a value
74+
val firstItem = repository.observeCount().first() // Returns the first item in the flow
75+
76+
// Then check it's the expected item
77+
assertEquals(ITEM_1, firstItem)
78+
// [END android_snippets_kotlin_flow_test_repository_test_first]
79+
}
80+
81+
@Test
82+
fun myRepositoryTest_toList() = runTest {
83+
// [START android_snippets_kotlin_flow_test_repository_test_to_list]
84+
// Given a repository with a fake data source that emits ALL_MESSAGES
85+
val repository = MyFakeRepository()
86+
val messages = repository.observeChatMessages().toList()
87+
88+
// When all messages are emitted then they should be ALL_MESSAGES
89+
assertEquals(ALL_MESSAGES, messages[0])
90+
// [END android_snippets_kotlin_flow_test_repository_test_to_list]
91+
}
92+
}
93+
94+
// [START android_snippets_kotlin_flow_test_repository_and_datasource]
95+
interface DataSource {
96+
fun counts(): Flow<Int>
97+
}
98+
99+
class Repository(private val dataSource: DataSource) {
100+
fun scores(): Flow<Int> {
101+
return dataSource.counts().map { it * 10 }
102+
}
103+
}
104+
105+
class FakeDataSource : DataSource {
106+
private val flow = MutableSharedFlow<Int>()
107+
suspend fun emit(value: Int) = flow.emit(value)
108+
override fun counts(): Flow<Int> = flow
109+
}
110+
// [END android_snippets_kotlin_flow_test_repository_and_datasource]
111+
112+
@OptIn(ExperimentalCoroutinesApi::class)
113+
class ContinuousCollectionTest {
114+
@Test
115+
fun continuouslyCollect() = runTest {
116+
// [START android_snippets_kotlin_flow_test_continuously_collect]
117+
val dataSource = FakeDataSource()
118+
val repository = Repository(dataSource)
119+
120+
val values = mutableListOf<Int>()
121+
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
122+
repository.scores().toList(values)
123+
}
124+
125+
dataSource.emit(1)
126+
assertEquals(10, values[0]) // Assert on the list contents
127+
128+
dataSource.emit(2)
129+
dataSource.emit(3)
130+
assertEquals(30, values[2])
131+
132+
assertEquals(3, values.size) // Assert the number of items collected
133+
// [END android_snippets_kotlin_flow_test_continuously_collect]
134+
}
135+
136+
@Test
137+
fun usingTurbine() = runTest {
138+
// [START android_snippets_kotlin_flow_test_using_turbine]
139+
val dataSource = FakeDataSource()
140+
val repository = Repository(dataSource)
141+
142+
repository.scores().test {
143+
// Make calls that will trigger value changes only within test{}
144+
dataSource.emit(1)
145+
assertEquals(10, awaitItem())
146+
147+
dataSource.emit(2)
148+
awaitItem() // Ignore items if needed, can also use skip(n)
149+
150+
dataSource.emit(3)
151+
assertEquals(30, awaitItem())
152+
}
153+
// [END android_snippets_kotlin_flow_test_using_turbine]
154+
}
155+
}
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
/*
2+
* Copyright (C) 2025 The Android Open Source Project
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.android.example.flow.test
18+
19+
import androidx.lifecycle.ViewModel
20+
import androidx.lifecycle.viewModelScope
21+
import kotlinx.coroutines.ExperimentalCoroutinesApi
22+
import kotlinx.coroutines.flow.Flow
23+
import kotlinx.coroutines.flow.MutableSharedFlow
24+
import kotlinx.coroutines.flow.MutableStateFlow
25+
import kotlinx.coroutines.flow.SharingStarted
26+
import kotlinx.coroutines.flow.StateFlow
27+
import kotlinx.coroutines.flow.asStateFlow
28+
import kotlinx.coroutines.flow.stateIn
29+
import kotlinx.coroutines.launch
30+
import kotlinx.coroutines.test.UnconfinedTestDispatcher
31+
import kotlinx.coroutines.test.runTest
32+
import org.junit.Assert.assertEquals
33+
import org.junit.Rule
34+
import org.junit.Test
35+
36+
interface MyRepositoryVM {
37+
fun scores(): Flow<Int>
38+
}
39+
40+
// [START android_snippets_kotlin_flow_test_my_view_model]
41+
class MyViewModel(private val myRepository: MyRepositoryVM) : ViewModel() {
42+
private val _score = MutableStateFlow(0)
43+
val score: StateFlow<Int> = _score.asStateFlow()
44+
45+
fun initialize() {
46+
viewModelScope.launch {
47+
myRepository.scores().collect { score ->
48+
_score.value = score
49+
}
50+
}
51+
}
52+
}
53+
// [END android_snippets_kotlin_flow_test_my_view_model]
54+
55+
// [START android_snippets_kotlin_flow_test_fake_repository_viewmodel]
56+
class FakeRepository : MyRepositoryVM {
57+
private val flow = MutableSharedFlow<Int>()
58+
suspend fun emit(value: Int) = flow.emit(value)
59+
override fun scores(): Flow<Int> = flow
60+
}
61+
// [END android_snippets_kotlin_flow_test_fake_repository_viewmodel]
62+
63+
@OptIn(ExperimentalCoroutinesApi::class)
64+
class ViewModelTest {
65+
@get:Rule
66+
val mainDispatcherRule = MainDispatcherRule()
67+
68+
@Test
69+
fun testHotFakeRepository() = runTest {
70+
// [START android_snippets_kotlin_flow_test_hot_fake_repository]
71+
val fakeRepository = FakeRepository()
72+
val viewModel = MyViewModel(fakeRepository)
73+
74+
assertEquals(0, viewModel.score.value) // Assert on the initial value
75+
76+
// Start collecting values from the Repository
77+
viewModel.initialize()
78+
79+
// Then we can send in values one by one, which the ViewModel will collect
80+
fakeRepository.emit(1)
81+
assertEquals(1, viewModel.score.value)
82+
83+
fakeRepository.emit(2)
84+
fakeRepository.emit(3)
85+
assertEquals(3, viewModel.score.value) // Assert on the latest value
86+
// [END android_snippets_kotlin_flow_test_hot_fake_repository]
87+
}
88+
}
89+
90+
// [START android_snippets_kotlin_flow_test_my_view_model_with_state_in]
91+
class MyViewModelWithStateIn(myRepository: MyRepositoryVM) : ViewModel() {
92+
val score: StateFlow<Int> = myRepository.scores()
93+
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000L), 0)
94+
}
95+
// [END android_snippets_kotlin_flow_test_my_view_model_with_state_in]
96+
97+
typealias HotFakeRepository = FakeRepository
98+
99+
@OptIn(ExperimentalCoroutinesApi::class)
100+
class LazilySharingViewModelTest {
101+
@get:Rule
102+
val mainDispatcherRule = MainDispatcherRule()
103+
104+
@Test
105+
fun testLazilySharingViewModel() = runTest {
106+
// [START android_snippets_kotlin_flow_test_lazily_sharing_view_model]
107+
val fakeRepository = HotFakeRepository()
108+
val viewModel = MyViewModelWithStateIn(fakeRepository)
109+
110+
// Create an empty collector for the StateFlow
111+
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
112+
viewModel.score.collect {}
113+
}
114+
115+
assertEquals(0, viewModel.score.value) // Can assert initial value
116+
117+
// Trigger-assert like before
118+
fakeRepository.emit(1)
119+
assertEquals(1, viewModel.score.value)
120+
121+
fakeRepository.emit(2)
122+
fakeRepository.emit(3)
123+
assertEquals(3, viewModel.score.value)
124+
// [END android_snippets_kotlin_flow_test_lazily_sharing_view_model]
125+
}
126+
}

0 commit comments

Comments
 (0)