From 94b3ceab79207c085089fe1144b22ecd52b67a7e Mon Sep 17 00:00:00 2001 From: Laimonas Turauskas Date: Thu, 19 Sep 2024 13:47:47 -0400 Subject: [PATCH] Refactoring android tests to use utils/android (pt2). (#394) --- .../formula/ActivityLifecycleEventTest.kt | 57 ------ .../instacart/formula/ActivityUpdateTest.kt | 80 --------- .../formula/ActivityUpdateTimingTest.kt | 54 ------ .../instacart/formula/NonBoundActivityTest.kt | 29 --- .../formula/NonBoundFragmentActivityTest.kt | 29 --- .../instacart/formula/FormulaAndroidTest.kt | 38 ---- .../formula/android/FormulaAndroidTest.kt | 169 ++++++++++++++++++ .../android/test/ActivityUpdateInteractor.kt | 57 ++++++ gradle/jacoco.gradle | 6 +- test-utils/android/build.gradle.kts | 1 + .../android/src/main/AndroidManifest.xml | 3 +- .../testutils/android/TestExtensions.kt | 27 +++ 12 files changed, 260 insertions(+), 290 deletions(-) delete mode 100644 formula-android-tests/src/test/java/com/instacart/formula/ActivityLifecycleEventTest.kt delete mode 100644 formula-android-tests/src/test/java/com/instacart/formula/ActivityUpdateTest.kt delete mode 100644 formula-android-tests/src/test/java/com/instacart/formula/ActivityUpdateTimingTest.kt delete mode 100644 formula-android-tests/src/test/java/com/instacart/formula/NonBoundActivityTest.kt delete mode 100644 formula-android-tests/src/test/java/com/instacart/formula/NonBoundFragmentActivityTest.kt delete mode 100644 formula-android/src/test/java/com/instacart/formula/FormulaAndroidTest.kt create mode 100644 formula-android/src/test/java/com/instacart/formula/android/FormulaAndroidTest.kt create mode 100644 formula-android/src/test/java/com/instacart/formula/android/test/ActivityUpdateInteractor.kt diff --git a/formula-android-tests/src/test/java/com/instacart/formula/ActivityLifecycleEventTest.kt b/formula-android-tests/src/test/java/com/instacart/formula/ActivityLifecycleEventTest.kt deleted file mode 100644 index 0b7d6e02..00000000 --- a/formula-android-tests/src/test/java/com/instacart/formula/ActivityLifecycleEventTest.kt +++ /dev/null @@ -1,57 +0,0 @@ -package com.instacart.formula - -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.Lifecycle.State.* -import androidx.test.core.app.ActivityScenario -import androidx.test.ext.junit.runners.AndroidJUnit4 -import com.google.common.truth.Truth.assertThat -import com.instacart.formula.android.ActivityStore -import com.instacart.testutils.android.TestFormulaActivity -import com.instacart.testutils.android.activity -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith - -@RunWith(AndroidJUnit4::class) -class ActivityLifecycleEventTest { - private lateinit var events: MutableList - - @get:Rule - val rule = TestFormulaRule( - initFormula = { app -> - FormulaAndroid.init(app) { - activity { - events = mutableListOf() - ActivityStore( - streams = { - activityLifecycleState().subscribe { - events.add(it) - } - } - ) - } - } - }) - - @Test - fun `full lifecycle`() { - val scenario = ActivityScenario.launch(TestFormulaActivity::class.java) - scenario.recreate() - scenario.close() - - val lifecycle = listOf(CREATED, STARTED, RESUMED, STARTED, CREATED, DESTROYED) - // We expect two full lifecycles - val expected = listOf(INITIALIZED) + lifecycle + lifecycle - assertThat(events).containsExactlyElementsIn(expected).inOrder() - } - - @Test - fun `calling onPreCreate() twice will throw an exception`() { - val scenario = ActivityScenario.launch(TestFormulaActivity::class.java) - val activity = scenario.activity() - val result = runCatching { FormulaAndroid.onPreCreate(activity, null) } - assertThat(result.exceptionOrNull()).hasMessageThat().contains( - "Activity TestFormulaActivity was already initialized. Did you call FormulaAndroid.onPreCreate() twice?" - ) - } -} diff --git a/formula-android-tests/src/test/java/com/instacart/formula/ActivityUpdateTest.kt b/formula-android-tests/src/test/java/com/instacart/formula/ActivityUpdateTest.kt deleted file mode 100644 index 514a12c1..00000000 --- a/formula-android-tests/src/test/java/com/instacart/formula/ActivityUpdateTest.kt +++ /dev/null @@ -1,80 +0,0 @@ -package com.instacart.formula - -import android.app.Activity -import androidx.test.core.app.ActivityScenario -import androidx.test.ext.junit.rules.ActivityScenarioRule -import androidx.test.ext.junit.runners.AndroidJUnit4 -import com.google.common.truth.Truth.assertThat -import com.instacart.formula.android.ActivityStore -import com.instacart.testutils.android.TestFormulaActivity -import com.instacart.testutils.android.get -import com.jakewharton.rxrelay3.PublishRelay -import org.junit.After -import org.junit.Before -import org.junit.Rule -import org.junit.Test -import org.junit.rules.RuleChain -import org.junit.runner.RunWith - -@RunWith(AndroidJUnit4::class) -class ActivityUpdateTest { - - private val updates = mutableListOf>() - private val updateRelay = PublishRelay.create() - - private val formulaRule = TestFormulaRule( - initFormula = { app -> - FormulaAndroid.init(app) { - activity { - ActivityStore( - streams = { - update(updateRelay) { activity, state -> - updates.add(activity to state) - } - } - ) - } - } - }) - - private val activityRule = ActivityScenarioRule(TestFormulaActivity::class.java) - - @get:Rule val rule = RuleChain.outerRule(formulaRule).around(activityRule) - lateinit var scenario: ActivityScenario - - @Before fun setup() { - scenario = activityRule.scenario - } - - @After fun cleanup() { - updates.clear() - } - - @Test fun `basic updates`() { - updateRelay.accept("update-1") - updateRelay.accept("update-2") - - assertThat(updates()).containsExactly("update-1", "update-2").inOrder() - } - - @Test fun `last update is applied after configuration changes`() { - updateRelay.accept("update-1") - updateRelay.accept("update-2") - - scenario.recreate() - - assertThat(updates()).containsExactly("update-2").inOrder() - } - - @Test fun `updates are unsubscribed from when activity is finished`() { - assertThat(updateRelay.hasObservers()).isTrue() - - scenario.close() - - assertThat(updateRelay.hasObservers()).isFalse() - } - - private fun updates() = scenario.get { - updates.filter { it.first == this }.map { it.second } - } -} diff --git a/formula-android-tests/src/test/java/com/instacart/formula/ActivityUpdateTimingTest.kt b/formula-android-tests/src/test/java/com/instacart/formula/ActivityUpdateTimingTest.kt deleted file mode 100644 index cf5215d6..00000000 --- a/formula-android-tests/src/test/java/com/instacart/formula/ActivityUpdateTimingTest.kt +++ /dev/null @@ -1,54 +0,0 @@ -package com.instacart.formula - -import android.app.Activity -import androidx.test.core.app.ActivityScenario -import androidx.test.ext.junit.rules.ActivityScenarioRule -import androidx.test.ext.junit.runners.AndroidJUnit4 -import com.google.common.truth.Truth.assertThat -import com.instacart.formula.android.ActivityStore -import com.instacart.testutils.android.TestFormulaActivity -import com.instacart.testutils.android.get -import io.reactivex.rxjava3.core.Observable -import org.junit.Before -import org.junit.Rule -import org.junit.Test -import org.junit.rules.RuleChain -import org.junit.runner.RunWith - -@RunWith(AndroidJUnit4::class) -class ActivityUpdateTimingTest { - - private val updates = mutableListOf>() - private val updateRelay = Observable.just("update-1", "update-2") - - private val formulaRule = TestFormulaRule( - initFormula = { app -> - FormulaAndroid.init(app) { - activity { - ActivityStore( - streams = { - update(updateRelay) { activity, state -> - updates.add(activity to state) - } - } - ) - } - } - }) - - private val activityRule = ActivityScenarioRule(TestFormulaActivity::class.java) - - @get:Rule val rule = RuleChain.outerRule(formulaRule).around(activityRule) - lateinit var scenario: ActivityScenario - - @Before fun setup() { - scenario = activityRule.scenario - } - - @Test fun `last update arrives`() { - val updates = scenario.get { - updates.filter { it.first == this }.map { it.second } - } - assertThat(updates).containsExactly("update-2").inOrder() - } -} diff --git a/formula-android-tests/src/test/java/com/instacart/formula/NonBoundActivityTest.kt b/formula-android-tests/src/test/java/com/instacart/formula/NonBoundActivityTest.kt deleted file mode 100644 index 0e0fd061..00000000 --- a/formula-android-tests/src/test/java/com/instacart/formula/NonBoundActivityTest.kt +++ /dev/null @@ -1,29 +0,0 @@ -package com.instacart.formula - -import androidx.test.core.app.ActivityScenario -import androidx.test.ext.junit.runners.AndroidJUnit4 -import com.instacart.testutils.android.TestActivity -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith - -/** - * Tests that formula-android module handles non-bound activities gracefully. - */ -@RunWith(AndroidJUnit4::class) -class NonBoundActivityTest { - - @get:Rule - val rule = TestFormulaRule( - initFormula = { app -> - FormulaAndroid.init(app) {} - } - ) - - @Test - fun `full lifecycle`() { - val scenario = ActivityScenario.launch(TestActivity::class.java) - scenario.recreate() - scenario.close() - } -} \ No newline at end of file diff --git a/formula-android-tests/src/test/java/com/instacart/formula/NonBoundFragmentActivityTest.kt b/formula-android-tests/src/test/java/com/instacart/formula/NonBoundFragmentActivityTest.kt deleted file mode 100644 index 367ceaa8..00000000 --- a/formula-android-tests/src/test/java/com/instacart/formula/NonBoundFragmentActivityTest.kt +++ /dev/null @@ -1,29 +0,0 @@ -package com.instacart.formula - -import androidx.test.core.app.ActivityScenario -import androidx.test.ext.junit.runners.AndroidJUnit4 -import com.instacart.testutils.android.TestFragmentActivity -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith - -/** - * Tests that formula-android module handles non-bound fragment activities gracefully. - */ -@RunWith(AndroidJUnit4::class) -class NonBoundFragmentActivityTest { - - @get:Rule - val rule = TestFormulaRule( - initFormula = { app -> - FormulaAndroid.init(app) {} - } - ) - - @Test - fun `full lifecycle`() { - val scenario = ActivityScenario.launch(TestFragmentActivity::class.java) - scenario.recreate() - scenario.close() - } -} \ No newline at end of file diff --git a/formula-android/src/test/java/com/instacart/formula/FormulaAndroidTest.kt b/formula-android/src/test/java/com/instacart/formula/FormulaAndroidTest.kt deleted file mode 100644 index 2ee64911..00000000 --- a/formula-android/src/test/java/com/instacart/formula/FormulaAndroidTest.kt +++ /dev/null @@ -1,38 +0,0 @@ -package com.instacart.formula - -import android.app.Application -import androidx.test.core.app.ApplicationProvider -import androidx.test.ext.junit.runners.AndroidJUnit4 -import com.google.common.truth.Truth -import com.instacart.testutils.android.TestFormulaActivity -import org.junit.Test -import org.junit.runner.RunWith - -@RunWith(AndroidJUnit4::class) -class FormulaAndroidTest { - - @Test fun `crashes if initialized twice`() { - - try { - val result = runCatching { - val context = ApplicationProvider.getApplicationContext() - FormulaAndroid.init(context) {} - FormulaAndroid.init(context) {} - } - val error = result.exceptionOrNull()?.message - Truth.assertThat(error).isEqualTo("can only initialize the store once.") - } finally { - FormulaAndroid.reset() - } - } - - @Test fun `crashes if accessed before initialization`() { - val result = runCatching { - FormulaAndroid.onBackPressed(TestFormulaActivity()) - } - val errorMessage = result.exceptionOrNull()?.message - Truth.assertThat(errorMessage).isEqualTo( - "Need to call FormulaAndroid.init() from your Application." - ) - } -} \ No newline at end of file diff --git a/formula-android/src/test/java/com/instacart/formula/android/FormulaAndroidTest.kt b/formula-android/src/test/java/com/instacart/formula/android/FormulaAndroidTest.kt new file mode 100644 index 00000000..6c144f8f --- /dev/null +++ b/formula-android/src/test/java/com/instacart/formula/android/FormulaAndroidTest.kt @@ -0,0 +1,169 @@ +package com.instacart.formula.android + +import android.app.Activity +import android.app.Application +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.Lifecycle.State.CREATED +import androidx.lifecycle.Lifecycle.State.DESTROYED +import androidx.lifecycle.Lifecycle.State.INITIALIZED +import androidx.lifecycle.Lifecycle.State.RESUMED +import androidx.lifecycle.Lifecycle.State.STARTED +import androidx.test.core.app.ActivityScenario +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth +import com.google.common.truth.Truth.assertThat +import com.instacart.formula.FormulaAndroid +import com.instacart.formula.android.test.runActivityUpdateTest +import com.instacart.testutils.android.TestActivity +import com.instacart.testutils.android.TestFormulaActivity +import com.instacart.testutils.android.TestFragmentActivity +import com.instacart.testutils.android.activity +import com.instacart.testutils.android.withFormulaAndroid +import io.reactivex.rxjava3.core.Observable +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class FormulaAndroidTest { + + @Test + fun `crashes if initialized twice`() { + + try { + val result = runCatching { + val context = ApplicationProvider.getApplicationContext() + FormulaAndroid.init(context) {} + FormulaAndroid.init(context) {} + } + val error = result.exceptionOrNull()?.message + Truth.assertThat(error).isEqualTo("can only initialize the store once.") + } finally { + FormulaAndroid.reset() + } + } + + @Test + fun `crashes if accessed before initialization`() { + val result = runCatching { + FormulaAndroid.onBackPressed(TestFormulaActivity()) + } + val errorMessage = result.exceptionOrNull()?.message + Truth.assertThat(errorMessage).isEqualTo( + "Need to call FormulaAndroid.init() from your Application." + ) + } + + @Test + fun `calling onPreCreate() twice will throw an exception`() { + withFormulaAndroid( + configure = { + activity { + ActivityStore() + } + } + ) { + val scenario = ActivityScenario.launch(TestFormulaActivity::class.java) + val activity = scenario.activity() + val result = runCatching { FormulaAndroid.onPreCreate(activity, null) } + assertThat(result.exceptionOrNull()).hasMessageThat().contains( + "Activity TestFormulaActivity was already initialized. Did you call FormulaAndroid.onPreCreate() twice?" + ) + } + } + + /** + * Checks that we handle non-bound activities gracefully. + */ + @Test + fun `does not crash when non bound activity is run`() { + withFormulaAndroid { + val scenario = ActivityScenario.launch(TestActivity::class.java) + scenario.recreate() + scenario.close() + } + } + + /** + * Checks that we handle non-bound fragment activities gracefully. + */ + @Test + fun `does not crash when non bound fragment activity is run`() { + withFormulaAndroid { + val scenario = ActivityScenario.launch(TestFragmentActivity::class.java) + scenario.recreate() + scenario.close() + } + } + + @Test + fun `activity lifecycle state emits all events`() { + var events: MutableList = mutableListOf() + withFormulaAndroid( + configure = { + activity { + events = mutableListOf() + ActivityStore( + streams = { + activityLifecycleState().subscribe { + events.add(it) + } + } + ) + } + } + ) { + val scenario = ActivityScenario.launch(TestFormulaActivity::class.java) + scenario.recreate() + scenario.close() + + val lifecycle = listOf(CREATED, STARTED, RESUMED, STARTED, CREATED, DESTROYED) + // We expect two full lifecycles + val expected = listOf(INITIALIZED) + lifecycle + lifecycle + assertThat(events).containsExactlyElementsIn(expected).inOrder() + } + } + + @Test + fun `all updates except the last are dropped if they are emitted before Activity onStarted is called`() { + runActivityUpdateTest( + initialUpdates = Observable.just("one", "two", "three") + ) { _, interactor -> + // Only last update is received while others are dropped. + val updates = interactor.currentUpdates() + assertThat(updates).containsExactly("three") + } + } + + @Test + fun `activity updates are emitted`() { + runActivityUpdateTest { _, interactor -> + interactor.publish("update-1") + interactor.publish("update-2") + + val updates = interactor.currentUpdates() + assertThat(updates).containsExactly("update-1", "update-2").inOrder() + } + } + + @Test + fun `last activity update is emitted after configuration changes`() { + runActivityUpdateTest { scenario, updateRelay -> + updateRelay.publish("update-1") + updateRelay.publish("update-2") + scenario.recreate() + + val updates = updateRelay.currentUpdates() + assertThat(updates).containsExactly("update-2").inOrder() + } + } + + @Test + fun `activity updates observable is disposed when activity is finished`() { + runActivityUpdateTest { scenario, updateRelay -> + updateRelay.assertHasObservers(true) + scenario.close() + updateRelay.assertHasObservers(false) + } + } +} diff --git a/formula-android/src/test/java/com/instacart/formula/android/test/ActivityUpdateInteractor.kt b/formula-android/src/test/java/com/instacart/formula/android/test/ActivityUpdateInteractor.kt new file mode 100644 index 00000000..426e290e --- /dev/null +++ b/formula-android/src/test/java/com/instacart/formula/android/test/ActivityUpdateInteractor.kt @@ -0,0 +1,57 @@ +package com.instacart.formula.android.test + +import android.app.Activity +import androidx.test.core.app.ActivityScenario +import com.google.common.truth.Truth.assertThat +import com.instacart.formula.android.ActivityStore +import com.instacart.testutils.android.TestFormulaActivity +import com.instacart.testutils.android.activity +import com.instacart.testutils.android.withFormulaAndroid +import com.jakewharton.rxrelay3.PublishRelay +import io.reactivex.rxjava3.core.Observable + +class ActivityUpdateInteractor( + private val scenario: ActivityScenario<*>, + private val updates: MutableList>, + private val updateRelay: PublishRelay, +) { + fun publish(update: String) { + updateRelay.accept(update) + } + + fun currentUpdates(): List { + return updates.filter { it.first == scenario.activity() }.map { it.second } + } + + fun assertHasObservers(expected: Boolean) { + assertThat(updateRelay.hasObservers()).isEqualTo(expected) + } +} + +fun runActivityUpdateTest( + initialUpdates: Observable = Observable.empty(), + continuation: (ActivityScenario, ActivityUpdateInteractor) -> Unit +) { + val updates = mutableListOf>() + val updateRelay = PublishRelay.create() + withFormulaAndroid( + configure = { + activity { + ActivityStore( + streams = { + val updateEvents = initialUpdates.mergeWith(updateRelay) + update(updateEvents) { activity, state -> + updates.add(activity to state) + } + } + ) + } + } + ) { + val scenario = ActivityScenario.launch(TestFormulaActivity::class.java) + val relay = ActivityUpdateInteractor(scenario, updates, updateRelay) + continuation(scenario, relay) + } +} + + diff --git a/gradle/jacoco.gradle b/gradle/jacoco.gradle index b0e9ae96..bec402a1 100644 --- a/gradle/jacoco.gradle +++ b/gradle/jacoco.gradle @@ -10,10 +10,12 @@ junitJacoco { '**/Manifest*.*', '**/*$Lambda$*.*', // Jacoco can not handle several "$" in class name. '**/*$inlined$*.*', // Kotlin specific, Jacoco can not handle several "$" in class name. - '**/com/instacart/formula/test/**/*.*' + '**/com/instacart/formula/test/**/*.*', + '**/com/instacart/testutils/**/*.*' ] ignoreProjects = [ - "samples/*" + "samples/*", + "test-utils/*" ] includeNoLocationClasses = true includeInstrumentationCoverageInMergedReport = true diff --git a/test-utils/android/build.gradle.kts b/test-utils/android/build.gradle.kts index 0d59a49a..f68734ac 100644 --- a/test-utils/android/build.gradle.kts +++ b/test-utils/android/build.gradle.kts @@ -17,4 +17,5 @@ dependencies { implementation(libs.androidx.test.core.ktx) implementation(libs.lifecycle.extensions) implementation(libs.robolectric) + implementation(libs.truth) } diff --git a/test-utils/android/src/main/AndroidManifest.xml b/test-utils/android/src/main/AndroidManifest.xml index cc7d5d87..ed7f28d7 100644 --- a/test-utils/android/src/main/AndroidManifest.xml +++ b/test-utils/android/src/main/AndroidManifest.xml @@ -14,6 +14,7 @@ + android:exported="false" + android:theme="@style/Theme.AppCompat.DayNight.NoActionBar" /> diff --git a/test-utils/android/src/main/java/com/instacart/testutils/android/TestExtensions.kt b/test-utils/android/src/main/java/com/instacart/testutils/android/TestExtensions.kt index 759a982c..e5596e6b 100644 --- a/test-utils/android/src/main/java/com/instacart/testutils/android/TestExtensions.kt +++ b/test-utils/android/src/main/java/com/instacart/testutils/android/TestExtensions.kt @@ -1,8 +1,15 @@ package com.instacart.testutils.android import android.app.Activity +import android.app.Application import android.os.Looper import androidx.test.core.app.ActivityScenario +import androidx.test.core.app.ApplicationProvider +import com.google.common.truth.Truth.assertThat +import com.instacart.formula.FormulaAndroid +import com.instacart.formula.android.ActivityConfigurator +import com.instacart.formula.android.FragmentEnvironment +import io.reactivex.rxjava3.plugins.RxJavaPlugins import org.robolectric.Shadows import java.util.concurrent.CountDownLatch import java.util.concurrent.Executors @@ -35,3 +42,23 @@ fun executeOnBackgroundThread(action: () -> Unit) { initLatch.throwOnTimeout() Shadows.shadowOf(Looper.getMainLooper()).idle() } + +fun withFormulaAndroid( + environment: FragmentEnvironment = FragmentEnvironment(), + configure: ActivityConfigurator.() -> Unit = {}, + continuation: () -> Unit, +) { + val errors = mutableListOf() + RxJavaPlugins.reset() + RxJavaPlugins.setErrorHandler { errors.add(it) } + + try { + val context = ApplicationProvider.getApplicationContext() + FormulaAndroid.init(context, environment, configure) + continuation() + } finally { + RxJavaPlugins.reset() + FormulaAndroid.reset() + assertThat(errors).isEmpty() + } +}