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 ec0a5b57..bf3ad720 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()}") + } + } } } 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 7b154a6b..60b5acf1 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/synchronization/idlingresources/IdlingResourcesHelper.kt b/test-util-android/src/main/java/org/fnives/test/showcase/android/testutil/synchronization/idlingresources/IdlingResourcesHelper.kt index 7246c54e..881896a9 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 f0770ffd..ac9c9185 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() } @@ -46,5 +47,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. + */ + 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 25a35183..2c7d4d0c 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)) } 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 00000000..7e54a7a3 --- /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() + } +}