diff --git a/.github/workflows/pull-request-jobs.yml b/.github/workflows/pull-request-jobs.yml index 6b087d8..920f129 100644 --- a/.github/workflows/pull-request-jobs.yml +++ b/.github/workflows/pull-request-jobs.yml @@ -75,7 +75,9 @@ jobs: if: always() with: name: JVM Test Results - path: ./**/build/reports/tests/**/*.html + path: | + ./**/build/reports/tests/**/*.html + ./**/**/build/reports/tests/**/*.html retention-days: 1 run-tests-on-emulator: @@ -126,7 +128,9 @@ jobs: if: always() with: name: Emulator-Test-Results-${{ matrix.api-level }} - path: ./**/build/reports/androidTests/**/*.html + path: | + ./**/build/reports/androidTests/**/*.html + ./**/**/build/reports/androidTests/**/*.html retention-days: 1 - name: Upload Test Screenshots uses: actions/upload-artifact@v2 diff --git a/app/src/androidTest/java/org/fnives/test/showcase/ui/AuthComposeInstrumentedTest.kt b/app/src/androidTest/java/org/fnives/test/showcase/ui/AuthComposeInstrumentedTest.kt index ac8f1b5..608b74a 100644 --- a/app/src/androidTest/java/org/fnives/test/showcase/ui/AuthComposeInstrumentedTest.kt +++ b/app/src/androidTest/java/org/fnives/test/showcase/ui/AuthComposeInstrumentedTest.kt @@ -1,21 +1,18 @@ package org.fnives.test.showcase.ui -import androidx.compose.ui.test.MainTestClock import androidx.compose.ui.test.junit4.StateRestorationTester import androidx.compose.ui.test.junit4.createComposeRule -import androidx.test.espresso.Espresso -import androidx.test.espresso.matcher.ViewMatchers import androidx.test.ext.junit.runners.AndroidJUnit4 import org.fnives.test.showcase.R import org.fnives.test.showcase.android.testutil.intent.DismissSystemDialogsRule import org.fnives.test.showcase.android.testutil.screenshot.ScreenshotRule -import org.fnives.test.showcase.android.testutil.viewaction.LoopMainThreadFor import org.fnives.test.showcase.compose.screen.AppNavigation import org.fnives.test.showcase.core.integration.fake.FakeUserDataLocalStorage import org.fnives.test.showcase.core.login.IsUserLoggedInUseCase import org.fnives.test.showcase.network.mockserver.scenario.auth.AuthScenario import org.fnives.test.showcase.testutils.MockServerScenarioSetupResetingTestRule import org.fnives.test.showcase.testutils.idling.DatabaseDispatcherTestRule +import org.fnives.test.showcase.ui.compose.idle.ComposeNetworkSynchronizationTestRule import org.junit.Before import org.junit.Rule import org.junit.Test @@ -29,7 +26,9 @@ class AuthComposeInstrumentedTest : KoinTest { private val composeTestRule = createComposeRule() private val stateRestorationTester = StateRestorationTester(composeTestRule) - private val mockServerScenarioSetupTestRule = MockServerScenarioSetupResetingTestRule() + private val mockServerScenarioSetupTestRule = MockServerScenarioSetupResetingTestRule( + networkSynchronizationTestRule = ComposeNetworkSynchronizationTestRule(composeTestRule) + ) private val mockServerScenarioSetup get() = mockServerScenarioSetupTestRule.mockServerScenarioSetup private val dispatcherTestRule = DatabaseDispatcherTestRule() private lateinit var robot: ComposeLoginRobot @@ -72,7 +71,7 @@ class AuthComposeInstrumentedTest : KoinTest { robot.assertLoading() composeTestRule.mainClock.autoAdvance = true - composeTestRule.mainClock.awaitIdlingResources() + composeTestRule.waitForIdle() navigationRobot.assertHomeScreen() } @@ -86,7 +85,7 @@ class AuthComposeInstrumentedTest : KoinTest { .assertUsername("banan") .clickOnLogin() - composeTestRule.mainClock.awaitIdlingResources() + composeTestRule.waitForIdle() robot.assertErrorIsShown(R.string.password_is_invalid) .assertNotLoading() navigationRobot.assertAuthScreen() @@ -103,7 +102,7 @@ class AuthComposeInstrumentedTest : KoinTest { .assertPassword("banan") .clickOnLogin() - composeTestRule.mainClock.awaitIdlingResources() + composeTestRule.waitForIdle() robot.assertErrorIsShown(R.string.username_is_invalid) .assertNotLoading() navigationRobot.assertAuthScreen() @@ -129,7 +128,7 @@ class AuthComposeInstrumentedTest : KoinTest { robot.assertLoading() composeTestRule.mainClock.autoAdvance = true - composeTestRule.mainClock.awaitIdlingResources() + composeTestRule.waitForIdle() robot.assertErrorIsShown(R.string.credentials_invalid) .assertNotLoading() navigationRobot.assertAuthScreen() @@ -155,7 +154,7 @@ class AuthComposeInstrumentedTest : KoinTest { robot.assertLoading() composeTestRule.mainClock.autoAdvance = true - composeTestRule.mainClock.awaitIdlingResources() + composeTestRule.waitForIdle() robot.assertErrorIsShown(R.string.something_went_wrong) .assertNotLoading() navigationRobot.assertAuthScreen() @@ -181,15 +180,5 @@ class AuthComposeInstrumentedTest : KoinTest { companion object { private const val SPLASH_DELAY = 600L - - // workaround, issue with idlingResources is tracked here https://github.com/robolectric/robolectric/issues/4807 - /** - * Await the idling resource on a different thread while looping main. - */ - fun MainTestClock.awaitIdlingResources() { - Espresso.onView(ViewMatchers.isRoot()).perform(LoopMainThreadFor(100L)) - - advanceTimeByFrame() - } } } diff --git a/app/src/androidTest/java/org/fnives/test/showcase/ui/ComposeLoginRobot.kt b/app/src/androidTest/java/org/fnives/test/showcase/ui/ComposeLoginRobot.kt index 1791ed1..5c0d410 100644 --- a/app/src/androidTest/java/org/fnives/test/showcase/ui/ComposeLoginRobot.kt +++ b/app/src/androidTest/java/org/fnives/test/showcase/ui/ComposeLoginRobot.kt @@ -1,10 +1,10 @@ package org.fnives.test.showcase.ui import android.content.Context +import androidx.compose.ui.test.SemanticsNodeInteractionsProvider import androidx.compose.ui.test.assertCountEquals import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertTextContains -import androidx.compose.ui.test.junit4.ComposeTestRule import androidx.compose.ui.test.onAllNodesWithTag import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.performClick @@ -13,8 +13,8 @@ import androidx.test.core.app.ApplicationProvider import org.fnives.test.showcase.compose.screen.auth.AuthScreenTag class ComposeLoginRobot( - composeTestRule: ComposeTestRule, -) : ComposeTestRule by composeTestRule { + semanticsNodeInteractionsProvider: SemanticsNodeInteractionsProvider, +) : SemanticsNodeInteractionsProvider by semanticsNodeInteractionsProvider { fun setUsername(username: String): ComposeLoginRobot = apply { onNodeWithTag(AuthScreenTag.UsernameInput).performTextInput(username) diff --git a/app/src/androidTest/java/org/fnives/test/showcase/ui/compose/idle/ComposeIdlingDisposable.kt b/app/src/androidTest/java/org/fnives/test/showcase/ui/compose/idle/ComposeIdlingDisposable.kt new file mode 100644 index 0000000..1f3efc0 --- /dev/null +++ b/app/src/androidTest/java/org/fnives/test/showcase/ui/compose/idle/ComposeIdlingDisposable.kt @@ -0,0 +1,22 @@ +package org.fnives.test.showcase.ui.compose.idle + +import androidx.compose.ui.test.IdlingResource +import androidx.compose.ui.test.junit4.ComposeTestRule +import org.fnives.test.showcase.android.testutil.synchronization.idlingresources.Disposable + +class ComposeIdlingDisposable( + private val idlingResource: IdlingResource, + private val testRule: ComposeTestRule, +) : Disposable { + override var isDisposed: Boolean = false + private set + + init { + testRule.registerIdlingResource(idlingResource) + } + + override fun dispose() { + isDisposed = true + testRule.unregisterIdlingResource(idlingResource) + } +} diff --git a/app/src/androidTest/java/org/fnives/test/showcase/ui/compose/idle/ComposeNetworkSynchronizationTestRule.kt b/app/src/androidTest/java/org/fnives/test/showcase/ui/compose/idle/ComposeNetworkSynchronizationTestRule.kt new file mode 100644 index 0000000..7f68107 --- /dev/null +++ b/app/src/androidTest/java/org/fnives/test/showcase/ui/compose/idle/ComposeNetworkSynchronizationTestRule.kt @@ -0,0 +1,50 @@ +package org.fnives.test.showcase.ui.compose.idle + +import android.util.Log +import androidx.annotation.CheckResult +import androidx.compose.ui.test.junit4.ComposeTestRule +import okhttp3.OkHttpClient +import org.fnives.test.showcase.android.testutil.synchronization.idlingresources.CompositeDisposable +import org.fnives.test.showcase.android.testutil.synchronization.idlingresources.Disposable +import org.fnives.test.showcase.android.testutil.synchronization.idlingresources.OkHttp3IdlingResource +import org.fnives.test.showcase.network.testutil.NetworkTestConfigurationHelper +import org.junit.rules.TestRule +import org.junit.runner.Description +import org.junit.runners.model.Statement +import org.koin.test.KoinTest + +class ComposeNetworkSynchronizationTestRule(private val composeTestRule: ComposeTestRule) : TestRule, KoinTest { + + private var disposable: Disposable? = null + + override fun apply(base: Statement, description: Description): Statement { + return object : Statement() { + override fun evaluate() { + disposable = registerIdlingResources() + try { + base.evaluate() + } finally { + dispose() + } + } + } + } + + fun dispose() { + if (disposable == null) { + Log.w("ComposeNetworkSynchronizationTestRule", "Was disposed, but registerIdlingResources was not called!") + } + disposable?.dispose() + } + + @CheckResult + private fun registerIdlingResources(): Disposable = getOkHttpClients() + .associateBy(keySelector = { it.toString() }) + .map { (key, client) -> OkHttp3IdlingResource.create(key, client) } + .map(::EspressoToComposeIdlingResourceAdapter) + .map { ComposeIdlingDisposable(it, composeTestRule) } + .let(::CompositeDisposable) + + private fun getOkHttpClients(): List = + NetworkTestConfigurationHelper.getOkHttpClients() +} diff --git a/app/src/androidTest/java/org/fnives/test/showcase/ui/compose/idle/EspressoToComposeIdlingResourceAdapter.kt b/app/src/androidTest/java/org/fnives/test/showcase/ui/compose/idle/EspressoToComposeIdlingResourceAdapter.kt new file mode 100644 index 0000000..866595b --- /dev/null +++ b/app/src/androidTest/java/org/fnives/test/showcase/ui/compose/idle/EspressoToComposeIdlingResourceAdapter.kt @@ -0,0 +1,7 @@ +package org.fnives.test.showcase.ui.compose.idle + +import androidx.test.espresso.IdlingResource + +class EspressoToComposeIdlingResourceAdapter(private val idlingResource: IdlingResource) : androidx.compose.ui.test.IdlingResource { + override val isIdleNow: Boolean get() = idlingResource.isIdleNow +} diff --git a/codekata/compose.instructionset.md b/codekata/compose.instructionset.md index 5b461ba..a5129b0 100644 --- a/codekata/compose.instructionset.md +++ b/codekata/compose.instructionset.md @@ -22,8 +22,8 @@ Here is a list of actions we want to do: ```kotlin class ComposeLoginRobot( - composeTestRule: ComposeTestRule, -) : ComposeTestRule by composeTestRule { + semanticsNodeInteractionsProvider: SemanticsNodeInteractionsProvider, +) : SemanticsNodeInteractionsProvider by semanticsNodeInteractionsProvider { fun setUsername(username: String): ComposeLoginRobot = apply { onNodeWithTag(AuthScreenTag.UsernameInput).performTextInput(username) @@ -60,11 +60,13 @@ class ComposeLoginRobot( } ``` -While in the View system we're using Espresso to interact with views, -in Compose we need a reference to the `ComposeTestRule` that contains our UI, +While in the View system we're using Espresso to interact with views, +in Compose we need a reference to the `SemanticsNodeInteractionsProvider` that contains our UI, which we will pass as a constructor parameter to the robot. -To create a `ComposeTestRule` you simply need to: +> SemanticsNodeInteractionsProvider gives access to `onNode` actions. ComposeTestRule extends it. + +To create a `ComposeTestRule` you simply need to: ```kotlin @get:Rule @@ -80,12 +82,12 @@ To add a tag to a composable use the `testTag` modifier in your UI, for example: Modifier.testTag(AuthScreenTag.UsernameInput) ``` -Once we have a node we can take actions such as `performClick()` or check assertions such as `assertTextContains`. +Once we have a node we can take actions such as `performClick()` or check assertions such as `assertTextContains`. For a list of finder, actions and assertions see the docs: https://developer.android.com/jetpack/compose/testing#testing-apis ##### Next up, we need to verify if we navigated: -If the navigation is also in compose we don't have an intent to check if we navigated. +If the navigation is also in compose we don't have an intent to check if we navigated. So instead, we're simply searching for regular composables that represent our destinations. This means that we could write a robot for our navigation which will simply check whether the root Composable for destination exists: @@ -102,7 +104,7 @@ This means that we could write a robot for our navigation which will simply chec ##### What about the Snackbar -Since everything in Compose is a composable, our Snackbar doesn't have anything special. +Since everything in Compose is a composable, our Snackbar doesn't have anything special. Put a tag on it and use the same finders and assertions. #### Test class setup @@ -111,7 +113,7 @@ The setup is the mostly the same as for View so for the sake of simplicity let's ##### Initializing the UI -We don't need an activity scenario. We will use instead `createComposeRule()` which will handle the host activity. +We don't need an activity scenario. We will use instead `createComposeRule()` which will handle the host activity. If you need a specific activity, use `createAndroidComposeRule()`. ```kotlin @@ -152,11 +154,15 @@ fun setup() { Network synchronization and mocking is the same as for View. ```kotlin -private val mockServerScenarioSetupTestRule = MockServerScenarioSetupResetingTestRule() +private val mockServerScenarioSetupTestRule = MockServerScenarioSetupResetingTestRule( + networkSynchronizationTestRule = ComposeNetworkSynchronizationTestRule(composeTestRule) +) private val mockServerScenarioSetup get() = mockServerScenarioSetupTestRule.mockServerScenarioSetup ``` -Coroutine setup is the same, except for `Dispatchers.setMain(dispatcher)`, which we don't need. +> ComposeNetworkSynchronizationTestRule is an equivalent to NetworkSynchronizationTestRule just registering the IdlingResource to ComposeTestRule instead of Espresso + +Coroutine setup is the same, except for `Dispatchers.setMain(dispatcher)`, which we don't need. ```kotlin private val dispatcherTestRule = DatabaseDispatcherTestRule() @@ -214,12 +220,13 @@ composeTestRule.mainClock.autoAdvance = true // Let clock auto advance again Lastly we check the navigation was correct, meaning we should be on the home screen: ```kotlin -composeTestRule.mainClock.awaitIdlingResources() // wait for login network call idling resource +composeTestRule.waitForIdle() // wait for login network call idling resource navigationRobot.assertHomeScreen() ``` -> `awaitIdlingResources` is an extension function to await all idling resources. -> Note: Considering what the docs say this shouldn't be necessarily if the idling resources are setup in Espresso, since the compose test rule is aware of espresso and it waits for idle before every finder. In practice it only works with the line above. Could be a bug somewhere. +> waitForIdle is necessary to wait for the Coroutine then the Network Call to finish. The Network call is running on OkHttps's own thread, so we use IdlingResources to synchronize with it. This is done in the ComposeNetworkSynchronizationTestRule. +> waitForIdle blocks the current thread while the Resources are busy. There is an alternative awaitIdle() which can be useful in runTest suspendable tests, feel free to look inside the Interface of ComposeTestRule. +> Basically since we have OkHttpIdlingResource as an EspressoIdlingResource we adapt that to Compose's IdlingResource class and register it with the ComposeTestRule and unregister it at the end. ### 2. `emptyPasswordShowsProperErrorMessage` @@ -240,7 +247,7 @@ robot.setUsername("banan") Finally we let coroutines go and verify the error is shown and we have not navigated: ```kotlin -composeTestRule.mainClock.awaitIdlingResources() +composeTestRule.waitForIdle() robot.assertErrorIsShown(R.string.password_is_invalid) .assertNotLoading() navigationRobot.assertAuthScreen() @@ -260,7 +267,7 @@ robot .assertPassword("banan") .clickOnLogin() -composeTestRule.mainClock.awaitIdlingResources() +composeTestRule.waitForIdle() robot.assertErrorIsShown(R.string.username_is_invalid) .assertNotLoading() navigationRobot.assertAuthScreen() @@ -293,7 +300,7 @@ composeTestRule.mainClock.autoAdvance = true Now at the end verify the error is shown properly: ```kotlin -composeTestRule.mainClock.awaitIdlingResources() +composeTestRule.waitForIdle() robot.assertErrorIsShown(R.string.credentials_invalid) .assertNotLoading() navigationRobot.assertAuthScreen() @@ -323,7 +330,7 @@ composeTestRule.mainClock.advanceTimeByFrame() robot.assertLoading() composeTestRule.mainClock.autoAdvance = true -composeTestRule.mainClock.awaitIdlingResources() +composeTestRule.waitForIdle() robot.assertErrorIsShown(R.string.something_went_wrong) .assertNotLoading() navigationRobot.assertAuthScreen() diff --git a/hilt/hilt-app/src/androidTest/java/org/fnives/test/showcase/hilt/ui/compose/AuthComposeInstrumentedTest.kt b/hilt/hilt-app/src/androidTest/java/org/fnives/test/showcase/hilt/ui/compose/AuthComposeInstrumentedTest.kt index 6ece289..211276e 100644 --- a/hilt/hilt-app/src/androidTest/java/org/fnives/test/showcase/hilt/ui/compose/AuthComposeInstrumentedTest.kt +++ b/hilt/hilt-app/src/androidTest/java/org/fnives/test/showcase/hilt/ui/compose/AuthComposeInstrumentedTest.kt @@ -1,31 +1,31 @@ package org.fnives.test.showcase.hilt.ui.compose -import androidx.compose.ui.test.MainTestClock import androidx.compose.ui.test.junit4.StateRestorationTester import androidx.compose.ui.test.junit4.createComposeRule -import androidx.test.espresso.Espresso -import androidx.test.espresso.matcher.ViewMatchers import androidx.test.ext.junit.runners.AndroidJUnit4 +import dagger.hilt.android.testing.HiltAndroidRule import dagger.hilt.android.testing.HiltAndroidTest import org.fnives.test.showcase.android.testutil.intent.DismissSystemDialogsRule import org.fnives.test.showcase.android.testutil.screenshot.ScreenshotRule -import org.fnives.test.showcase.android.testutil.viewaction.LoopMainThreadFor import org.fnives.test.showcase.hilt.R import org.fnives.test.showcase.hilt.compose.screen.AppNavigation import org.fnives.test.showcase.hilt.core.integration.fake.FakeUserDataLocalStorage import org.fnives.test.showcase.hilt.di.TestUserDataLocalStorageModule import org.fnives.test.showcase.hilt.test.shared.testutils.MockServerScenarioSetupTestRule import org.fnives.test.showcase.hilt.test.shared.testutils.idling.DatabaseDispatcherTestRule -import org.fnives.test.showcase.hilt.test.shared.ui.NetworkSynchronizedActivityTest +import org.fnives.test.showcase.hilt.ui.compose.idle.ComposeNetworkSyncHelper import org.fnives.test.showcase.network.mockserver.scenario.auth.AuthScenario +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 +import javax.inject.Inject @HiltAndroidTest @RunWith(AndroidJUnit4::class) -class AuthComposeInstrumentedTest : NetworkSynchronizedActivityTest() { +class AuthComposeInstrumentedTest { private val composeTestRule = createComposeRule() private val stateRestorationTester = StateRestorationTester(composeTestRule) @@ -36,6 +36,12 @@ class AuthComposeInstrumentedTest : NetworkSynchronizedActivityTest() { private lateinit var robot: ComposeLoginRobot private lateinit var navigationRobot: ComposeNavigationRobot + @Inject + lateinit var composeNetworkSyncHelper: ComposeNetworkSyncHelper + + @get:Rule + val hiltRule = HiltAndroidRule(this) + @Rule @JvmField val ruleOrder: RuleChain = RuleChain.outerRule(DismissSystemDialogsRule()) @@ -44,16 +50,22 @@ class AuthComposeInstrumentedTest : NetworkSynchronizedActivityTest() { .around(composeTestRule) .around(ScreenshotRule("test-showcase-compose")) - override fun setupBeforeInjection() { + @Before + fun setup() { TestUserDataLocalStorageModule.replacement = FakeUserDataLocalStorage() - } + hiltRule.inject() - override fun setupAfterInjection() { stateRestorationTester.setContent { AppNavigation() } robot = ComposeLoginRobot(composeTestRule) navigationRobot = ComposeNavigationRobot(composeTestRule) + composeNetworkSyncHelper.setup(composeTestRule) + } + + @After + fun tearDown() { + composeNetworkSyncHelper.tearDown() } /** GIVEN non empty password and username and successful response WHEN signIn THEN no error is shown and navigating to home */ @@ -76,11 +88,10 @@ class AuthComposeInstrumentedTest : NetworkSynchronizedActivityTest() { robot.assertLoading() composeTestRule.mainClock.autoAdvance = true - composeTestRule.mainClock.awaitIdlingResources() + composeTestRule.waitForIdle() navigationRobot.assertHomeScreen() } - /** GIVEN empty password and username WHEN signIn THEN error password is shown */ @Test fun emptyPasswordShowsProperErrorMessage() { composeTestRule.mainClock.advanceTimeBy(SPLASH_DELAY) @@ -90,7 +101,7 @@ class AuthComposeInstrumentedTest : NetworkSynchronizedActivityTest() { .assertUsername("banan") .clickOnLogin() - composeTestRule.mainClock.awaitIdlingResources() + composeTestRule.waitForIdle() robot.assertErrorIsShown(R.string.password_is_invalid) .assertNotLoading() navigationRobot.assertAuthScreen() @@ -107,7 +118,7 @@ class AuthComposeInstrumentedTest : NetworkSynchronizedActivityTest() { .assertPassword("banan") .clickOnLogin() - composeTestRule.mainClock.awaitIdlingResources() + composeTestRule.waitForIdle() robot.assertErrorIsShown(R.string.username_is_invalid) .assertNotLoading() navigationRobot.assertAuthScreen() @@ -133,7 +144,7 @@ class AuthComposeInstrumentedTest : NetworkSynchronizedActivityTest() { robot.assertLoading() composeTestRule.mainClock.autoAdvance = true - composeTestRule.mainClock.awaitIdlingResources() + composeTestRule.waitForIdle() robot.assertErrorIsShown(R.string.credentials_invalid) .assertNotLoading() navigationRobot.assertAuthScreen() @@ -159,7 +170,7 @@ class AuthComposeInstrumentedTest : NetworkSynchronizedActivityTest() { robot.assertLoading() composeTestRule.mainClock.autoAdvance = true - composeTestRule.mainClock.awaitIdlingResources() + composeTestRule.waitForIdle() robot.assertErrorIsShown(R.string.something_went_wrong) .assertNotLoading() navigationRobot.assertAuthScreen() @@ -185,15 +196,5 @@ class AuthComposeInstrumentedTest : NetworkSynchronizedActivityTest() { companion object { private const val SPLASH_DELAY = 600L - - // workaround, issue with idlingResources is tracked here https://github.com/robolectric/robolectric/issues/4807 - /** - * Await the idling resource on a different thread while looping main. - */ - fun MainTestClock.awaitIdlingResources() { - Espresso.onView(ViewMatchers.isRoot()).perform(LoopMainThreadFor(100L)) - - advanceTimeByFrame() - } } } diff --git a/hilt/hilt-app/src/androidTest/java/org/fnives/test/showcase/hilt/ui/compose/ComposeLoginRobot.kt b/hilt/hilt-app/src/androidTest/java/org/fnives/test/showcase/hilt/ui/compose/ComposeLoginRobot.kt index 913c350..a653854 100644 --- a/hilt/hilt-app/src/androidTest/java/org/fnives/test/showcase/hilt/ui/compose/ComposeLoginRobot.kt +++ b/hilt/hilt-app/src/androidTest/java/org/fnives/test/showcase/hilt/ui/compose/ComposeLoginRobot.kt @@ -1,10 +1,10 @@ package org.fnives.test.showcase.hilt.ui.compose import android.content.Context +import androidx.compose.ui.test.SemanticsNodeInteractionsProvider import androidx.compose.ui.test.assertCountEquals import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertTextContains -import androidx.compose.ui.test.junit4.ComposeTestRule import androidx.compose.ui.test.onAllNodesWithTag import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.performClick @@ -13,8 +13,8 @@ import androidx.test.core.app.ApplicationProvider import org.fnives.test.showcase.hilt.compose.screen.auth.AuthScreenTag class ComposeLoginRobot( - composeTestRule: ComposeTestRule, -) : ComposeTestRule by composeTestRule { + semanticsNodeInteractionsProvider: SemanticsNodeInteractionsProvider, +) : SemanticsNodeInteractionsProvider by semanticsNodeInteractionsProvider { fun setUsername(username: String): ComposeLoginRobot = apply { onNodeWithTag(AuthScreenTag.UsernameInput).performTextInput(username) diff --git a/hilt/hilt-app/src/androidTest/java/org/fnives/test/showcase/hilt/ui/compose/idle/ComposeIdlingDisposable.kt b/hilt/hilt-app/src/androidTest/java/org/fnives/test/showcase/hilt/ui/compose/idle/ComposeIdlingDisposable.kt new file mode 100644 index 0000000..e0e0936 --- /dev/null +++ b/hilt/hilt-app/src/androidTest/java/org/fnives/test/showcase/hilt/ui/compose/idle/ComposeIdlingDisposable.kt @@ -0,0 +1,22 @@ +package org.fnives.test.showcase.hilt.ui.compose.idle + +import androidx.compose.ui.test.IdlingResource +import androidx.compose.ui.test.junit4.ComposeTestRule +import org.fnives.test.showcase.android.testutil.synchronization.idlingresources.Disposable + +class ComposeIdlingDisposable( + private val idlingResource: IdlingResource, + private val testRule: ComposeTestRule, +) : Disposable { + override var isDisposed: Boolean = false + private set + + init { + testRule.registerIdlingResource(idlingResource) + } + + override fun dispose() { + isDisposed = true + testRule.unregisterIdlingResource(idlingResource) + } +} diff --git a/hilt/hilt-app/src/androidTest/java/org/fnives/test/showcase/hilt/ui/compose/idle/ComposeNetworkSyncHelper.kt b/hilt/hilt-app/src/androidTest/java/org/fnives/test/showcase/hilt/ui/compose/idle/ComposeNetworkSyncHelper.kt new file mode 100644 index 0000000..a44e1ef --- /dev/null +++ b/hilt/hilt-app/src/androidTest/java/org/fnives/test/showcase/hilt/ui/compose/idle/ComposeNetworkSyncHelper.kt @@ -0,0 +1,29 @@ +package org.fnives.test.showcase.hilt.ui.compose.idle + +import android.util.Log +import androidx.compose.ui.test.junit4.ComposeTestRule +import org.fnives.test.showcase.android.testutil.synchronization.idlingresources.CompositeDisposable +import org.fnives.test.showcase.android.testutil.synchronization.idlingresources.Disposable +import org.fnives.test.showcase.hilt.network.testutil.NetworkSynchronization +import javax.inject.Inject + +class ComposeNetworkSyncHelper @Inject constructor( + private val networkSynchronization: NetworkSynchronization, +) { + + private var disposable: Disposable? = null + + fun setup(composeTestRule: ComposeTestRule) { + disposable = networkSynchronization.networkIdlingResources() + .map(::EspressoToComposeIdlingResourceAdapter) + .map { ComposeIdlingDisposable(it, composeTestRule) } + .let(::CompositeDisposable) + } + + fun tearDown() { + if (disposable == null) { + Log.w("ComposeNetworkSyncHelper", "tearDown called, but setup wasn't!") + } + disposable?.dispose() + } +} diff --git a/hilt/hilt-app/src/androidTest/java/org/fnives/test/showcase/hilt/ui/compose/idle/EspressoToComposeIdlingResourceAdapter.kt b/hilt/hilt-app/src/androidTest/java/org/fnives/test/showcase/hilt/ui/compose/idle/EspressoToComposeIdlingResourceAdapter.kt new file mode 100644 index 0000000..a76b2c4 --- /dev/null +++ b/hilt/hilt-app/src/androidTest/java/org/fnives/test/showcase/hilt/ui/compose/idle/EspressoToComposeIdlingResourceAdapter.kt @@ -0,0 +1,7 @@ +package org.fnives.test.showcase.hilt.ui.compose.idle + +import androidx.test.espresso.IdlingResource + +class EspressoToComposeIdlingResourceAdapter(private val idlingResource: IdlingResource) : androidx.compose.ui.test.IdlingResource { + override val isIdleNow: Boolean get() = idlingResource.isIdleNow +} diff --git a/hilt/hilt-network-di-test-util/build.gradle b/hilt/hilt-network-di-test-util/build.gradle index 5db617c..55921d0 100644 --- a/hilt/hilt-network-di-test-util/build.gradle +++ b/hilt/hilt-network-di-test-util/build.gradle @@ -40,4 +40,5 @@ dependencies { implementation "com.squareup.retrofit2:retrofit:$retrofit_version" implementation project(':mockserver') implementation "androidx.test.espresso:espresso-core:$espresso_version" + implementation project(":test-util-android") } \ No newline at end of file diff --git a/hilt/hilt-network-di-test-util/src/main/java/org/fnives/test/showcase/hilt/network/testutil/NetworkSynchronization.kt b/hilt/hilt-network-di-test-util/src/main/java/org/fnives/test/showcase/hilt/network/testutil/NetworkSynchronization.kt index ffae218..d531f81 100644 --- a/hilt/hilt-network-di-test-util/src/main/java/org/fnives/test/showcase/hilt/network/testutil/NetworkSynchronization.kt +++ b/hilt/hilt-network-di-test-util/src/main/java/org/fnives/test/showcase/hilt/network/testutil/NetworkSynchronization.kt @@ -3,6 +3,7 @@ package org.fnives.test.showcase.hilt.network.testutil import androidx.annotation.CheckResult import androidx.test.espresso.IdlingResource import okhttp3.OkHttpClient +import org.fnives.test.showcase.android.testutil.synchronization.idlingresources.OkHttp3IdlingResource import org.fnives.test.showcase.hilt.network.di.SessionLessQualifier import org.fnives.test.showcase.hilt.network.di.SessionQualifier import javax.inject.Inject diff --git a/hilt/hilt-network-di-test-util/src/main/java/org/fnives/test/showcase/hilt/network/testutil/OkHttp3IdlingResource.kt b/hilt/hilt-network-di-test-util/src/main/java/org/fnives/test/showcase/hilt/network/testutil/OkHttp3IdlingResource.kt deleted file mode 100644 index 69423f2..0000000 --- a/hilt/hilt-network-di-test-util/src/main/java/org/fnives/test/showcase/hilt/network/testutil/OkHttp3IdlingResource.kt +++ /dev/null @@ -1,76 +0,0 @@ -package org.fnives.test.showcase.hilt.network.testutil - -import androidx.annotation.CheckResult -import androidx.annotation.NonNull -import androidx.test.espresso.IdlingResource -import okhttp3.Dispatcher -import okhttp3.OkHttpClient - -/** - * AndroidX version of Jake Wharton's OkHttp3IdlingResource. - * - * Reference: https://github.com/JakeWharton/okhttp-idling-resource/blob/master/src/main/java/com/jakewharton/espresso/OkHttp3IdlingResource.java - */ -class OkHttp3IdlingResource private constructor( - private val name: String, - private val dispatcher: Dispatcher -) : IdlingResource { - @Volatile - var callback: IdlingResource.ResourceCallback? = null - private var isIdleCallbackWasCalled: Boolean = true - - init { - val currentCallback = dispatcher.idleCallback - dispatcher.idleCallback = Runnable { - sleepForDispatcherDefaultCallInRetrofitErrorState() - callback?.onTransitionToIdle() - currentCallback?.run() - isIdleCallbackWasCalled = true - } - } - - override fun getName(): String = name - - override fun isIdleNow(): Boolean { - val isIdle = dispatcher.runningCallsCount() == 0 - if (isIdle) { - // sometime the callback is just not properly called it seems, or maybe sync error. - // if it isn't called Espresso crashes, so we add this here. - callback?.onTransitionToIdle() - } - return isIdle - } - - override fun registerIdleTransitionCallback(callback: IdlingResource.ResourceCallback?) { - this.callback = callback - } - - companion object { - /** - * Create a new [IdlingResource] from `client` as `name`. You must register - * this instance using `Espresso.registerIdlingResources`. - */ - @CheckResult - @NonNull - fun create(@NonNull name: String?, @NonNull client: OkHttpClient?): OkHttp3IdlingResource { - if (name == null) throw NullPointerException("name == null") - 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/idlingresources/OkHttp3IdlingResource.kt b/test-util-android/src/main/java/org/fnives/test/showcase/android/testutil/synchronization/idlingresources/OkHttp3IdlingResource.kt index 9a48319..258067b 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 @@ -17,29 +17,33 @@ class OkHttp3IdlingResource private constructor( ) : IdlingResource { @Volatile var callback: IdlingResource.ResourceCallback? = null + @Volatile private var isIdleCallbackWasCalled: Boolean = true + private val idleSync = Any() init { val currentCallback = dispatcher.idleCallback dispatcher.idleCallback = Runnable { - sleepForDispatcherDefaultCallInRetrofitErrorState() - callback?.onTransitionToIdle() - currentCallback?.run() - isIdleCallbackWasCalled = true + synchronized(idleSync) { + sleepForDispatcherDefaultCallInRetrofitErrorState() + callback?.onTransitionToIdle() + currentCallback?.run() + isIdleCallbackWasCalled = true + } } } override fun getName(): String = name - override fun isIdleNow(): Boolean { - val isIdle = dispatcher.runningCallsCount() == 0 - if (isIdle) { - // sometime the callback is just not properly called it seems, or maybe sync error. - // if it isn't called Espresso crashes, so we add this here. - callback?.onTransitionToIdle() + override fun isIdleNow(): Boolean = + synchronized(idleSync) { + val isIdle = dispatcher.runningCallsCount() == 0 + if (!isIdle) { + isIdleCallbackWasCalled = false + } + + return@synchronized isIdle && isIdleCallbackWasCalled } - return isIdle - } override fun registerIdleTransitionCallback(callback: IdlingResource.ResourceCallback?) { this.callback = callback