Skip to content

Commit 149da18

Browse files
committed
Implement fake datastore for unit tests (#6874)
Implement fake datastore for unit tests. This fake can act like a simple in memory datastore, but it can also throw provided exceptions on specific actions. It can throw on update, throw on collect, throw on init. This will help write unit tests for when datastore fails.
1 parent 22438cd commit 149da18

File tree

2 files changed

+218
-0
lines changed

2 files changed

+218
-0
lines changed
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
/*
2+
* Copyright 2025 Google LLC
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.google.firebase.sessions
18+
19+
import androidx.test.ext.junit.runners.AndroidJUnit4
20+
import com.google.common.truth.Truth.assertThat
21+
import com.google.firebase.sessions.testing.FakeDataStore
22+
import java.io.IOException
23+
import kotlinx.coroutines.ExperimentalCoroutinesApi
24+
import kotlinx.coroutines.flow.catch
25+
import kotlinx.coroutines.flow.first
26+
import kotlinx.coroutines.launch
27+
import kotlinx.coroutines.test.runCurrent
28+
import kotlinx.coroutines.test.runTest
29+
import org.junit.Test
30+
import org.junit.runner.RunWith
31+
32+
/** Tests for the [FakeDataStore] implementation. */
33+
@OptIn(ExperimentalCoroutinesApi::class)
34+
@RunWith(AndroidJUnit4::class)
35+
internal class FakeDataStoreTest {
36+
@Test
37+
fun emitsProvidedValues() = runTest {
38+
val fakeDataStore = FakeDataStore(23)
39+
40+
val result = mutableListOf<Int>()
41+
42+
// Collect data into result list
43+
backgroundScope.launch { fakeDataStore.data.collect { result.add(it) } }
44+
45+
fakeDataStore.updateData { 1 }
46+
fakeDataStore.updateData { 2 }
47+
fakeDataStore.updateData { 3 }
48+
fakeDataStore.updateData { 4 }
49+
50+
runCurrent()
51+
52+
assertThat(result).containsExactly(23, 1, 2, 3, 4)
53+
}
54+
55+
@Test
56+
fun throwsProvidedExceptionOnEmit() = runTest {
57+
val fakeDataStore = FakeDataStore(23)
58+
59+
val result = mutableListOf<String>()
60+
backgroundScope.launch {
61+
fakeDataStore.data
62+
.catch { ex -> result.add(ex.message!!) }
63+
.collect { result.add(it.toString()) }
64+
}
65+
66+
fakeDataStore.updateData { 1 }
67+
fakeDataStore.throwOnNextEmit(IOException("oops"))
68+
69+
runCurrent()
70+
71+
assertThat(result).containsExactly("23", "1", "oops")
72+
}
73+
74+
@Test(expected = IndexOutOfBoundsException::class)
75+
fun throwsProvidedExceptionOnUpdateData() = runTest {
76+
val fakeDataStore = FakeDataStore(23)
77+
78+
fakeDataStore.updateData { 1 }
79+
fakeDataStore.throwOnNextUpdateData(IndexOutOfBoundsException("oops"))
80+
81+
// Expected to throw
82+
fakeDataStore.updateData { 2 }
83+
}
84+
85+
@Test(expected = IllegalArgumentException::class)
86+
fun throwsFirstProvidedExceptionOnCollect() = runTest {
87+
val fakeDataStore = FakeDataStore(23, IllegalArgumentException("oops"))
88+
89+
// Expected to throw
90+
fakeDataStore.data.collect {}
91+
}
92+
93+
@Test(expected = IllegalStateException::class)
94+
fun throwsFirstProvidedExceptionOnFirst() = runTest {
95+
val fakeDataStore = FakeDataStore(23, IllegalStateException("oops"))
96+
97+
// Expected to throw
98+
fakeDataStore.data.first()
99+
}
100+
101+
@Test
102+
fun consistentAfterManyUpdates() = runTest {
103+
val fakeDataStore = FakeDataStore(0)
104+
105+
var collectResult = 0
106+
backgroundScope.launch { fakeDataStore.data.collect { collectResult = it } }
107+
108+
var updateResult = 0
109+
// 100 is bigger than the channel buffer size so this will cause suspending
110+
repeat(100) { updateResult = fakeDataStore.updateData { it.inc() } }
111+
112+
runCurrent()
113+
114+
assertThat(collectResult).isEqualTo(100)
115+
assertThat(updateResult).isEqualTo(100)
116+
117+
fakeDataStore.close()
118+
}
119+
}
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
/*
2+
* Copyright 2025 Google LLC
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.google.firebase.sessions.testing
18+
19+
import androidx.datastore.core.DataStore
20+
import kotlinx.coroutines.DelicateCoroutinesApi
21+
import kotlinx.coroutines.channels.Channel
22+
import kotlinx.coroutines.channels.ClosedReceiveChannelException
23+
import kotlinx.coroutines.flow.Flow
24+
import kotlinx.coroutines.flow.flow
25+
26+
/** Fake [DataStore] that can act like an in memory data store, or throw provided exceptions. */
27+
@OptIn(DelicateCoroutinesApi::class)
28+
internal class FakeDataStore<T>(
29+
private val firstValue: T,
30+
private val firstThrowable: Throwable? = null,
31+
) : DataStore<T> {
32+
// The channel is buffered so data can be updated without blocking until collected
33+
// Default buffer size is 64. This makes unit tests more convenient to write
34+
private val channel = Channel<() -> T>(Channel.BUFFERED)
35+
private var value = firstValue
36+
37+
private var throwOnUpdateData: Throwable? = null
38+
39+
override val data: Flow<T> = flow {
40+
// If a first throwable is set, simply throw it
41+
// This is intended to simulate a failure on init
42+
if (firstThrowable != null) {
43+
throw firstThrowable
44+
}
45+
46+
// Otherwise, emit the first value
47+
emit(firstValue)
48+
49+
// Start receiving values on the channel, and emit them
50+
// The values are updated by updateData or throwOnNextEmit
51+
try {
52+
while (true) {
53+
// Invoke the lambda in the channel
54+
// Either emit the value, or throw
55+
emit(channel.receive().invoke())
56+
}
57+
} catch (_: ClosedReceiveChannelException) {
58+
// Expected when the channel is closed
59+
}
60+
}
61+
62+
override suspend fun updateData(transform: suspend (t: T) -> T): T {
63+
// Check for a throwable to throw on this call to update data
64+
val throwable = throwOnUpdateData
65+
if (throwable != null) {
66+
// Clear the throwable since it should only throw once
67+
throwOnUpdateData = null
68+
throw throwable
69+
}
70+
71+
// Apply the transformation and send it to the channel
72+
val transformedValue = transform(value)
73+
value = transformedValue
74+
if (!channel.isClosedForSend) {
75+
channel.send { transformedValue }
76+
}
77+
78+
return transformedValue
79+
}
80+
81+
/** Set an exception to throw on the next call to [updateData]. */
82+
fun throwOnNextUpdateData(throwable: Throwable) {
83+
throwOnUpdateData = throwable
84+
}
85+
86+
/** Set an exception to throw on the next emit. */
87+
suspend fun throwOnNextEmit(throwable: Throwable) {
88+
if (!channel.isClosedForSend) {
89+
channel.send { throw throwable }
90+
}
91+
}
92+
93+
/** Finish the test. */
94+
fun close() {
95+
// Close the channel to stop the flow from emitting more values
96+
// This might be needed if tests fail with UncompletedCoroutinesError
97+
channel.close()
98+
}
99+
}

0 commit comments

Comments
 (0)