From fc225be2c23609ed85943167f4f8a6af73e134d5 Mon Sep 17 00:00:00 2001 From: Matthew Robertson Date: Tue, 15 Apr 2025 09:55:48 -0400 Subject: [PATCH 1/2] Implement fake datastore for unit tests --- .../firebase/sessions/FakeDataStoreTest.kt | 123 ++++++++++++++++++ .../sessions/testing/FakeDataStore.kt | 98 ++++++++++++++ 2 files changed, 221 insertions(+) create mode 100644 firebase-sessions/src/test/kotlin/com/google/firebase/sessions/FakeDataStoreTest.kt create mode 100644 firebase-sessions/src/test/kotlin/com/google/firebase/sessions/testing/FakeDataStore.kt diff --git a/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/FakeDataStoreTest.kt b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/FakeDataStoreTest.kt new file mode 100644 index 00000000000..bb8c3e77172 --- /dev/null +++ b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/FakeDataStoreTest.kt @@ -0,0 +1,123 @@ +/* + * Copyright 2025 Google LLC + * + * 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 + * + * http://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.google.firebase.sessions + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat +import com.google.firebase.sessions.testing.FakeDataStore +import java.io.IOException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.junit.runner.RunWith + +/** Tests for the [FakeDataStore] implementation. */ +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(AndroidJUnit4::class) +internal class FakeDataStoreTest { + @Test + fun emitsProvidedValues() = runTest { + val fakeDataStore = FakeDataStore(23) + + val result = mutableListOf() + + // Collect data into result list + CoroutineScope(coroutineContext).launch { fakeDataStore.data.collect { result.add(it) } } + + fakeDataStore.updateData { 1 } + fakeDataStore.updateData { 2 } + fakeDataStore.updateData { 3 } + fakeDataStore.updateData { 4 } + + runCurrent() + + assertThat(result).containsExactly(23, 1, 2, 3, 4) + + fakeDataStore.close() + } + + @Test + fun throwsProvidedExceptionOnEmit() = runTest { + val fakeDataStore = FakeDataStore(23) + + val result = mutableListOf() + CoroutineScope(coroutineContext).launch { + fakeDataStore.data + .catch { ex -> result.add(ex.message!!) } + .collect { result.add(it.toString()) } + } + + fakeDataStore.updateData { 1 } + fakeDataStore.throwOnNextEmit(IOException("oops")) + + runCurrent() + + assertThat(result).containsExactly("23", "1", "oops") + + fakeDataStore.close() + } + + @Test(expected = IndexOutOfBoundsException::class) + fun throwsProvidedExceptionOnUpdateData() = runTest { + val fakeDataStore = FakeDataStore(23) + + fakeDataStore.updateData { 1 } + fakeDataStore.throwOnNextUpdateData(IndexOutOfBoundsException("oops")) + + // Expected to throw + fakeDataStore.updateData { 2 } + } + + @Test(expected = IllegalArgumentException::class) + fun throwsFirstProvidedExceptionOnCollect() = runTest { + val fakeDataStore = FakeDataStore(23, IllegalArgumentException("oops")) + + // Expected to throw + fakeDataStore.data.collect {} + } + + @Test(expected = IllegalStateException::class) + fun throwsFirstProvidedExceptionOnFirst() = runTest { + val fakeDataStore = FakeDataStore(23, IllegalStateException("oops")) + + // Expected to throw + fakeDataStore.data.first() + } + + @Test + fun consistentAfterManyUpdates() = runTest { + val fakeDataStore = FakeDataStore(0) + + var collectResult = 0 + CoroutineScope(coroutineContext).launch { fakeDataStore.data.collect { collectResult = it } } + + var updateResult = 0 + repeat(100) { updateResult = fakeDataStore.updateData { it.inc() } } + + runCurrent() + + assertThat(collectResult).isEqualTo(100) + assertThat(updateResult).isEqualTo(100) + + fakeDataStore.close() + } +} diff --git a/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/testing/FakeDataStore.kt b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/testing/FakeDataStore.kt new file mode 100644 index 00000000000..6e2d460d49c --- /dev/null +++ b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/testing/FakeDataStore.kt @@ -0,0 +1,98 @@ +/* + * Copyright 2025 Google LLC + * + * 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 + * + * http://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.google.firebase.sessions.testing + +import androidx.datastore.core.DataStore +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.ClosedReceiveChannelException +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow + +/** Fake [DataStore] that can act like an in memory data store, or throw provided exceptions. */ +@OptIn(DelicateCoroutinesApi::class) +internal class FakeDataStore( + private val firstValue: T, + private val firstThrowable: Throwable? = null, +) : DataStore { + // The channel is buffered so data can be updated without blocking until collected + // Default buffer size is 64. This makes unit tests more convenient to write + private val channel = Channel<() -> T>(Channel.BUFFERED) + private var value = firstValue + + private var throwOnUpdateData: Throwable? = null + + override val data: Flow = flow { + // If a first throwable is set, simply throw it + // This is intended to simulate a failure on init + if (firstThrowable != null) { + throw firstThrowable + } + + // Otherwise, emit the first value + emit(firstValue) + + // Start receiving values on the channel, and emit them + // The values are updated by updateData or throwOnNextEmit + try { + while (true) { + // Invoke the lambda in the channel + // Either emit the value, or throw + emit(channel.receive().invoke()) + } + } catch (_: ClosedReceiveChannelException) { + // Expected when the channel is closed + } + } + + override suspend fun updateData(transform: suspend (t: T) -> T): T { + // Check for a throwable to throw on this call to update data + val throwable = throwOnUpdateData + if (throwable != null) { + // Clear the throwable since it should only throw once + throwOnUpdateData = null + throw throwable + } + + // Apply the transformation and send it to the channel + val transformedValue = transform(value) + value = transformedValue + if (!channel.isClosedForSend) { + channel.send { transformedValue } + } + + return transformedValue + } + + /** Set an exception to throw on the next call to [updateData]. */ + fun throwOnNextUpdateData(throwable: Throwable) { + throwOnUpdateData = throwable + } + + /** Set an exception to throw on the next emit. */ + suspend fun throwOnNextEmit(throwable: Throwable) { + if (!channel.isClosedForSend) { + channel.send { throw throwable } + } + } + + /** Finish the test. */ + fun close() { + // Close the channel to stop the flow from emitting more values + channel.close() + } +} From f743e04ddedddc7ab8752435d5ae89db240b2242 Mon Sep 17 00:00:00 2001 From: Matthew Robertson Date: Tue, 15 Apr 2025 10:22:46 -0400 Subject: [PATCH 2/2] Use backgroundScope for fake datastore tests --- .../google/firebase/sessions/FakeDataStoreTest.kt | 12 ++++-------- .../firebase/sessions/testing/FakeDataStore.kt | 1 + 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/FakeDataStoreTest.kt b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/FakeDataStoreTest.kt index bb8c3e77172..12a5e8f128e 100644 --- a/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/FakeDataStoreTest.kt +++ b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/FakeDataStoreTest.kt @@ -20,7 +20,6 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import com.google.common.truth.Truth.assertThat import com.google.firebase.sessions.testing.FakeDataStore import java.io.IOException -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.first @@ -41,7 +40,7 @@ internal class FakeDataStoreTest { val result = mutableListOf() // Collect data into result list - CoroutineScope(coroutineContext).launch { fakeDataStore.data.collect { result.add(it) } } + backgroundScope.launch { fakeDataStore.data.collect { result.add(it) } } fakeDataStore.updateData { 1 } fakeDataStore.updateData { 2 } @@ -51,8 +50,6 @@ internal class FakeDataStoreTest { runCurrent() assertThat(result).containsExactly(23, 1, 2, 3, 4) - - fakeDataStore.close() } @Test @@ -60,7 +57,7 @@ internal class FakeDataStoreTest { val fakeDataStore = FakeDataStore(23) val result = mutableListOf() - CoroutineScope(coroutineContext).launch { + backgroundScope.launch { fakeDataStore.data .catch { ex -> result.add(ex.message!!) } .collect { result.add(it.toString()) } @@ -72,8 +69,6 @@ internal class FakeDataStoreTest { runCurrent() assertThat(result).containsExactly("23", "1", "oops") - - fakeDataStore.close() } @Test(expected = IndexOutOfBoundsException::class) @@ -108,9 +103,10 @@ internal class FakeDataStoreTest { val fakeDataStore = FakeDataStore(0) var collectResult = 0 - CoroutineScope(coroutineContext).launch { fakeDataStore.data.collect { collectResult = it } } + backgroundScope.launch { fakeDataStore.data.collect { collectResult = it } } var updateResult = 0 + // 100 is bigger than the channel buffer size so this will cause suspending repeat(100) { updateResult = fakeDataStore.updateData { it.inc() } } runCurrent() diff --git a/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/testing/FakeDataStore.kt b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/testing/FakeDataStore.kt index 6e2d460d49c..1157a309917 100644 --- a/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/testing/FakeDataStore.kt +++ b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/testing/FakeDataStore.kt @@ -93,6 +93,7 @@ internal class FakeDataStore( /** Finish the test. */ fun close() { // Close the channel to stop the flow from emitting more values + // This might be needed if tests fail with UncompletedCoroutinesError channel.close() } }