From 00c222b461e70558bc67315e1b0ba0ac9161b912 Mon Sep 17 00:00:00 2001 From: Gergely Hegedus Date: Tue, 19 Jul 2022 16:33:38 +0300 Subject: [PATCH 1/4] issue#103 Removing item animations --- .../fnives/test/showcase/ui/home/HomeRobot.kt | 14 +++++++++++ .../recycler/RemoveItemAnimations.kt | 25 +++++++++++++++++++ 2 files changed, 39 insertions(+) create mode 100644 test-util-android/src/main/java/org/fnives/test/showcase/android/testutil/viewaction/recycler/RemoveItemAnimations.kt diff --git a/app/src/sharedTest/java/org/fnives/test/showcase/ui/home/HomeRobot.kt b/app/src/sharedTest/java/org/fnives/test/showcase/ui/home/HomeRobot.kt index 7b154a6..60b5acf 100644 --- a/app/src/sharedTest/java/org/fnives/test/showcase/ui/home/HomeRobot.kt +++ b/app/src/sharedTest/java/org/fnives/test/showcase/ui/home/HomeRobot.kt @@ -21,6 +21,7 @@ import androidx.test.espresso.matcher.ViewMatchers.withText import org.fnives.test.showcase.R import org.fnives.test.showcase.android.testutil.intent.notIntended import org.fnives.test.showcase.android.testutil.viewaction.imageview.WithDrawable +import org.fnives.test.showcase.android.testutil.viewaction.recycler.RemoveItemAnimations import org.fnives.test.showcase.android.testutil.viewaction.swiperefresh.PullToRefresh import org.fnives.test.showcase.model.content.Content import org.fnives.test.showcase.model.content.FavouriteContent @@ -29,6 +30,16 @@ import org.hamcrest.Matchers.allOf class HomeRobot { + /** + * Needed because Espresso idling sometimes not in sync with RecyclerView's animation. + * So we simply remove the item animations, the animations should be disabled anyway for test. + * + * Reference: https://github.com/android/android-test/issues/223 + */ + fun removeItemAnimations() = apply { + Espresso.onView(withId(R.id.recycler)).perform(RemoveItemAnimations()) + } + fun setupIntentResults() { Intents.intending(IntentMatchers.hasComponent(AuthActivity::class.java.canonicalName)) .respondWith(Instrumentation.ActivityResult(Activity.RESULT_OK, Intent())) @@ -50,6 +61,7 @@ class HomeRobot { } fun assertContainsItem(index: Int, item: FavouriteContent) = apply { + removeItemAnimations() val isFavouriteResourceId = if (item.isFavourite) { R.drawable.favorite_24 } else { @@ -69,6 +81,7 @@ class HomeRobot { } fun clickOnContentItem(index: Int, item: Content) = apply { + removeItemAnimations() Espresso.onView(withId(R.id.recycler)) .perform(RecyclerViewActions.scrollToPosition(index)) @@ -91,6 +104,7 @@ class HomeRobot { } fun assertContainsNoItems() = apply { + removeItemAnimations() Espresso.onView(withId(R.id.recycler)) .check(matches(hasChildCount(0))) } diff --git a/test-util-android/src/main/java/org/fnives/test/showcase/android/testutil/viewaction/recycler/RemoveItemAnimations.kt b/test-util-android/src/main/java/org/fnives/test/showcase/android/testutil/viewaction/recycler/RemoveItemAnimations.kt new file mode 100644 index 0000000..7e54a7a --- /dev/null +++ b/test-util-android/src/main/java/org/fnives/test/showcase/android/testutil/viewaction/recycler/RemoveItemAnimations.kt @@ -0,0 +1,25 @@ +package org.fnives.test.showcase.android.testutil.viewaction.recycler + +import android.view.View +import androidx.recyclerview.widget.RecyclerView +import androidx.test.espresso.UiController +import androidx.test.espresso.ViewAction +import androidx.test.espresso.matcher.ViewMatchers +import org.hamcrest.Matcher + +/** + * Sets the [RecyclerView]'s [itemAnimator][RecyclerView.setItemAnimator] to null, thus disabling animations. + */ +class RemoveItemAnimations : ViewAction { + override fun getConstraints(): Matcher = + ViewMatchers.isAssignableFrom(RecyclerView::class.java) + + override fun getDescription(): String = + "Remove item animations" + + override fun perform(uiController: UiController, view: View) { + val recycler: RecyclerView = view as RecyclerView + recycler.itemAnimator = null + uiController.loopMainThreadUntilIdle() + } +} From 732aa05a87f5e6258f170d61f52d54ac4bf38603 Mon Sep 17 00:00:00 2001 From: Gergely Hegedus Date: Wed, 20 Jul 2022 00:37:02 +0300 Subject: [PATCH 2/4] issue#103 Add delay for Dispatcher.Default to have time to propagate exceptions --- .../idlingresources/OkHttp3IdlingResource.kt | 24 ++++++++++++++++++- .../mainThreadSynchronization.kt | 2 +- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/test-util-android/src/main/java/org/fnives/test/showcase/android/testutil/synchronization/idlingresources/OkHttp3IdlingResource.kt b/test-util-android/src/main/java/org/fnives/test/showcase/android/testutil/synchronization/idlingresources/OkHttp3IdlingResource.kt index f0770ff..a0c70d2 100644 --- a/test-util-android/src/main/java/org/fnives/test/showcase/android/testutil/synchronization/idlingresources/OkHttp3IdlingResource.kt +++ b/test-util-android/src/main/java/org/fnives/test/showcase/android/testutil/synchronization/idlingresources/OkHttp3IdlingResource.kt @@ -21,6 +21,7 @@ class OkHttp3IdlingResource private constructor( init { val currentCallback = dispatcher.idleCallback dispatcher.idleCallback = Runnable { + sleepForDispatcherDefaultCallInRetrofitErrorState() callback?.onTransitionToIdle() currentCallback?.run() } @@ -28,7 +29,13 @@ class OkHttp3IdlingResource private constructor( override fun getName(): String = name - override fun isIdleNow(): Boolean = dispatcher.runningCallsCount() == 0 + override fun isIdleNow(): Boolean { + val isIdle = dispatcher.runningCallsCount() == 0 + if (isIdle) { + sleepForDispatcherDefaultCallInRetrofitErrorState() + } + return isIdle + } override fun registerIdleTransitionCallback(callback: IdlingResource.ResourceCallback?) { this.callback = callback @@ -46,5 +53,20 @@ class OkHttp3IdlingResource private constructor( if (client == null) throw NullPointerException("client == null") return OkHttp3IdlingResource(name, client.dispatcher) } + + /** + * This is required, because in case of Errors Retrofit uses Dispatcher.Default to suspendThrow + * see: retrofit2.KotlinExtensions.kt Exception.suspendAndThrow + * Relevant code issue: https://github.com/square/retrofit/blob/6cd6f7d8287f73909614cb7300fcde05f5719750/retrofit/src/main/java/retrofit2/KotlinExtensions.kt#L121 + * This is the current suggested approach to their problem with Unchecked Exceptions + * + * Sadly Dispatcher.Default cannot be replaced yet, so we can't swap it out in tests: + * https://github.com/Kotlin/kotlinx.coroutines/issues/1365 + * + * This brings us to this sleep for now. + */ + private fun sleepForDispatcherDefaultCallInRetrofitErrorState() { + Thread.sleep(200L) + } } } diff --git a/test-util-android/src/main/java/org/fnives/test/showcase/android/testutil/synchronization/mainThreadSynchronization.kt b/test-util-android/src/main/java/org/fnives/test/showcase/android/testutil/synchronization/mainThreadSynchronization.kt index 25a3518..2c7d4d0 100644 --- a/test-util-android/src/main/java/org/fnives/test/showcase/android/testutil/synchronization/mainThreadSynchronization.kt +++ b/test-util-android/src/main/java/org/fnives/test/showcase/android/testutil/synchronization/mainThreadSynchronization.kt @@ -28,7 +28,7 @@ fun runOnUIAwaitOnCurrent(action: () -> Unit) { fun loopMainThreadFor(delay: Long) { if (Looper.getMainLooper().thread == Thread.currentThread()) { - Thread.sleep(200L) + Thread.sleep(delay) } else { Espresso.onView(ViewMatchers.isRoot()).perform(LoopMainThreadFor(delay)) } From d057c357a3178815c21f5433c86e8a093392fd96 Mon Sep 17 00:00:00 2001 From: Gergely Hegedus Date: Wed, 20 Jul 2022 15:16:06 +0300 Subject: [PATCH 3/4] issue#103 Close DB to clean up logs --- .../ReloadKoinModulesIfNecessaryTestRule.kt | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/app/src/sharedTest/java/org/fnives/test/showcase/testutils/ReloadKoinModulesIfNecessaryTestRule.kt b/app/src/sharedTest/java/org/fnives/test/showcase/testutils/ReloadKoinModulesIfNecessaryTestRule.kt index ec0a5b5..bf3ad72 100644 --- a/app/src/sharedTest/java/org/fnives/test/showcase/testutils/ReloadKoinModulesIfNecessaryTestRule.kt +++ b/app/src/sharedTest/java/org/fnives/test/showcase/testutils/ReloadKoinModulesIfNecessaryTestRule.kt @@ -1,10 +1,12 @@ package org.fnives.test.showcase.testutils +import android.util.Log import androidx.test.core.app.ApplicationProvider import org.fnives.test.showcase.BuildConfig import org.fnives.test.showcase.TestShowcaseApplication import org.fnives.test.showcase.di.createAppModules import org.fnives.test.showcase.model.network.BaseUrl +import org.fnives.test.showcase.storage.LocalDatabase import org.junit.rules.TestRule import org.junit.runner.Description import org.junit.runners.model.Statement @@ -14,6 +16,7 @@ import org.koin.core.context.startKoin import org.koin.core.context.stopKoin import org.koin.mp.KoinPlatformTools import org.koin.test.KoinTest +import org.koin.test.get /** * Test rule to help reinitialize the whole Koin setup. @@ -23,16 +26,17 @@ import org.koin.test.KoinTest * * Note: Do not use if you want your test's to share Koin, and in such case do not stop your Koin. */ -class ReloadKoinModulesIfNecessaryTestRule : TestRule, KoinTest { +class ReloadKoinModulesIfNecessaryTestRule : TestRule { override fun apply(base: Statement, description: Description): Statement = ReinitKoinStatement(base) - class ReinitKoinStatement(private val base: Statement) : Statement() { + class ReinitKoinStatement(private val base: Statement) : Statement(), KoinTest { override fun evaluate() { reinitKoinIfNeeded() try { base.evaluate() } finally { + closeDB() stopKoin() } } @@ -48,5 +52,13 @@ class ReloadKoinModulesIfNecessaryTestRule : TestRule, KoinTest { modules(createAppModules(baseUrl)) } } + + private fun closeDB() { + try { + get().close() + } catch (ignored: Throwable) { + Log.d("ReloadKoinModulesRule", "Could not close db: $ignored, stacktrace: ${ignored.stackTraceToString()}") + } + } } } From fd661ca13d4d97f9458b2794f137506b950737c5 Mon Sep 17 00:00:00 2001 From: Gergely Hegedus Date: Wed, 20 Jul 2022 18:43:33 +0300 Subject: [PATCH 4/4] issue#116 Adjustments for timing of Delay This is required because otherwise clicks could become Long clicks --- .../idlingresources/IdlingResourcesHelper.kt | 12 ++++++++++-- .../idlingresources/OkHttp3IdlingResource.kt | 10 ++-------- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/test-util-android/src/main/java/org/fnives/test/showcase/android/testutil/synchronization/idlingresources/IdlingResourcesHelper.kt b/test-util-android/src/main/java/org/fnives/test/showcase/android/testutil/synchronization/idlingresources/IdlingResourcesHelper.kt index 7246c54..881896a 100644 --- a/test-util-android/src/main/java/org/fnives/test/showcase/android/testutil/synchronization/idlingresources/IdlingResourcesHelper.kt +++ b/test-util-android/src/main/java/org/fnives/test/showcase/android/testutil/synchronization/idlingresources/IdlingResourcesHelper.kt @@ -6,11 +6,18 @@ import org.fnives.test.showcase.android.testutil.synchronization.loopMainThreadF import java.util.concurrent.Executors // workaround, issue with idlingResources is tracked here https://github.com/robolectric/robolectric/issues/4807 -fun anyResourceNotIdle(): Boolean = (!IdlingRegistry.getInstance().resources.all(IdlingResource::isIdleNow)) +fun anyResourceNotIdle(): Boolean { + val anyResourceNotIdle = (!IdlingRegistry.getInstance().resources.all(IdlingResource::isIdleNow)) + if (!anyResourceNotIdle) { + // once it's idle we wait the Idling resource's time + OkHttp3IdlingResource.sleepForDispatcherDefaultCallInRetrofitErrorState() + } + return anyResourceNotIdle +} fun awaitIdlingResources() { + if (!anyResourceNotIdle()) return val idlingRegistry = IdlingRegistry.getInstance() - if (idlingRegistry.resources.all(IdlingResource::isIdleNow)) return val executor = Executors.newSingleThreadExecutor() var isIdle = false @@ -22,6 +29,7 @@ fun awaitIdlingResources() { idlingResource.awaitUntilIdle() } } while (!idlingRegistry.resources.all(IdlingResource::isIdleNow)) + OkHttp3IdlingResource.sleepForDispatcherDefaultCallInRetrofitErrorState() isIdle = true } while (!isIdle) { diff --git a/test-util-android/src/main/java/org/fnives/test/showcase/android/testutil/synchronization/idlingresources/OkHttp3IdlingResource.kt b/test-util-android/src/main/java/org/fnives/test/showcase/android/testutil/synchronization/idlingresources/OkHttp3IdlingResource.kt index a0c70d2..ac9c918 100644 --- a/test-util-android/src/main/java/org/fnives/test/showcase/android/testutil/synchronization/idlingresources/OkHttp3IdlingResource.kt +++ b/test-util-android/src/main/java/org/fnives/test/showcase/android/testutil/synchronization/idlingresources/OkHttp3IdlingResource.kt @@ -29,13 +29,7 @@ class OkHttp3IdlingResource private constructor( override fun getName(): String = name - override fun isIdleNow(): Boolean { - val isIdle = dispatcher.runningCallsCount() == 0 - if (isIdle) { - sleepForDispatcherDefaultCallInRetrofitErrorState() - } - return isIdle - } + override fun isIdleNow(): Boolean = dispatcher.runningCallsCount() == 0 override fun registerIdleTransitionCallback(callback: IdlingResource.ResourceCallback?) { this.callback = callback @@ -65,7 +59,7 @@ class OkHttp3IdlingResource private constructor( * * This brings us to this sleep for now. */ - private fun sleepForDispatcherDefaultCallInRetrofitErrorState() { + fun sleepForDispatcherDefaultCallInRetrofitErrorState() { Thread.sleep(200L) } }