diff --git a/lifecycle/lifecycle-runtime/build.gradle b/lifecycle/lifecycle-runtime/build.gradle index 76c6be98a0d33..86dfe01ea2073 100644 --- a/lifecycle/lifecycle-runtime/build.gradle +++ b/lifecycle/lifecycle-runtime/build.gradle @@ -70,6 +70,7 @@ kotlin { implementation(libs.kotlinCoroutinesTest) implementation(libs.kotlinTest) implementation(project(":kruth:kruth")) + implementation(libs.atomicFu) } } diff --git a/lifecycle/lifecycle-runtime/src/androidUnitTest/kotlin/androidx/lifecycle/runLifecycleTest.android.kt b/lifecycle/lifecycle-runtime/src/androidUnitTest/kotlin/androidx/lifecycle/runLifecycleTest.android.kt new file mode 100644 index 0000000000000..7fba4bf81076b --- /dev/null +++ b/lifecycle/lifecycle-runtime/src/androidUnitTest/kotlin/androidx/lifecycle/runLifecycleTest.android.kt @@ -0,0 +1,86 @@ +/* + * Copyright 2024 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 + * + * 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 androidx.lifecycle + +import kotlin.coroutines.CoroutineContext +import kotlin.coroutines.EmptyCoroutineContext +import kotlinx.coroutines.CloseableCoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.MainCoroutineDispatcher +import kotlinx.coroutines.Runnable +import kotlinx.coroutines.newSingleThreadContext +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.TestResult +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain + +/** + * Android Unit Tests target doesn't provide a Main dispatcher. + * Lifecycle internals rely on Main & Main.immediate dispatchers heavily, + * so we need to re-create their behavior in tests. + */ +@OptIn(ExperimentalCoroutinesApi::class, DelicateCoroutinesApi::class) +private class SurrogateMainCoroutineDispatcher : MainCoroutineDispatcher() { + private val isMainThread: ThreadLocal = ThreadLocal() + + private val mainThreadSurrogate = newSingleThreadContext("UI thread") + + init { + mainThreadSurrogate.dispatch(EmptyCoroutineContext, Runnable { isMainThread.set(true) }) + } + + override val immediate: MainCoroutineDispatcher = ImmediateMainCoroutineDispatcher(isMainThread, mainThreadSurrogate) + + override fun dispatch(context: CoroutineContext, block: Runnable) { + mainThreadSurrogate.dispatch(context, block) + } + + fun close() { + mainThreadSurrogate.close() + } +} + +private class ImmediateMainCoroutineDispatcher( + private val isMainThread: ThreadLocal, + private val mainThreadSurrogate: CloseableCoroutineDispatcher, +) : MainCoroutineDispatcher() { + override val immediate: MainCoroutineDispatcher get() = this + + override fun dispatch(context: CoroutineContext, block: Runnable) { + mainThreadSurrogate.dispatch(context, block) + } + + override fun isDispatchNeeded(context: CoroutineContext): Boolean { + return !isMainThread.get() + } +} + +@OptIn(ExperimentalCoroutinesApi::class) +actual fun runLifecycleTest(block: suspend CoroutineScope.() -> Unit): TestResult { + val mainThreadSurrogate = SurrogateMainCoroutineDispatcher() + Dispatchers.setMain(mainThreadSurrogate) + + try { + runBlocking(mainThreadSurrogate, block = block) + } finally { + Dispatchers.resetMain() + mainThreadSurrogate.close() + } +} diff --git a/lifecycle/lifecycle-runtime/src/androidInstrumentedTest/kotlin/androidx/lifecycle/Expectations.kt b/lifecycle/lifecycle-runtime/src/commonTest/kotlin/androidx/lifecycle/Expectations.kt similarity index 77% rename from lifecycle/lifecycle-runtime/src/androidInstrumentedTest/kotlin/androidx/lifecycle/Expectations.kt rename to lifecycle/lifecycle-runtime/src/commonTest/kotlin/androidx/lifecycle/Expectations.kt index a1053b682c39d..905183cb20f58 100644 --- a/lifecycle/lifecycle-runtime/src/androidInstrumentedTest/kotlin/androidx/lifecycle/Expectations.kt +++ b/lifecycle/lifecycle-runtime/src/commonTest/kotlin/androidx/lifecycle/Expectations.kt @@ -1,5 +1,5 @@ /* - * Copyright 2019 The Android Open Source Project + * Copyright 2024 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. @@ -16,8 +16,9 @@ package androidx.lifecycle -import com.google.common.truth.Truth -import java.util.concurrent.atomic.AtomicInteger +import androidx.kruth.assertThat +import kotlinx.atomicfu.atomic + /** * Partial copy from @@ -25,11 +26,11 @@ import java.util.concurrent.atomic.AtomicInteger * to track execution order. */ class Expectations { - private val counter = AtomicInteger(0) + private val counter = atomic(0) fun expect(expected: Int) { val order = counter.incrementAndGet() - Truth.assertThat(order).isEqualTo(expected) + assertThat(order).isEqualTo(expected) } fun expectUnreached() { @@ -37,6 +38,6 @@ class Expectations { } fun expectTotal(total: Int) { - Truth.assertThat(counter.get()).isEqualTo(total) + assertThat(counter.value).isEqualTo(total) } } diff --git a/lifecycle/lifecycle-runtime/src/androidInstrumentedTest/kotlin/androidx/lifecycle/FlowWithLifecycleTest.kt b/lifecycle/lifecycle-runtime/src/commonTest/kotlin/androidx/lifecycle/FlowWithLifecycleTest.kt similarity index 87% rename from lifecycle/lifecycle-runtime/src/androidInstrumentedTest/kotlin/androidx/lifecycle/FlowWithLifecycleTest.kt rename to lifecycle/lifecycle-runtime/src/commonTest/kotlin/androidx/lifecycle/FlowWithLifecycleTest.kt index 7e6f298431d71..4f822415e30b0 100644 --- a/lifecycle/lifecycle-runtime/src/androidInstrumentedTest/kotlin/androidx/lifecycle/FlowWithLifecycleTest.kt +++ b/lifecycle/lifecycle-runtime/src/commonTest/kotlin/androidx/lifecycle/FlowWithLifecycleTest.kt @@ -1,5 +1,5 @@ /* - * Copyright 2021 The Android Open Source Project + * Copyright 2024 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. @@ -16,15 +16,13 @@ package androidx.lifecycle -import androidx.test.filters.SmallTest -import com.google.common.truth.Truth.assertThat +import androidx.kruth.assertThat +import kotlin.test.Test import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.launchIn @@ -32,17 +30,13 @@ import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.take import kotlinx.coroutines.flow.toList import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking import kotlinx.coroutines.yield -import org.junit.Test -@SmallTest -@OptIn(ExperimentalCoroutinesApi::class) class FlowWithLifecycleTest { - private val owner = FakeLifecycleOwner() + private val owner = TestLifecycleOwner() @Test - fun testFiniteFlowCompletes() = runBlocking(Dispatchers.Main) { + fun testFiniteFlowCompletes() = runLifecycleTest { owner.setState(Lifecycle.State.CREATED) val result = flowOf(1, 2, 3) .flowWithLifecycle(owner.lifecycle, Lifecycle.State.CREATED) @@ -53,7 +47,7 @@ class FlowWithLifecycleTest { } @Test - fun testFlowStartsInSubsequentLifecycleState() = runBlocking(Dispatchers.Main) { + fun testFlowStartsInSubsequentLifecycleState() = runLifecycleTest { owner.setState(Lifecycle.State.RESUMED) val result = flowOf(1, 2, 3) .flowWithLifecycle(owner.lifecycle, Lifecycle.State.CREATED) @@ -64,7 +58,7 @@ class FlowWithLifecycleTest { } @Test - fun testFlowDoesNotCollectIfLifecycleIsDestroyed() = runBlocking(Dispatchers.Main) { + fun testFlowDoesNotCollectIfLifecycleIsDestroyed() = runLifecycleTest { owner.setState(Lifecycle.State.CREATED) owner.setState(Lifecycle.State.DESTROYED) val result = flowOf(1, 2, 3) @@ -75,7 +69,7 @@ class FlowWithLifecycleTest { } @Test - fun testCollectionRestartsWithFlowThatCompletes() = runBlocking(Dispatchers.Main) { + fun testCollectionRestartsWithFlowThatCompletes() = runLifecycleTest { assertFlowCollectsAgainOnRestart( flowOf(1, 2), expectedItemsBeforeRestarting = listOf(1, 2), @@ -84,7 +78,7 @@ class FlowWithLifecycleTest { } @Test - fun testCollectionRestartsWithFlowThatDoesNotComplete() = runBlocking(Dispatchers.Main) { + fun testCollectionRestartsWithFlowThatDoesNotComplete() = runLifecycleTest { assertFlowCollectsAgainOnRestart( flow { emit(1) @@ -97,7 +91,7 @@ class FlowWithLifecycleTest { } @Test - fun testCollectionRestartsWithAHotFlow() = runBlocking(Dispatchers.Main) { + fun testCollectionRestartsWithAHotFlow() = runLifecycleTest { val sharedFlow = MutableSharedFlow() assertFlowCollectsAgainOnRestart( sharedFlow, @@ -113,7 +107,7 @@ class FlowWithLifecycleTest { } @Test - fun testCancellingCoroutineDoesNotGetUpdates() = runBlocking(Dispatchers.Main) { + fun testCancellingCoroutineDoesNotGetUpdates() = runLifecycleTest { owner.setState(Lifecycle.State.STARTED) val sharedFlow = MutableSharedFlow() val resultList = mutableListOf() @@ -138,7 +132,7 @@ class FlowWithLifecycleTest { } @Test - fun testDestroyedLifecycleDoesNotGetUpdates() = runBlocking(Dispatchers.Main) { + fun testDestroyedLifecycleDoesNotGetUpdates() = runLifecycleTest { owner.setState(Lifecycle.State.STARTED) val sharedFlow = MutableSharedFlow() val resultList = mutableListOf() @@ -161,7 +155,7 @@ class FlowWithLifecycleTest { } @Test - fun testWithLaunchIn() = runBlocking(Dispatchers.Main) { + fun testWithLaunchIn() = runLifecycleTest { owner.setState(Lifecycle.State.STARTED) val resultList = mutableListOf() flowOf(1, 2, 3) @@ -174,7 +168,7 @@ class FlowWithLifecycleTest { } @Test - fun testOnEachBeforeOperatorOnlyExecutesInTheRightState() = runBlocking(Dispatchers.Main) { + fun testOnEachBeforeOperatorOnlyExecutesInTheRightState() = runLifecycleTest { owner.setState(Lifecycle.State.RESUMED) val sharedFlow = MutableSharedFlow() val resultList = mutableListOf() @@ -207,7 +201,7 @@ class FlowWithLifecycleTest { } @Test - fun testExtensionFailsWithInitializedState() = runBlocking(Dispatchers.Main) { + fun testExtensionFailsWithInitializedState() = runLifecycleTest { try { flowOf(1, 2, 3) .flowWithLifecycle(owner.lifecycle, Lifecycle.State.INITIALIZED) @@ -220,7 +214,7 @@ class FlowWithLifecycleTest { } @Test - fun testExtensionDoesNotCollectInDestroyedState() = runBlocking(Dispatchers.Main) { + fun testExtensionDoesNotCollectInDestroyedState() = runLifecycleTest { owner.setState(Lifecycle.State.STARTED) val resultList = mutableListOf() launch(Dispatchers.Main.immediate) { diff --git a/lifecycle/lifecycle-runtime/src/androidInstrumentedTest/kotlin/androidx/lifecycle/RepeatOnLifecycleTest.kt b/lifecycle/lifecycle-runtime/src/commonTest/kotlin/androidx/lifecycle/RepeatOnLifecycleTest.kt similarity index 86% rename from lifecycle/lifecycle-runtime/src/androidInstrumentedTest/kotlin/androidx/lifecycle/RepeatOnLifecycleTest.kt rename to lifecycle/lifecycle-runtime/src/commonTest/kotlin/androidx/lifecycle/RepeatOnLifecycleTest.kt index 064eca4360482..75465beddcd16 100644 --- a/lifecycle/lifecycle-runtime/src/androidInstrumentedTest/kotlin/androidx/lifecycle/RepeatOnLifecycleTest.kt +++ b/lifecycle/lifecycle-runtime/src/commonTest/kotlin/androidx/lifecycle/RepeatOnLifecycleTest.kt @@ -1,5 +1,5 @@ /* - * Copyright 2020 The Android Open Source Project + * Copyright 2024 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. @@ -16,31 +16,26 @@ package androidx.lifecycle -import androidx.test.filters.SmallTest -import com.google.common.truth.Truth.assertThat +import androidx.kruth.assertThat +import kotlin.test.Test import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineExceptionHandler -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.Job import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.UnconfinedTestDispatcher -import kotlinx.coroutines.test.runTest import kotlinx.coroutines.withContext import kotlinx.coroutines.yield -import org.junit.Test -@SmallTest class RepeatOnLifecycleTest { private val expectations = Expectations() - private val owner = FakeLifecycleOwner() + private val owner = TestLifecycleOwner() @Test - fun testBlockRunsWhenCreatedStateIsReached() = runBlocking(Dispatchers.Main) { + fun testBlockRunsWhenCreatedStateIsReached() = runLifecycleTest { owner.setState(Lifecycle.State.CREATED) expectations.expect(1) @@ -56,7 +51,7 @@ class RepeatOnLifecycleTest { } @Test - fun testBlockRunsWhenStartedStateIsReached() = runBlocking(Dispatchers.Main) { + fun testBlockRunsWhenStartedStateIsReached() = runLifecycleTest { owner.setState(Lifecycle.State.CREATED) expectations.expect(1) @@ -73,7 +68,7 @@ class RepeatOnLifecycleTest { } @Test - fun testBlockRunsWhenResumedStateIsReached() = runBlocking(Dispatchers.Main) { + fun testBlockRunsWhenResumedStateIsReached() = runLifecycleTest { owner.setState(Lifecycle.State.CREATED) expectations.expect(1) @@ -92,7 +87,7 @@ class RepeatOnLifecycleTest { } @Test - fun testBlocksRepeatsExecution() = runBlocking(Dispatchers.Main) { + fun testBlocksRepeatsExecution() = runLifecycleTest { owner.setState(Lifecycle.State.CREATED) var restarted = false expectations.expect(1) @@ -120,7 +115,7 @@ class RepeatOnLifecycleTest { } @Test - fun testBlocksRepeatsExecutionSerially() = runBlocking(Dispatchers.Main) { + fun testBlocksRepeatsExecutionSerially() = runLifecycleTest { owner.setState(Lifecycle.State.CREATED) var restarted = false expectations.expect(1) @@ -161,7 +156,7 @@ class RepeatOnLifecycleTest { } @Test - fun testBlockIsCancelledWhenLifecycleIsDestroyed() = runBlocking(Dispatchers.Main) { + fun testBlockIsCancelledWhenLifecycleIsDestroyed() = runLifecycleTest { owner.setState(Lifecycle.State.RESUMED) expectations.expect(1) @@ -185,7 +180,7 @@ class RepeatOnLifecycleTest { } @Test - fun testBlockRunsOnSubsequentLifecycleState() = runBlocking(Dispatchers.Main) { + fun testBlockRunsOnSubsequentLifecycleState() = runLifecycleTest { owner.setState(Lifecycle.State.RESUMED) expectations.expect(1) @@ -201,7 +196,7 @@ class RepeatOnLifecycleTest { } @Test - fun testBlockDoesNotStartIfLifecycleIsDestroyed() = runBlocking(Dispatchers.Main) { + fun testBlockDoesNotStartIfLifecycleIsDestroyed() = runLifecycleTest { owner.setState(Lifecycle.State.CREATED) owner.setState(Lifecycle.State.DESTROYED) expectations.expect(1) @@ -217,7 +212,7 @@ class RepeatOnLifecycleTest { } @Test - fun testCancellingTheReturnedJobCancelsTheBlock() = runBlocking(Dispatchers.Main) { + fun testCancellingTheReturnedJobCancelsTheBlock() = runLifecycleTest { owner.setState(Lifecycle.State.RESUMED) expectations.expect(1) @@ -242,7 +237,7 @@ class RepeatOnLifecycleTest { } @Test - fun testCancellingACustomJobCanBeHandled() = runBlocking(Dispatchers.Main) { + fun testCancellingACustomJobCanBeHandled() = runLifecycleTest { owner.setState(Lifecycle.State.RESUMED) expectations.expect(1) @@ -272,7 +267,7 @@ class RepeatOnLifecycleTest { } @Test - fun testCancellingACustomJobDoesNotReRunThatBlock() = runBlocking(Dispatchers.Main) { + fun testCancellingACustomJobDoesNotReRunThatBlock() = runLifecycleTest { owner.setState(Lifecycle.State.CREATED) var restarted = false expectations.expect(1) @@ -309,7 +304,7 @@ class RepeatOnLifecycleTest { } @Test - fun testCancellingTheJobDoesNotRestartTheBlockOnNewStates() = runBlocking(Dispatchers.Main) { + fun testCancellingTheJobDoesNotRestartTheBlockOnNewStates() = runLifecycleTest { owner.setState(Lifecycle.State.RESUMED) expectations.expect(1) @@ -341,14 +336,15 @@ class RepeatOnLifecycleTest { @OptIn(ExperimentalCoroutinesApi::class) @Test - fun testBlockRunsWhenLogicUsesWithContext() = runBlocking(Dispatchers.Main) { + fun testBlockRunsWhenLogicUsesWithContext() = runLifecycleTest { owner.setState(Lifecycle.State.CREATED) expectations.expect(1) - runTest(UnconfinedTestDispatcher()) { + val unconfinedDispatcher = UnconfinedTestDispatcher() + withContext(unconfinedDispatcher) { owner.lifecycleScope.launch { owner.repeatOnLifecycle(Lifecycle.State.CREATED) { - withContext(this@runTest.coroutineContext) { + withContext(unconfinedDispatcher) { expectations.expect(2) } } @@ -360,7 +356,7 @@ class RepeatOnLifecycleTest { } @Test - fun testBlockDoesNotStartWithDestroyedState() = runBlocking(Dispatchers.Main) { + fun testBlockDoesNotStartWithDestroyedState() = runLifecycleTest { owner.setState(Lifecycle.State.STARTED) expectations.expect(1) @@ -376,7 +372,7 @@ class RepeatOnLifecycleTest { } @Test - fun testExceptionWithInitializedState() = runBlocking(Dispatchers.Main) { + fun testExceptionWithInitializedState() = runLifecycleTest { val exceptions: MutableList = mutableListOf() val coroutineExceptionHandler = CoroutineExceptionHandler { _, exception -> exceptions.add(exception) @@ -388,7 +384,7 @@ class RepeatOnLifecycleTest { } } - assertThat(exceptions[0]).isInstanceOf(IllegalArgumentException::class.java) + assertThat(exceptions[0]).isInstanceOf() assertThat(exceptions).hasSize(1) } } diff --git a/lifecycle/lifecycle-runtime/src/commonTest/kotlin/androidx/lifecycle/TestLifecycleOwner.kt b/lifecycle/lifecycle-runtime/src/commonTest/kotlin/androidx/lifecycle/TestLifecycleOwner.kt new file mode 100644 index 0000000000000..c0b50923152e7 --- /dev/null +++ b/lifecycle/lifecycle-runtime/src/commonTest/kotlin/androidx/lifecycle/TestLifecycleOwner.kt @@ -0,0 +1,34 @@ +/* + * Copyright 2019 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 + * + * 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 androidx.lifecycle + +class TestLifecycleOwner(initialState: Lifecycle.State? = null) : LifecycleOwner { + private val registry: LifecycleRegistry = LifecycleRegistry.createUnsafe(this) + + init { + initialState?.let { + setState(it) + } + } + + override val lifecycle: Lifecycle + get() = registry + + fun setState(state: Lifecycle.State) { + registry.currentState = state + } +} diff --git a/lifecycle/lifecycle-runtime/src/androidInstrumentedTest/kotlin/androidx/lifecycle/WithLifecycleStateTest.kt b/lifecycle/lifecycle-runtime/src/commonTest/kotlin/androidx/lifecycle/WithLifecycleStateTest.kt similarity index 56% rename from lifecycle/lifecycle-runtime/src/androidInstrumentedTest/kotlin/androidx/lifecycle/WithLifecycleStateTest.kt rename to lifecycle/lifecycle-runtime/src/commonTest/kotlin/androidx/lifecycle/WithLifecycleStateTest.kt index 66f4780531763..6bd4f95491c20 100644 --- a/lifecycle/lifecycle-runtime/src/androidInstrumentedTest/kotlin/androidx/lifecycle/WithLifecycleStateTest.kt +++ b/lifecycle/lifecycle-runtime/src/commonTest/kotlin/androidx/lifecycle/WithLifecycleStateTest.kt @@ -1,5 +1,5 @@ /* - * Copyright 2020 The Android Open Source Project + * Copyright 2024 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. @@ -16,21 +16,18 @@ package androidx.lifecycle -import androidx.test.filters.SmallTest -import kotlinx.coroutines.Dispatchers +import androidx.kruth.assertThat +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlinx.coroutines.async import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking import kotlinx.coroutines.yield -import org.junit.Assert.assertEquals -import org.junit.Assert.assertTrue -import org.junit.Test -@SmallTest class WithLifecycleStateTest { @Test - fun testInitialResumed() = runBlocking(Dispatchers.Main) { - val owner = FakeLifecycleOwner(Lifecycle.State.RESUMED) + fun testInitialResumed() = runLifecycleTest { + val owner = TestLifecycleOwner(Lifecycle.State.RESUMED) val expected = "initial value" var toRead = expected @@ -40,8 +37,8 @@ class WithLifecycleStateTest { } @Test - fun testBlockRunsWithLifecycleStateChange() = runBlocking(Dispatchers.Main) { - val owner = FakeLifecycleOwner() + fun testBlockRunsWithLifecycleStateChange() = runLifecycleTest { + val owner = TestLifecycleOwner() val initial = "initial value" val afterSetState = "value set after setState" @@ -57,23 +54,18 @@ class WithLifecycleStateTest { } @Test - fun testBlockCancelledWhenInitiallyDestroyed() = runBlocking(Dispatchers.Main) { - val owner = FakeLifecycleOwner(Lifecycle.State.CREATED) + fun testBlockCancelledWhenInitiallyDestroyed() = runLifecycleTest { + val owner = TestLifecycleOwner(Lifecycle.State.CREATED) owner.setState(Lifecycle.State.DESTROYED) - val result = runCatching { + assertFailsWith { owner.withStarted {} } - - assertTrue( - "withStarted threw LifecycleDestroyedException", - result.exceptionOrNull() is LifecycleDestroyedException - ) } @Test - fun testBlockCancelledWhenDestroyedWhileSuspended() = runBlocking(Dispatchers.Main) { - val owner = FakeLifecycleOwner(Lifecycle.State.CREATED) + fun testBlockCancelledWhenDestroyedWhileSuspended() = runLifecycleTest { + val owner = TestLifecycleOwner(Lifecycle.State.CREATED) var launched = false val resultTask = async { @@ -82,14 +74,13 @@ class WithLifecycleStateTest { } yield() - assertTrue("test ran to first suspension after successfully launching", launched) - assertTrue("withStarted is still active", resultTask.isActive) + // test ran to first suspension after successfully launching + assertThat(launched).isTrue() + // withStarted is still active + assertThat(resultTask.isActive).isTrue() owner.setState(Lifecycle.State.DESTROYED) - assertTrue( - "result threw LifecycleDestroyedException", - resultTask.await().exceptionOrNull() is LifecycleDestroyedException - ) + assertThat(resultTask.await().exceptionOrNull()).isInstanceOf() } } diff --git a/lifecycle/lifecycle-runtime/src/commonTest/kotlin/androidx/lifecycle/runLifecycleTest.kt b/lifecycle/lifecycle-runtime/src/commonTest/kotlin/androidx/lifecycle/runLifecycleTest.kt new file mode 100644 index 0000000000000..1bf7a53b672c0 --- /dev/null +++ b/lifecycle/lifecycle-runtime/src/commonTest/kotlin/androidx/lifecycle/runLifecycleTest.kt @@ -0,0 +1,26 @@ +/* + * Copyright 2024 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 + * + * 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 androidx.lifecycle + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.test.TestResult + +/** + * Runs provided [block] in an environment suitable for testing lifecycle. + * It must provide a main thread able to dispatch coroutines. + */ +expect fun runLifecycleTest(block: suspend CoroutineScope.() -> Unit): TestResult \ No newline at end of file diff --git a/lifecycle/lifecycle-runtime/src/desktopTest/kotlin/androidx/lifecycle/runLifecycleTest.desktop.kt b/lifecycle/lifecycle-runtime/src/desktopTest/kotlin/androidx/lifecycle/runLifecycleTest.desktop.kt new file mode 100644 index 0000000000000..9b7915e00e274 --- /dev/null +++ b/lifecycle/lifecycle-runtime/src/desktopTest/kotlin/androidx/lifecycle/runLifecycleTest.desktop.kt @@ -0,0 +1,25 @@ +/* + * Copyright 2024 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 + * + * 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 androidx.lifecycle + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.TestResult + +actual fun runLifecycleTest(block: suspend CoroutineScope.() -> Unit): TestResult = + runBlocking(Dispatchers.Main, block) diff --git a/lifecycle/lifecycle-runtime/src/jsTest/kotlin/androidx/lifecycle/runLifecycleTest.js.kt b/lifecycle/lifecycle-runtime/src/jsTest/kotlin/androidx/lifecycle/runLifecycleTest.js.kt new file mode 100644 index 0000000000000..be74c8a480c9d --- /dev/null +++ b/lifecycle/lifecycle-runtime/src/jsTest/kotlin/androidx/lifecycle/runLifecycleTest.js.kt @@ -0,0 +1,28 @@ +/* + * Copyright 2024 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 + * + * 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 androidx.lifecycle + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.test.TestResult +import kotlinx.coroutines.test.runTest + +/** + * Run provided [block] in an environment suitable for testing lifecycle. + * It must provide a main thread able to dispatch coroutines. + */ +actual fun runLifecycleTest(block: suspend CoroutineScope.() -> Unit): TestResult = + runTest(testBody = block) \ No newline at end of file diff --git a/lifecycle/lifecycle-runtime/src/nativeTest/kotlin/androidx/lifecycle/runLifecycleTest.native.kt b/lifecycle/lifecycle-runtime/src/nativeTest/kotlin/androidx/lifecycle/runLifecycleTest.native.kt new file mode 100644 index 0000000000000..f319f8352ecb3 --- /dev/null +++ b/lifecycle/lifecycle-runtime/src/nativeTest/kotlin/androidx/lifecycle/runLifecycleTest.native.kt @@ -0,0 +1,85 @@ +/* + * Copyright 2024 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 + * + * 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 androidx.lifecycle + +import kotlin.coroutines.CoroutineContext +import kotlin.coroutines.EmptyCoroutineContext +import kotlin.native.concurrent.ThreadLocal +import kotlinx.coroutines.CloseableCoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.MainCoroutineDispatcher +import kotlinx.coroutines.Runnable +import kotlinx.coroutines.newSingleThreadContext +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.TestResult +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain + +@ThreadLocal +private var isMainThread = false + +/** + * On native platforms, tests are running from the main thread itself, + * so it is impossible to reschedule some coroutines to a later time, + * as the test code itself is preventing it. + * + * That's why we need a complete replacement for the Dispatchers.Main. + */ +@OptIn(ExperimentalCoroutinesApi::class, DelicateCoroutinesApi::class) +private class SurrogateMainCoroutineDispatcher : MainCoroutineDispatcher() { + private val mainThreadSurrogate = newSingleThreadContext("UI thread") + + init { + mainThreadSurrogate.dispatch(EmptyCoroutineContext, Runnable { isMainThread = true }) + } + + override val immediate: MainCoroutineDispatcher = ImmediateMainCoroutineDispatcher(mainThreadSurrogate) + + override fun dispatch(context: CoroutineContext, block: Runnable) { + mainThreadSurrogate.dispatch(context, block) + } + + fun close() { + mainThreadSurrogate.close() + } +} + +private class ImmediateMainCoroutineDispatcher(private val mainThreadSurrogate: CloseableCoroutineDispatcher) : MainCoroutineDispatcher() { + override val immediate: MainCoroutineDispatcher get() = this + + override fun dispatch(context: CoroutineContext, block: Runnable) { + mainThreadSurrogate.dispatch(context, block) + } + + override fun isDispatchNeeded(context: CoroutineContext): Boolean = !isMainThread +} + +@OptIn(ExperimentalCoroutinesApi::class) +actual fun runLifecycleTest(block: suspend CoroutineScope.() -> Unit): TestResult { + val mainThreadSurrogate = SurrogateMainCoroutineDispatcher() + Dispatchers.setMain(mainThreadSurrogate) + + try { + runBlocking(mainThreadSurrogate, block = block) + } finally { + Dispatchers.resetMain() + mainThreadSurrogate.close() + } +} diff --git a/lifecycle/lifecycle-runtime/src/wasmJsTest/kotlin/androidx/lifecycle/runLifecycleTest.wasmJs.kt b/lifecycle/lifecycle-runtime/src/wasmJsTest/kotlin/androidx/lifecycle/runLifecycleTest.wasmJs.kt new file mode 100644 index 0000000000000..015c91c21c031 --- /dev/null +++ b/lifecycle/lifecycle-runtime/src/wasmJsTest/kotlin/androidx/lifecycle/runLifecycleTest.wasmJs.kt @@ -0,0 +1,24 @@ +/* + * Copyright 2024 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 + * + * 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 androidx.lifecycle + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.test.TestResult +import kotlinx.coroutines.test.runTest + +actual fun runLifecycleTest(block: suspend CoroutineScope.() -> Unit): TestResult = + runTest(testBody = block) \ No newline at end of file