From c38e608c8cf7fb566dacbcdb18ea40d2091d31db Mon Sep 17 00:00:00 2001 From: Gergely Hegedus Date: Fri, 28 Jan 2022 21:42:31 +0200 Subject: [PATCH 1/5] Issue#13 Finish Robolectric SharedPreferences test description --- .../CodeKataUserDataLocalStorageTest.kt | 28 +++ .../storage/UserDataLocalStorageTest.kt | 73 +++++++ ...ContentLocalStorageImplInstrumentedTest.kt | 28 ++- codekata/robolectric.instructionset.md | 204 ++++++++++++++---- .../fake/FakeFavouriteContentLocalStorage.kt | 0 .../fake/FakeUserDataLocalStorage.kt | 0 6 files changed, 281 insertions(+), 52 deletions(-) create mode 100644 app/src/robolectricTest/java/org/fnives/test/showcase/storage/CodeKataUserDataLocalStorageTest.kt create mode 100644 app/src/robolectricTest/java/org/fnives/test/showcase/storage/UserDataLocalStorageTest.kt rename core/src/{test => testFixtures}/java/org/fnives/test/showcase/core/integration/fake/FakeFavouriteContentLocalStorage.kt (100%) rename core/src/{test => testFixtures}/java/org/fnives/test/showcase/core/integration/fake/FakeUserDataLocalStorage.kt (100%) diff --git a/app/src/robolectricTest/java/org/fnives/test/showcase/storage/CodeKataUserDataLocalStorageTest.kt b/app/src/robolectricTest/java/org/fnives/test/showcase/storage/CodeKataUserDataLocalStorageTest.kt new file mode 100644 index 0000000..1316dd8 --- /dev/null +++ b/app/src/robolectricTest/java/org/fnives/test/showcase/storage/CodeKataUserDataLocalStorageTest.kt @@ -0,0 +1,28 @@ +package org.fnives.test.showcase.storage + +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.jupiter.api.Disabled + +@Disabled("CodeKata") +class CodeKataUserDataLocalStorageTest { + + @Before + fun setup() { + } + + @After + fun tearDown() { + } + + /** GIVEN session value WHEN accessed THEN it's returned **/ + @Test + fun sessionSetWillStayBeKept() { + } + + /** GIVEN null value WHEN accessed THEN it's null **/ + @Test + fun sessionSetToNullWillStayNull() { + } +} \ No newline at end of file diff --git a/app/src/robolectricTest/java/org/fnives/test/showcase/storage/UserDataLocalStorageTest.kt b/app/src/robolectricTest/java/org/fnives/test/showcase/storage/UserDataLocalStorageTest.kt new file mode 100644 index 0000000..f378186 --- /dev/null +++ b/app/src/robolectricTest/java/org/fnives/test/showcase/storage/UserDataLocalStorageTest.kt @@ -0,0 +1,73 @@ +package org.fnives.test.showcase.storage + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import org.fnives.test.showcase.core.integration.fake.FakeUserDataLocalStorage +import org.fnives.test.showcase.core.storage.UserDataLocalStorage +import org.fnives.test.showcase.model.session.Session +import org.junit.After +import org.junit.Assert +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.koin.core.context.GlobalContext.stopKoin +import org.koin.test.KoinTest +import org.robolectric.ParameterizedRobolectricTestRunner + +@RunWith(ParameterizedRobolectricTestRunner::class) +class UserDataLocalStorageTest( + private val userDataLocalStorageFactory: () -> UserDataLocalStorage +) : KoinTest { + + private lateinit var userDataLocalStorage: UserDataLocalStorage + + @Before + fun setup() { + userDataLocalStorage = userDataLocalStorageFactory.invoke() + } + + @After + fun tearDown() { + stopKoin() + } + + /** GIVEN session value WHEN accessed THEN it's returned **/ + @Test + fun sessionSetWillStayBeKept() { + val session = Session(accessToken = "a", refreshToken = "b") + userDataLocalStorage.session = session + + val actual = userDataLocalStorage.session + + Assert.assertEquals(session, actual) + } + + /** GIVEN null value WHEN accessed THEN it's null **/ + @Test + fun sessionSetToNullWillStayNull() { + userDataLocalStorage.session = Session(accessToken = "a", refreshToken = "b") + + userDataLocalStorage.session = null + val actual = userDataLocalStorage.session + + Assert.assertEquals(null, actual) + } + + companion object { + + private fun createFake(): UserDataLocalStorage = FakeUserDataLocalStorage() + + private fun createReal(): UserDataLocalStorage { + val context = ApplicationProvider.getApplicationContext() + + return SharedPreferencesManagerImpl.create(context) + } + + @JvmStatic + @ParameterizedRobolectricTestRunner.Parameters + fun userDataLocalStorageFactories(): List<() -> UserDataLocalStorage> = listOf( + ::createFake, + ::createReal + ) + } +} \ No newline at end of file diff --git a/app/src/robolectricTest/java/org/fnives/test/showcase/storage/favourite/FavouriteContentLocalStorageImplInstrumentedTest.kt b/app/src/robolectricTest/java/org/fnives/test/showcase/storage/favourite/FavouriteContentLocalStorageImplInstrumentedTest.kt index b527b76..ae52bb0 100644 --- a/app/src/robolectricTest/java/org/fnives/test/showcase/storage/favourite/FavouriteContentLocalStorageImplInstrumentedTest.kt +++ b/app/src/robolectricTest/java/org/fnives/test/showcase/storage/favourite/FavouriteContentLocalStorageImplInstrumentedTest.kt @@ -1,6 +1,5 @@ package org.fnives.test.showcase.storage.favourite -import androidx.test.ext.junit.runners.AndroidJUnit4 import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.async import kotlinx.coroutines.flow.first @@ -11,6 +10,7 @@ import kotlinx.coroutines.test.TestCoroutineScheduler import kotlinx.coroutines.test.TestDispatcher import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest +import org.fnives.test.showcase.core.integration.fake.FakeFavouriteContentLocalStorage import org.fnives.test.showcase.core.storage.content.FavouriteContentLocalStorage import org.fnives.test.showcase.model.content.ContentId import org.fnives.test.showcase.storage.database.DatabaseInitialization @@ -21,20 +21,24 @@ import org.junit.Test import org.junit.runner.RunWith import org.koin.core.context.stopKoin import org.koin.test.KoinTest -import org.koin.test.inject +import org.koin.test.get +import org.robolectric.ParameterizedRobolectricTestRunner @Suppress("TestFunctionName") @OptIn(ExperimentalCoroutinesApi::class) -@RunWith(AndroidJUnit4::class) -internal class FavouriteContentLocalStorageImplInstrumentedTest : KoinTest { +@RunWith(ParameterizedRobolectricTestRunner::class) +internal class FavouriteContentLocalStorageImplInstrumentedTest( + private val favouriteContentLocalStorageFactory: KoinTest.() -> FavouriteContentLocalStorage +) : KoinTest { - private val sut by inject() + private lateinit var sut: FavouriteContentLocalStorage private lateinit var testDispatcher: TestDispatcher @Before fun setUp() { testDispatcher = StandardTestDispatcher(TestCoroutineScheduler()) DatabaseInitialization.dispatcher = testDispatcher + sut = favouriteContentLocalStorageFactory() } @After @@ -117,4 +121,18 @@ internal class FavouriteContentLocalStorageImplInstrumentedTest : KoinTest { Assert.assertFalse(actual.isCompleted) actual.cancel() } + + companion object { + + private fun createFake(): FavouriteContentLocalStorage = FakeFavouriteContentLocalStorage() + + private fun KoinTest.createReal(): FavouriteContentLocalStorage = get() + + @JvmStatic + @ParameterizedRobolectricTestRunner.Parameters + fun favouriteContentLocalStorageFactories(): List FavouriteContentLocalStorage> = listOf( + { createFake() }, + { createReal() } + ) + } } diff --git a/codekata/robolectric.instructionset.md b/codekata/robolectric.instructionset.md index 7dcf81f..7f2c2ee 100644 --- a/codekata/robolectric.instructionset.md +++ b/codekata/robolectric.instructionset.md @@ -1,6 +1,6 @@ # 5. Starting of Robolectric testing -So we are finally here, so far we didn't had to touch any kind of context or resources, activities, fragments or anything android. This is where we have to get back to reality and actually deal with Android. +So we are finally here, so far we didn't had to touch any kind of context or resources, activities, fragments or anything Android. This is where we have to get back to reality and actually deal with Android. In this testing instruction set you will learn how to write simple tests using Robolectric. @@ -10,6 +10,146 @@ In this testing instruction set you will learn how to write simple tests using R - Learn what a Robolectric Shadow is - And Learn how to write basic UI tests +## `CodeKataUserDataLocalStorageTest` + +Let's start with something easy: +Our System Under Test will be `org.fnives.test.showcase.storage.SharedPreferencesManagerImpl` +But we only test their interface functions. + +We don't add anything Robolectric just yet, let's try to do this without it first. + +Let's setup or System Under Test as usual: +```kotlin +private lateinit var sut: UserDataLocalStorage + +@Before +fun setup() { + sut = SharedPreferencesManagerImpl.create(mock()) +} +``` + +And if we run our test class we already get an exception: + +> sharedPreferences must not be null +> java.lang.NullPointerException: sharedPreferences must not be null + at org.fnives.test.showcase.storage.SharedPreferencesManagerImpl$Companion.create(SharedPreferencesManagerImpl.kt:65) + +So we need to mock the creation of `SharedPreferences`, then the `SharedPreferences` as well. +Since our classes main purpose is to handle `SharedPreferences`, that doesn't really make sense. + +Well, I would rather not do that. So then we need to test on a Real Device or Emulator. Well we could, but then we need to integrate a Testing Farm with our CI. It would be good to do that, but sometimes that's just not possible, here is where [Robolectric](http://robolectric.org/) comes in. + +>Robolectric is the industry-standard unit testing framework for Android. With Robolectric, your tests run in a simulated Android environment inside a JVM, without the overhead and flakiness of an emulator. Robolectric tests routinely run 10x faster than those on cold-started emulators. + +### Setup with Robolectric + +We already have the dependencies in the project. +We need to annotate our class with `@RunWith(AndroidJUnit4::class)` +With this Robolectric actually starts our `TestShowcaseApplication` so we need to stop Koin after our tests: +```kotlin +@RunWith(AndroidJUnit4::class) +class CodeKataUserDataLocalStorageTest: KoinTest { + + //... + @After + fun tearDown() { + stopKoin() + } +``` + +Okay, now we just need to get a context. With Robolectric we can get our application class the following way: + +```kotlin +val application = ApplicationProvider.getApplicationContext() +sut = SharedPreferencesManagerImpl.create(application) +``` + +With that, we can start testing: + +### 1. `sessionSetWillStayBeKept` + +Well, our tests will be pretty simple since the interface itsell will be pretty simple. +We set a value and we just verify its kept: + +```kotlin +val session = Session(accessToken = "a", refreshToken = "b") +sut.session = session + +val actual = sut.session + +Assert.assertEquals(session, actual) +``` + +With that our first test is already done, + +### 2. `sessionSetToNullWillStayNull` + +Here we almost have the same test, we just use null. Personally I also set the value beforehand. +But you should be able to do this easily on your own. For completeness sake: +```kotlin +sut.session = Session(accessToken = "a", refreshToken = "b") + +sut.session = null +val actual = sut.session + +Assert.assertEquals(null, actual) +``` + +### 3. Fake + +So if you are doing these instructions in order, you may remember that in our core integration tests, namely `org.fnives.test.showcase.core.integration.CodeKataAuthIntegrationTest` we actually had Fake implementation of this class. +But we never verified that the Fake behaves exactly as will the real thing, so let's do that. +Sadly we can't depend on the `org.fnives.test.showcase.core.integration.fake.CodeKataUserDataLocalStorage` since it's in a test module. +However with usage of testFixtures we are able to share test classes as we had previously shared an Extension. +Take a look `at code/src/testFixtures/java`, in package `org.fnives.test.showcase.core.integration.fake` We have a `FakeUserDataLocalStorage`. We can use that since it's in the testFixture. + +> Reminder: Test fixture plugin creates a new testFixture sourceset where main <- testFixture <- test dependency is created. +> Also one can depend on another modules testFixtures via testImplementation testFixtures(project('')) + +So what's better way is there to verify the `Fake` than testing it with the `Real` implementation's test case? + +To do that we will parametrize our test. Note, it will be different than previous, since it's junit4 and Robolectric. + +Let's modify our annotation and Test Class constructor: +```kotlin +@RunWith(ParameterizedRobolectricTestRunner::class) +class CodeKataUserDataLocalStorageTest(val userDataLocalStorageFactory: () -> UserDataLocalStorage) : TestKoin { + //... +} +``` + +Then we create our parameters: +```kotlin +companion object { + + private fun createFake(): UserDataLocalStorage = FakeUserDataLocalStorage() + + private fun createReal(): UserDataLocalStorage { + val context = ApplicationProvider.getApplicationContext() + + return SharedPreferencesManagerImpl.create(context) + } + + @JvmStatic // notice it needs to be static + @ParameterizedRobolectricTestRunner.Parameters // notice the annotation + // notice the return List's type parameter matches the constructor of CodeKataUserDataLocalStorageTest + fun userDataLocalStorageFactories(): List<() -> UserDataLocalStorage> = listOf( + ::createFake, + ::createReal + ) +} +``` + +Now we just change how we create our SUT: +```kotlin +@Before +fun setup() { + sut = userDataLocalStorageFactory.invoke() +} +``` + +Now we validated our fake implementation as well. With this we can be sure our previous integration tests were indeed correct. + ## FavouriteContentLocalStorage test Our System Under Test will be `org.fnives.test.showcase.core.storage.content.FavouriteContentLocalStorage` or more precisely it's implementation: `org.fnives.test.showcase.storage.favourite.FavouriteContentLocalStorageImpl` @@ -25,47 +165,12 @@ So let's start with the setup. Our test class is `org.fnives.test.showcase.storage.favourite.CodeKataFavouriteContentLocalStorageInstrumentedTest` -Question: Why don't we test the DAO and Storage separately using mocking? -Answer: The same logic applies how we didn't test the RetrofitServices just the RemoteSources. The Service just like the DAO is an implementation detail, our code only accesses them through the RemoteSource / LocalStorage abstraction. With this in mind now we only want to test that we interact with the database properly, we don't really care how many DAOs are used. +> Question: Why don't we test the DAO and Storage separately using mocking? -We don't add anything Robolectric just yet, let's try to do this without it first. +>Answer: The same logic applies how we didn't test the RetrofitServices just the RemoteSources. The Service just like the DAO is an implementation detail, our code only accesses them through the RemoteSource / LocalStorage abstraction. With this in mind now we only want to test that we interact with the database properly, we don't really care how many DAOs are used. -Let's setup or System Under Test as usual: +We again need Robolectric to create a Room Database. -```kotlin -private lateinit var sut: FavouriteContentLocalStorage // notice we only care about the interface - -@Before -fun setup() { - val room = Room.inMemoryDatabaseBuilder(mock(), LocalDatabase::class.java) // we are using inmemory, cause we don't really want to create files. - .allowMainThreadQueries() // we don't really care about threading for now - .build() - - sut = FavouriteContentLocalStorageImpl(room.favouriteDao) -} - -@Test -fun atTheStartOurDatabaseIsEmpty() = runBlocking { - // we just verify our setup is correct - sut.observeFavourites().first() -} -``` - -Let's run our test and see: - -> Method getWritableDatabase in android.database.sqlite.SQLiteOpenHelper not mocked. See http://g.co/androidstudio/not-mocked for details. -> java.lang.RuntimeException: Method getWritableDatabase in android.database.sqlite.SQLiteOpenHelper not mocked. See http://g.co/androidstudio/not-mocked for details. -> at android.database.sqlite.SQLiteOpenHelper.getWritableDatabase(SQLiteOpenHelper.java) - - -So we need to mock something inside the `SQLiteOpenHelper` which is used inside the Dao and Room in order to test the Database. -Well, I would rather not do that. So then we need to test on a Real Device or Emulator. Well we could, but then we need to integrate a Testing Farm with our CI. It would be good to do that, but sometimes that's just not possible, here is where [Robolectric](http://robolectric.org/) comes in. - ->Robolectric is the industry-standard unit testing framework for Android. With Robolectric, your tests run in a simulated Android environment inside a JVM, without the overhead and flakiness of an emulator. Robolectric tests routinely run 10x faster than those on cold-started emulators. - -### Setup with Robolectric - -We already have the dependencies in the project. We need to annotate our class with `@RunWith(AndroidJUnit4::class)` With this Robolectric actually starts our `TestShowcaseApplication` so instead of creating our SUT, we just inject it. However to easily inject with Koin, we extend `KoinTest`: ```kotlin @@ -73,14 +178,12 @@ With this Robolectric actually starts our `TestShowcaseApplication` so instead o class CodeKataFavouriteContentLocalStorage: KoinTest ``` -So additional changes will be: -- remove our previous mocking attempt - we inject our SUT - we stop koin in tearDown - we add a testDispatcher to Room - we switch to runTest(testDispatcher) -Since Room has their own exercutors, that could make our tests flaky, since we might get out of sync. Luckily we can switch out these executors, so we do that to make sure our tests run just as we would like them to. +Since Room has their own exercutors, that could make our tests flaky, since it might get out of sync. Luckily we can switch out these executors, so we do that to make sure our tests run just as we would like them to. ``` private val sut by inject() @@ -103,13 +206,12 @@ fun atTheStartOurDatabaseIsEmpty()= runTest(testDispatcher) { } ``` -The line `DatabaseInitialization.dispatcher = testDispatcher` may look a bit mysterious, but all we do her is overwrite our iriginal DatabaseInitialization in tests, and use the given Dispatcher as an executor for Room setup. - -Now if we run our test we see we can indeed access the database. We can get down to actual testing. +The line `DatabaseInitialization.dispatcher = testDispatcher` may look a bit mysterious, but all we do her is overwrite our original DatabaseInitialization in tests, and use the given Dispatcher as an executor for Room setup. ### 1. `atTheStartOurDatabaseIsEmpty` -Since we used this test for our setup, we just need to finish it. We just verify the returned list is empty, so: +Our test is as simple as it gets. We get the observable and it's first element. Then we assert that it is an empty list. + ```kotlin @Test fun atTheStartOurDatabaseIsEmpty() = runTest(testDispatcher) { @@ -187,7 +289,7 @@ Assert.assertEquals(expected, actual.getCompleted()) ##### Note: we can use turbine as well to verify our flows, just like we did previously -### 5. `removingFavouriteUpdatesExistingObservers` +### 5. `removingFavouriteUpdatesExistingObservers` Okay, this should be really similar to `addingFavouriteUpdatesExistingObservers` just with a hint of `contentIdAddedThenRemovedCanNoLongerBeReadOut` so try to write it on your own. @@ -233,5 +335,13 @@ actual.cancel() With that we know how to verify our Database running on the JVM, without needing an emulator or device. +### Fake + +We also have created a `FakeFavouriteContentLocalStorage` previously. We can verify that also using the same parameterization. +However this is an optional exercise. +> Hint: we can use KoinTest.() -> T lambdas as well. And KoinTest.get() function. + +If you want to check it out, `FavouriteContentLocalStorageImplInstrumentedTest` does exactly that. + ## Conclusion diff --git a/core/src/test/java/org/fnives/test/showcase/core/integration/fake/FakeFavouriteContentLocalStorage.kt b/core/src/testFixtures/java/org/fnives/test/showcase/core/integration/fake/FakeFavouriteContentLocalStorage.kt similarity index 100% rename from core/src/test/java/org/fnives/test/showcase/core/integration/fake/FakeFavouriteContentLocalStorage.kt rename to core/src/testFixtures/java/org/fnives/test/showcase/core/integration/fake/FakeFavouriteContentLocalStorage.kt diff --git a/core/src/test/java/org/fnives/test/showcase/core/integration/fake/FakeUserDataLocalStorage.kt b/core/src/testFixtures/java/org/fnives/test/showcase/core/integration/fake/FakeUserDataLocalStorage.kt similarity index 100% rename from core/src/test/java/org/fnives/test/showcase/core/integration/fake/FakeUserDataLocalStorage.kt rename to core/src/testFixtures/java/org/fnives/test/showcase/core/integration/fake/FakeUserDataLocalStorage.kt From 03e413fba68f083046ed326ed31b766edb54b274 Mon Sep 17 00:00:00 2001 From: Gergely Hegedus Date: Fri, 28 Jan 2022 23:48:49 +0200 Subject: [PATCH 2/5] Issue#13 Add CodeKata for Robolectric Tests --- app/build.gradle | 2 +- .../AndroidTestSnackbarVerificationHelper.kt | 44 --- .../SpecificTestConfigurationsFactory.kt | 3 - .../RobolectricSnackbarVerificationHelper.kt | 19 - .../SpecificTestConfigurationsFactory.kt | 3 - .../testutils/shadow/ShadowSnackbar.kt | 100 ----- .../shadow/ShadowSnackbarResetTestRule.kt | 20 - ...RobolectricAuthActivityInstrumentedTest.kt | 166 +++++++++ .../test/showcase/ui/RobolectricLoginRobot.kt | 74 ++++ .../CodeKataAuthActivityInstrumentedTest.kt | 51 +++ .../ui/codekata/CodeKataLoginRobot.kt | 4 + .../SnackbarVerificationHelper.kt | 41 ++- .../TestConfigurationsFactory.kt | 2 - .../idling/MainDispatcherTestRule.kt | 14 +- .../test/showcase/testutils/robot/Robot.kt | 8 - .../showcase/testutils/robot/RobotTestRule.kt | 20 - .../statesetup/SetupAuthenticationState.kt | 52 +-- .../fnives/test/showcase/ui/home/HomeRobot.kt | 14 +- .../ui/home/MainActivityInstrumentedTest.kt | 8 +- .../ui/login/AuthActivityInstrumentedTest.kt | 13 +- .../test/showcase/ui/login/LoginRobot.kt | 18 +- .../splash/SplashActivityInstrumentedTest.kt | 13 +- .../test/showcase/ui/splash/SplashRobot.kt | 17 +- app/src/test/resources/robolectric.properties | 2 +- codekata/robolectric.instructionset.md | 346 ++++++++++++++++++ 25 files changed, 758 insertions(+), 296 deletions(-) delete mode 100644 app/src/androidTest/java/org/fnives/test/showcase/testutils/configuration/AndroidTestSnackbarVerificationHelper.kt delete mode 100644 app/src/robolectricTest/java/org/fnives/test/showcase/testutils/configuration/RobolectricSnackbarVerificationHelper.kt delete mode 100644 app/src/robolectricTest/java/org/fnives/test/showcase/testutils/shadow/ShadowSnackbar.kt delete mode 100644 app/src/robolectricTest/java/org/fnives/test/showcase/testutils/shadow/ShadowSnackbarResetTestRule.kt create mode 100644 app/src/robolectricTest/java/org/fnives/test/showcase/ui/RobolectricAuthActivityInstrumentedTest.kt create mode 100644 app/src/robolectricTest/java/org/fnives/test/showcase/ui/RobolectricLoginRobot.kt create mode 100644 app/src/robolectricTest/java/org/fnives/test/showcase/ui/codekata/CodeKataAuthActivityInstrumentedTest.kt create mode 100644 app/src/robolectricTest/java/org/fnives/test/showcase/ui/codekata/CodeKataLoginRobot.kt delete mode 100644 app/src/sharedTest/java/org/fnives/test/showcase/testutils/robot/Robot.kt delete mode 100644 app/src/sharedTest/java/org/fnives/test/showcase/testutils/robot/RobotTestRule.kt diff --git a/app/build.gradle b/app/build.gradle index e27ddf4..a6ec148 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -38,7 +38,7 @@ android { sourceSets { androidTest { - java.srcDirs += "src/sharedTest/java" +// java.srcDirs += "src/sharedTest/java" assets.srcDirs += files("$projectDir/schemas".toString()) } test { diff --git a/app/src/androidTest/java/org/fnives/test/showcase/testutils/configuration/AndroidTestSnackbarVerificationHelper.kt b/app/src/androidTest/java/org/fnives/test/showcase/testutils/configuration/AndroidTestSnackbarVerificationHelper.kt deleted file mode 100644 index 411a634..0000000 --- a/app/src/androidTest/java/org/fnives/test/showcase/testutils/configuration/AndroidTestSnackbarVerificationHelper.kt +++ /dev/null @@ -1,44 +0,0 @@ -package org.fnives.test.showcase.testutils.configuration - -import android.view.View -import androidx.annotation.StringRes -import androidx.test.espresso.Espresso -import androidx.test.espresso.UiController -import androidx.test.espresso.ViewAction -import androidx.test.espresso.action.ViewActions -import androidx.test.espresso.assertion.ViewAssertions -import androidx.test.espresso.matcher.ViewMatchers -import com.google.android.material.R -import com.google.android.material.snackbar.Snackbar -import org.hamcrest.Matcher -import org.hamcrest.Matchers -import org.junit.runner.Description -import org.junit.runners.model.Statement - -object AndroidTestSnackbarVerificationHelper : SnackbarVerificationHelper { - - override fun apply(base: Statement, description: Description): Statement = base - - override fun assertIsShownWithText(@StringRes stringResID: Int) { - Espresso.onView(ViewMatchers.withId(R.id.snackbar_text)) - .check(ViewAssertions.matches(ViewMatchers.withText(stringResID))) - Espresso.onView(ViewMatchers.isAssignableFrom(Snackbar.SnackbarLayout::class.java)).perform(ViewActions.swipeRight()) - Espresso.onView(ViewMatchers.isRoot()).perform(LoopMainUntilSnackbarDismissed()) - } - - override fun assertIsNotShown() { - Espresso.onView(ViewMatchers.withId(R.id.snackbar_text)).check(ViewAssertions.doesNotExist()) - } - - class LoopMainUntilSnackbarDismissed() : ViewAction { - override fun getConstraints(): Matcher = Matchers.isA(View::class.java) - - override fun getDescription(): String = "loop MainThread until Snackbar is Dismissed" - - override fun perform(uiController: UiController, view: View?) { - while (view?.findViewById(com.google.android.material.R.id.snackbar_text) != null) { - uiController.loopMainThreadForAtLeast(100) - } - } - } -} diff --git a/app/src/androidTest/java/org/fnives/test/showcase/testutils/configuration/SpecificTestConfigurationsFactory.kt b/app/src/androidTest/java/org/fnives/test/showcase/testutils/configuration/SpecificTestConfigurationsFactory.kt index f35cb9d..974ff95 100644 --- a/app/src/androidTest/java/org/fnives/test/showcase/testutils/configuration/SpecificTestConfigurationsFactory.kt +++ b/app/src/androidTest/java/org/fnives/test/showcase/testutils/configuration/SpecificTestConfigurationsFactory.kt @@ -2,9 +2,6 @@ package org.fnives.test.showcase.testutils.configuration object SpecificTestConfigurationsFactory : TestConfigurationsFactory { - override fun createSnackbarVerification(): SnackbarVerificationHelper = - AndroidTestSnackbarVerificationHelper - override fun createSharedMigrationTestRuleFactory(): SharedMigrationTestRuleFactory = AndroidMigrationTestRuleFactory } diff --git a/app/src/robolectricTest/java/org/fnives/test/showcase/testutils/configuration/RobolectricSnackbarVerificationHelper.kt b/app/src/robolectricTest/java/org/fnives/test/showcase/testutils/configuration/RobolectricSnackbarVerificationHelper.kt deleted file mode 100644 index edde912..0000000 --- a/app/src/robolectricTest/java/org/fnives/test/showcase/testutils/configuration/RobolectricSnackbarVerificationHelper.kt +++ /dev/null @@ -1,19 +0,0 @@ -package org.fnives.test.showcase.testutils.configuration - -import androidx.annotation.StringRes -import org.fnives.test.showcase.testutils.shadow.ShadowSnackbar -import org.fnives.test.showcase.testutils.shadow.ShadowSnackbarResetTestRule -import org.junit.Assert -import org.junit.rules.TestRule - -object RobolectricSnackbarVerificationHelper : SnackbarVerificationHelper, TestRule by ShadowSnackbarResetTestRule() { - - override fun assertIsShownWithText(@StringRes stringResID: Int) { - val latestSnackbar = ShadowSnackbar.latestSnackbar ?: throw IllegalStateException("Snackbar not found") - Assert.assertEquals(latestSnackbar.context.getString(stringResID), ShadowSnackbar.textOfLatestSnackbar) - } - - override fun assertIsNotShown() { - Assert.assertNull(ShadowSnackbar.latestSnackbar) - } -} diff --git a/app/src/robolectricTest/java/org/fnives/test/showcase/testutils/configuration/SpecificTestConfigurationsFactory.kt b/app/src/robolectricTest/java/org/fnives/test/showcase/testutils/configuration/SpecificTestConfigurationsFactory.kt index e7ef0bb..e9c281d 100644 --- a/app/src/robolectricTest/java/org/fnives/test/showcase/testutils/configuration/SpecificTestConfigurationsFactory.kt +++ b/app/src/robolectricTest/java/org/fnives/test/showcase/testutils/configuration/SpecificTestConfigurationsFactory.kt @@ -2,9 +2,6 @@ package org.fnives.test.showcase.testutils.configuration object SpecificTestConfigurationsFactory : TestConfigurationsFactory { - override fun createSnackbarVerification(): SnackbarVerificationHelper = - RobolectricSnackbarVerificationHelper - override fun createSharedMigrationTestRuleFactory(): SharedMigrationTestRuleFactory = RobolectricMigrationTestHelperFactory } diff --git a/app/src/robolectricTest/java/org/fnives/test/showcase/testutils/shadow/ShadowSnackbar.kt b/app/src/robolectricTest/java/org/fnives/test/showcase/testutils/shadow/ShadowSnackbar.kt deleted file mode 100644 index 2d4fc4d..0000000 --- a/app/src/robolectricTest/java/org/fnives/test/showcase/testutils/shadow/ShadowSnackbar.kt +++ /dev/null @@ -1,100 +0,0 @@ -package org.fnives.test.showcase.testutils.shadow - -import android.R -import android.content.Context -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.FrameLayout -import androidx.annotation.StringRes -import androidx.coordinatorlayout.widget.CoordinatorLayout -import com.google.android.material.snackbar.ContentViewCallback -import com.google.android.material.snackbar.Snackbar -import com.google.android.material.snackbar.SnackbarContentLayout -import org.robolectric.annotation.Implementation -import org.robolectric.annotation.Implements -import org.robolectric.annotation.RealObject -import org.robolectric.shadow.api.Shadow.extract -import java.lang.reflect.Modifier - -@Implements(Snackbar::class) -class ShadowSnackbar { - @RealObject - var snackbar: Snackbar? = null - var text: String? = null - - companion object { - val shadowSnackbars = mutableListOf() - - @Implementation - @JvmStatic - fun make(view: View, text: CharSequence, duration: Int): Snackbar? { - val snackbar: Snackbar? - try { - val constructor = Snackbar::class.java.getDeclaredConstructor( - Context::class.java, - ViewGroup::class.java, - View::class.java, - ContentViewCallback::class.java - ) ?: throw IllegalArgumentException("Seems like the constructor was not found!") - if (Modifier.isPrivate(constructor.modifiers)) { - constructor.isAccessible = true - } - val parent = findSuitableParent(view) - val content = LayoutInflater.from(parent.context) - .inflate( - com.google.android.material.R.layout.design_layout_snackbar_include, - parent, - false - ) as SnackbarContentLayout - snackbar = constructor.newInstance(view.context, parent, content, content) - snackbar.setText(text) - snackbar.duration = duration - } catch (e: Exception) { - e.printStackTrace() - throw e - } - shadowOf(snackbar).text = text.toString() - shadowSnackbars.add(shadowOf(snackbar)) - return snackbar - } - - private fun findSuitableParent(view: View): ViewGroup = - when (view) { - is CoordinatorLayout -> view - is FrameLayout -> { - when { - view.id == R.id.content -> view - (view.parent as? View) == null -> view - else -> findSuitableParent(view.parent as View) - } - } - else -> { - when { - (view.parent as? View) == null && view is ViewGroup -> view - (view.parent as? View) == null -> FrameLayout(view.context) - else -> findSuitableParent(view.parent as View) - } - } - } - - @Implementation - @JvmStatic - fun make(view: View, @StringRes resId: Int, duration: Int): Snackbar? = - make(view, view.resources.getText(resId), duration) - - fun shadowOf(bar: Snackbar?): ShadowSnackbar = - extract(bar) - - fun reset() { - shadowSnackbars.clear() - } - - fun shownSnackbarCount(): Int = shadowSnackbars.size - - val textOfLatestSnackbar: String? - get() = shadowSnackbars.lastOrNull()?.text - val latestSnackbar: Snackbar? - get() = shadowSnackbars.lastOrNull()?.snackbar - } -} diff --git a/app/src/robolectricTest/java/org/fnives/test/showcase/testutils/shadow/ShadowSnackbarResetTestRule.kt b/app/src/robolectricTest/java/org/fnives/test/showcase/testutils/shadow/ShadowSnackbarResetTestRule.kt deleted file mode 100644 index 3fa4de9..0000000 --- a/app/src/robolectricTest/java/org/fnives/test/showcase/testutils/shadow/ShadowSnackbarResetTestRule.kt +++ /dev/null @@ -1,20 +0,0 @@ -package org.fnives.test.showcase.testutils.shadow - -import org.junit.rules.TestRule -import org.junit.runner.Description -import org.junit.runners.model.Statement - -class ShadowSnackbarResetTestRule : TestRule { - override fun apply(base: Statement, description: Description): Statement = - object : Statement() { - @Throws(Throwable::class) - override fun evaluate() { - ShadowSnackbar.reset() - try { - base.evaluate() - } finally { - ShadowSnackbar.reset() - } - } - } -} diff --git a/app/src/robolectricTest/java/org/fnives/test/showcase/ui/RobolectricAuthActivityInstrumentedTest.kt b/app/src/robolectricTest/java/org/fnives/test/showcase/ui/RobolectricAuthActivityInstrumentedTest.kt new file mode 100644 index 0000000..b18c6c6 --- /dev/null +++ b/app/src/robolectricTest/java/org/fnives/test/showcase/ui/RobolectricAuthActivityInstrumentedTest.kt @@ -0,0 +1,166 @@ +package org.fnives.test.showcase.ui + +import androidx.lifecycle.Lifecycle +import androidx.test.core.app.ActivityScenario +import androidx.test.espresso.intent.Intents +import androidx.test.ext.junit.runners.AndroidJUnit4 +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestCoroutineScheduler +import kotlinx.coroutines.test.TestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.fnives.test.showcase.R +import org.fnives.test.showcase.network.mockserver.MockServerScenarioSetup +import org.fnives.test.showcase.network.mockserver.scenario.auth.AuthScenario +import org.fnives.test.showcase.network.testutil.NetworkTestConfigurationHelper +import org.fnives.test.showcase.storage.database.DatabaseInitialization +import org.fnives.test.showcase.testutils.idling.CompositeDisposable +import org.fnives.test.showcase.testutils.idling.Disposable +import org.fnives.test.showcase.testutils.idling.IdlingResourceDisposable +import org.fnives.test.showcase.testutils.idling.MainDispatcherTestRule.Companion.advanceUntilIdleWithIdlingResources +import org.fnives.test.showcase.testutils.idling.OkHttp3IdlingResource +import org.fnives.test.showcase.testutils.safeClose +import org.fnives.test.showcase.ui.auth.AuthActivity +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.koin.core.context.GlobalContext.stopKoin +import org.koin.test.KoinTest + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(AndroidJUnit4::class) +class RobolectricAuthActivityInstrumentedTest : KoinTest { + + private lateinit var activityScenario: ActivityScenario + private lateinit var robot: RobolectricLoginRobot + private lateinit var testDispatcher: TestDispatcher + private lateinit var mockServerScenarioSetup: MockServerScenarioSetup + private lateinit var disposable: Disposable + + @Before + fun setup() { + Intents.init() + val dispatcher = StandardTestDispatcher(TestCoroutineScheduler()) + Dispatchers.setMain(dispatcher) + testDispatcher = dispatcher + DatabaseInitialization.dispatcher = dispatcher + + mockServerScenarioSetup = NetworkTestConfigurationHelper.startWithHTTPSMockWebServer() + + val idlingResources = NetworkTestConfigurationHelper.getOkHttpClients() + .associateBy(keySelector = { it.toString() }) + .map { (key, client) -> OkHttp3IdlingResource.create(key, client) } + .map(::IdlingResourceDisposable) + disposable = CompositeDisposable(idlingResources) + + robot = RobolectricLoginRobot() + activityScenario = ActivityScenario.launch(AuthActivity::class.java) + activityScenario.moveToState(Lifecycle.State.RESUMED) + } + + @After + fun tearDown() { + stopKoin() + Dispatchers.resetMain() + mockServerScenarioSetup.stop() + disposable.dispose() + activityScenario.safeClose() + Intents.release() + } + + /** GIVEN non empty password and username and successful response WHEN signIn THEN no error is shown and navigating to home */ + @Test + fun properLoginResultsInNavigationToHome() { + mockServerScenarioSetup.setScenario( + AuthScenario.Success(password = "alma", username = "banan"), + validateArguments = true + ) + + robot.setPassword("alma") + .setUsername("banan") + .assertPassword("alma") + .assertUsername("banan") + .clickOnLogin() + .assertLoadingBeforeRequests() + .assertErrorIsNotShown() + + testDispatcher.advanceUntilIdleWithIdlingResources() + robot.assertNavigatedToHome() + } + + /** GIVEN empty password and username WHEN signIn THEN error password is shown */ + @Test + fun emptyPasswordShowsProperErrorMessage() { + robot.setUsername("banan") + .assertUsername("banan") + .clickOnLogin() + .assertLoadingBeforeRequests() + .assertErrorIsNotShown() + + testDispatcher.advanceUntilIdleWithIdlingResources() + robot.assertErrorIsShown(R.string.password_is_invalid) + .assertNotNavigatedToHome() + .assertNotLoading() + } + + /** GIVEN password and empty username WHEN signIn THEN error username is shown */ + @Test + fun emptyUserNameShowsProperErrorMessage() { + robot.setPassword("banan") + .assertPassword("banan") + .clickOnLogin() + .assertLoadingBeforeRequests() + + testDispatcher.advanceUntilIdleWithIdlingResources() + robot.assertErrorIsShown(R.string.username_is_invalid) + .assertNotNavigatedToHome() + .assertNotLoading() + } + + /** GIVEN password and username and invalid credentials response WHEN signIn THEN error invalid credentials is shown */ + @Test + fun invalidCredentialsGivenShowsProperErrorMessage() { + mockServerScenarioSetup.setScenario( + AuthScenario.InvalidCredentials(username = "alma", password = "banan"), + validateArguments = true + ) + robot + .setUsername("alma") + .setPassword("banan") + .assertUsername("alma") + .assertPassword("banan") + .clickOnLogin() + .assertLoadingBeforeRequests() + .assertErrorIsNotShown() + + testDispatcher.advanceUntilIdleWithIdlingResources() + robot.assertErrorIsShown(R.string.credentials_invalid) + .assertNotNavigatedToHome() + .assertNotLoading() + } + + /** GIVEN password and username and error response WHEN signIn THEN error invalid credentials is shown */ + @Test + fun networkErrorShowsProperErrorMessage() { + mockServerScenarioSetup.setScenario( + AuthScenario.GenericError(username = "alma", password = "banan"), + validateArguments = true + ) + robot + .setUsername("alma") + .setPassword("banan") + .assertUsername("alma") + .assertPassword("banan") + .clickOnLogin() + .assertLoadingBeforeRequests() + .assertErrorIsNotShown() + + testDispatcher.advanceUntilIdleWithIdlingResources() + robot.assertErrorIsShown(R.string.something_went_wrong) + .assertNotNavigatedToHome() + .assertNotLoading() + } +} \ No newline at end of file diff --git a/app/src/robolectricTest/java/org/fnives/test/showcase/ui/RobolectricLoginRobot.kt b/app/src/robolectricTest/java/org/fnives/test/showcase/ui/RobolectricLoginRobot.kt new file mode 100644 index 0000000..72e723f --- /dev/null +++ b/app/src/robolectricTest/java/org/fnives/test/showcase/ui/RobolectricLoginRobot.kt @@ -0,0 +1,74 @@ +package org.fnives.test.showcase.ui + +//import org.fnives.test.showcase.testutils.shadow.ShadowSnackbar +import androidx.annotation.StringRes +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.action.ViewActions +import androidx.test.espresso.assertion.ViewAssertions +import androidx.test.espresso.intent.Intents.intended +import androidx.test.espresso.intent.matcher.IntentMatchers.hasComponent +import androidx.test.espresso.matcher.ViewMatchers +import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import androidx.test.espresso.matcher.ViewMatchers.withId +import org.fnives.test.showcase.R +import org.fnives.test.showcase.testutils.configuration.SnackbarVerificationHelper +import org.fnives.test.showcase.testutils.viewactions.notIntended +import org.fnives.test.showcase.ui.home.MainActivity +import org.hamcrest.core.IsNot.not + +class RobolectricLoginRobot( + private val snackbarVerificationHelper: SnackbarVerificationHelper = SnackbarVerificationHelper() +) { + + fun setUsername(username: String): RobolectricLoginRobot = apply { + onView(withId(R.id.user_edit_text)) + .perform(ViewActions.replaceText(username), ViewActions.closeSoftKeyboard()) + } + + fun setPassword(password: String): RobolectricLoginRobot = apply { + onView(withId(R.id.password_edit_text)) + .perform(ViewActions.replaceText(password), ViewActions.closeSoftKeyboard()) + } + + fun clickOnLogin() = apply { + onView(withId(R.id.login_cta)) + .perform(ViewActions.click()) + } + + fun assertPassword(password: String) = apply { + onView(withId((R.id.password_edit_text))) + .check(ViewAssertions.matches(ViewMatchers.withText(password))) + } + + fun assertUsername(username: String) = apply { + onView(withId((R.id.user_edit_text))) + .check(ViewAssertions.matches(ViewMatchers.withText(username))) + } + + fun assertLoadingBeforeRequests() = apply { + onView(withId(R.id.loading_indicator)) + .check(ViewAssertions.matches(isDisplayed())) + } + + fun assertNotLoading() = apply { + onView(withId(R.id.loading_indicator)) + .check(ViewAssertions.matches(not(isDisplayed()))) + } + + fun assertErrorIsShown(@StringRes stringResID: Int) = apply { + snackbarVerificationHelper.assertIsShownWithText(stringResID) + } + + fun assertErrorIsNotShown() = apply { + snackbarVerificationHelper.assertIsNotShown() + } + + fun assertNavigatedToHome() = apply { + intended(hasComponent(MainActivity::class.java.canonicalName)) + } + + fun assertNotNavigatedToHome() = apply { + notIntended(hasComponent(MainActivity::class.java.canonicalName)) + } + +} diff --git a/app/src/robolectricTest/java/org/fnives/test/showcase/ui/codekata/CodeKataAuthActivityInstrumentedTest.kt b/app/src/robolectricTest/java/org/fnives/test/showcase/ui/codekata/CodeKataAuthActivityInstrumentedTest.kt new file mode 100644 index 0000000..82b147e --- /dev/null +++ b/app/src/robolectricTest/java/org/fnives/test/showcase/ui/codekata/CodeKataAuthActivityInstrumentedTest.kt @@ -0,0 +1,51 @@ +package org.fnives.test.showcase.ui.codekata + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import kotlinx.coroutines.ExperimentalCoroutinesApi +import org.junit.After +import org.junit.Before +import org.junit.Ignore +import org.junit.Test +import org.junit.runner.RunWith +import org.koin.core.context.GlobalContext.stopKoin +import org.koin.test.KoinTest + +@Ignore("CodeKata") +@RunWith(AndroidJUnit4::class) +@OptIn(ExperimentalCoroutinesApi::class) +class CodeKataAuthActivityInstrumentedTest : KoinTest { + + @Before + fun setup() { + } + + @After + fun tearDown() { + stopKoin() + } + + /** GIVEN non empty password and username and successful response WHEN signIn THEN no error is shown and navigating to home */ + @Test + fun properLoginResultsInNavigationToHome() { + } + + /** GIVEN empty password and username WHEN signIn THEN error password is shown */ + @Test + fun emptyPasswordShowsProperErrorMessage() { + } + + /** GIVEN password and empty username WHEN signIn THEN error username is shown */ + @Test + fun emptyUserNameShowsProperErrorMessage() { + } + + /** GIVEN password and username and invalid credentials response WHEN signIn THEN error invalid credentials is shown */ + @Test + fun invalidCredentialsGivenShowsProperErrorMessage() { + } + + /** GIVEN password and username and error response WHEN signIn THEN error invalid credentials is shown */ + @Test + fun networkErrorShowsProperErrorMessage() { + } +} \ No newline at end of file diff --git a/app/src/robolectricTest/java/org/fnives/test/showcase/ui/codekata/CodeKataLoginRobot.kt b/app/src/robolectricTest/java/org/fnives/test/showcase/ui/codekata/CodeKataLoginRobot.kt new file mode 100644 index 0000000..8ac678b --- /dev/null +++ b/app/src/robolectricTest/java/org/fnives/test/showcase/ui/codekata/CodeKataLoginRobot.kt @@ -0,0 +1,4 @@ +package org.fnives.test.showcase.ui.codekata + +class CodeKataLoginRobot { +} \ No newline at end of file diff --git a/app/src/sharedTest/java/org/fnives/test/showcase/testutils/configuration/SnackbarVerificationHelper.kt b/app/src/sharedTest/java/org/fnives/test/showcase/testutils/configuration/SnackbarVerificationHelper.kt index 0adb1d9..cdba01c 100644 --- a/app/src/sharedTest/java/org/fnives/test/showcase/testutils/configuration/SnackbarVerificationHelper.kt +++ b/app/src/sharedTest/java/org/fnives/test/showcase/testutils/configuration/SnackbarVerificationHelper.kt @@ -1,15 +1,40 @@ package org.fnives.test.showcase.testutils.configuration +import android.view.View import androidx.annotation.StringRes -import org.junit.rules.TestRule +import androidx.test.espresso.Espresso +import androidx.test.espresso.UiController +import androidx.test.espresso.ViewAction +import androidx.test.espresso.action.ViewActions +import androidx.test.espresso.assertion.ViewAssertions +import androidx.test.espresso.matcher.ViewMatchers +import com.google.android.material.R +import com.google.android.material.snackbar.Snackbar +import org.hamcrest.Matcher +import org.hamcrest.Matchers -interface SnackbarVerificationHelper : TestRule { +class SnackbarVerificationHelper { - fun assertIsShownWithText(@StringRes stringResID: Int) + fun assertIsShownWithText(@StringRes stringResID: Int) { + Espresso.onView(ViewMatchers.withId(R.id.snackbar_text)) + .check(ViewAssertions.matches(ViewMatchers.withText(stringResID))) + Espresso.onView(ViewMatchers.isAssignableFrom(Snackbar.SnackbarLayout::class.java)).perform(ViewActions.swipeRight()) + Espresso.onView(ViewMatchers.isRoot()).perform(LoopMainUntilSnackbarDismissed()) + } - fun assertIsNotShown() + fun assertIsNotShown() { + Espresso.onView(ViewMatchers.withId(R.id.snackbar_text)).check(ViewAssertions.doesNotExist()) + } + + class LoopMainUntilSnackbarDismissed : ViewAction { + override fun getConstraints(): Matcher = Matchers.isA(View::class.java) + + override fun getDescription(): String = "loop MainThread until Snackbar is Dismissed" + + override fun perform(uiController: UiController, view: View?) { + while (view?.findViewById(com.google.android.material.R.id.snackbar_text) != null) { + uiController.loopMainThreadForAtLeast(100) + } + } + } } - -@Suppress("TestFunctionName") -fun SnackbarVerificationTestRule(): SnackbarVerificationHelper = - SpecificTestConfigurationsFactory.createSnackbarVerification() diff --git a/app/src/sharedTest/java/org/fnives/test/showcase/testutils/configuration/TestConfigurationsFactory.kt b/app/src/sharedTest/java/org/fnives/test/showcase/testutils/configuration/TestConfigurationsFactory.kt index 3dc5eb3..18336c9 100644 --- a/app/src/sharedTest/java/org/fnives/test/showcase/testutils/configuration/TestConfigurationsFactory.kt +++ b/app/src/sharedTest/java/org/fnives/test/showcase/testutils/configuration/TestConfigurationsFactory.kt @@ -8,7 +8,5 @@ package org.fnives.test.showcase.testutils.configuration */ interface TestConfigurationsFactory { - fun createSnackbarVerification(): SnackbarVerificationHelper - fun createSharedMigrationTestRuleFactory(): SharedMigrationTestRuleFactory } diff --git a/app/src/sharedTest/java/org/fnives/test/showcase/testutils/idling/MainDispatcherTestRule.kt b/app/src/sharedTest/java/org/fnives/test/showcase/testutils/idling/MainDispatcherTestRule.kt index 68dd4c4..029caed 100644 --- a/app/src/sharedTest/java/org/fnives/test/showcase/testutils/idling/MainDispatcherTestRule.kt +++ b/app/src/sharedTest/java/org/fnives/test/showcase/testutils/idling/MainDispatcherTestRule.kt @@ -46,12 +46,14 @@ class MainDispatcherTestRule : TestRule { testDispatcher.scheduler.advanceTimeBy(delayInMillis) } - private fun TestDispatcher.advanceUntilIdleWithIdlingResources() { - scheduler.advanceUntilIdle() // advance until a request is sent - while (anyResourceIdling()) { // check if any request is in progress - awaitIdlingResources() // complete all requests and other idling resources - scheduler.advanceUntilIdle() // run coroutines after request is finished + companion object { + fun TestDispatcher.advanceUntilIdleWithIdlingResources() { + scheduler.advanceUntilIdle() // advance until a request is sent + while (anyResourceIdling()) { // check if any request is in progress + awaitIdlingResources() // complete all requests and other idling resources + scheduler.advanceUntilIdle() // run coroutines after request is finished + } + scheduler.advanceUntilIdle() } - scheduler.advanceUntilIdle() } } diff --git a/app/src/sharedTest/java/org/fnives/test/showcase/testutils/robot/Robot.kt b/app/src/sharedTest/java/org/fnives/test/showcase/testutils/robot/Robot.kt deleted file mode 100644 index c393b4b..0000000 --- a/app/src/sharedTest/java/org/fnives/test/showcase/testutils/robot/Robot.kt +++ /dev/null @@ -1,8 +0,0 @@ -package org.fnives.test.showcase.testutils.robot - -interface Robot { - - fun init() - - fun release() -} diff --git a/app/src/sharedTest/java/org/fnives/test/showcase/testutils/robot/RobotTestRule.kt b/app/src/sharedTest/java/org/fnives/test/showcase/testutils/robot/RobotTestRule.kt deleted file mode 100644 index e54716b..0000000 --- a/app/src/sharedTest/java/org/fnives/test/showcase/testutils/robot/RobotTestRule.kt +++ /dev/null @@ -1,20 +0,0 @@ -package org.fnives.test.showcase.testutils.robot - -import org.junit.rules.TestRule -import org.junit.runner.Description -import org.junit.runners.model.Statement - -class RobotTestRule(val robot: T) : TestRule { - override fun apply(base: Statement, description: Description): Statement = - object : Statement() { - @Throws(Throwable::class) - override fun evaluate() { - robot.init() - try { - base.evaluate() - } finally { - robot.release() - } - } - } -} diff --git a/app/src/sharedTest/java/org/fnives/test/showcase/testutils/statesetup/SetupAuthenticationState.kt b/app/src/sharedTest/java/org/fnives/test/showcase/testutils/statesetup/SetupAuthenticationState.kt index 305d61b..7ffc9dd 100644 --- a/app/src/sharedTest/java/org/fnives/test/showcase/testutils/statesetup/SetupAuthenticationState.kt +++ b/app/src/sharedTest/java/org/fnives/test/showcase/testutils/statesetup/SetupAuthenticationState.kt @@ -21,38 +21,48 @@ object SetupAuthenticationState : KoinTest { mockServerScenarioSetup: MockServerScenarioSetup, resetIntents: Boolean = true ) { - mockServerScenarioSetup.setScenario(AuthScenario.Success(username = "a", password = "b")) - val activityScenario = ActivityScenario.launch(AuthActivity::class.java) - activityScenario.moveToState(Lifecycle.State.RESUMED) - val loginRobot = LoginRobot() - loginRobot.setupIntentResults() - loginRobot - .setPassword("b") - .setUsername("a") - .clickOnLogin() + resetIntentsIfNeeded(resetIntents) { + mockServerScenarioSetup.setScenario(AuthScenario.Success(username = "a", password = "b")) + val activityScenario = ActivityScenario.launch(AuthActivity::class.java) + activityScenario.moveToState(Lifecycle.State.RESUMED) - mainDispatcherTestRule.advanceUntilIdleWithIdlingResources() + val loginRobot = LoginRobot() + loginRobot.setupIntentResults() + loginRobot + .setPassword("b") + .setUsername("a") + .clickOnLogin() + mainDispatcherTestRule.advanceUntilIdleWithIdlingResources() - activityScenario.safeClose() - resetIntentsIfNeeded(resetIntents) + activityScenario.safeClose() + } } fun setupLogout( mainDispatcherTestRule: MainDispatcherTestRule, resetIntents: Boolean = true ) { - val activityScenario = ActivityScenario.launch(MainActivity::class.java) - activityScenario.moveToState(Lifecycle.State.RESUMED) - HomeRobot().clickSignOut() - mainDispatcherTestRule.advanceUntilIdleWithIdlingResources() + resetIntentsIfNeeded(resetIntents) { + val activityScenario = ActivityScenario.launch(MainActivity::class.java) + activityScenario.moveToState(Lifecycle.State.RESUMED) - activityScenario.safeClose() - resetIntentsIfNeeded(resetIntents) + val homeRobot = HomeRobot() + homeRobot.setupIntentResults() + homeRobot.clickSignOut() + mainDispatcherTestRule.advanceUntilIdleWithIdlingResources() + + activityScenario.safeClose() + } } - private fun resetIntentsIfNeeded(resetIntents: Boolean) { - if (resetIntents && IntentStubberRegistry.isLoaded()) { - Intents.release() + private fun resetIntentsIfNeeded(resetIntents: Boolean, action: () -> Unit) { + val wasInitialized = IntentStubberRegistry.isLoaded() + if (!wasInitialized) { + Intents.init() + } + action() + Intents.release() + if (resetIntents && wasInitialized) { Intents.init() } } 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 c0a74fc..fe95319 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 @@ -1,6 +1,8 @@ package org.fnives.test.showcase.ui.home +import android.app.Activity import android.app.Instrumentation +import android.content.Intent import androidx.recyclerview.widget.RecyclerView import androidx.test.espresso.Espresso import androidx.test.espresso.action.ViewActions.click @@ -19,21 +21,17 @@ import androidx.test.espresso.matcher.ViewMatchers.withText import org.fnives.test.showcase.R import org.fnives.test.showcase.model.content.Content import org.fnives.test.showcase.model.content.FavouriteContent -import org.fnives.test.showcase.testutils.robot.Robot import org.fnives.test.showcase.testutils.viewactions.PullToRefresh import org.fnives.test.showcase.testutils.viewactions.WithDrawable import org.fnives.test.showcase.testutils.viewactions.notIntended import org.fnives.test.showcase.ui.auth.AuthActivity import org.hamcrest.Matchers.allOf -class HomeRobot : Robot { +class HomeRobot { - override fun init() { - Intents.init() - } - - override fun release() { - Intents.release() + fun setupIntentResults() { + Intents.intending(IntentMatchers.hasComponent(AuthActivity::class.java.canonicalName)) + .respondWith(Instrumentation.ActivityResult(Activity.RESULT_OK, Intent())) } fun assertNavigatedToAuth() = apply { diff --git a/app/src/sharedTest/java/org/fnives/test/showcase/ui/home/MainActivityInstrumentedTest.kt b/app/src/sharedTest/java/org/fnives/test/showcase/ui/home/MainActivityInstrumentedTest.kt index eb77d0b..dad765a 100644 --- a/app/src/sharedTest/java/org/fnives/test/showcase/ui/home/MainActivityInstrumentedTest.kt +++ b/app/src/sharedTest/java/org/fnives/test/showcase/ui/home/MainActivityInstrumentedTest.kt @@ -1,6 +1,7 @@ package org.fnives.test.showcase.ui.home import androidx.test.core.app.ActivityScenario +import androidx.test.espresso.intent.Intents import androidx.test.ext.junit.runners.AndroidJUnit4 import org.fnives.test.showcase.model.content.FavouriteContent import org.fnives.test.showcase.network.mockserver.ContentData @@ -10,7 +11,6 @@ import org.fnives.test.showcase.testutils.MockServerScenarioSetupResetingTestRul import org.fnives.test.showcase.testutils.idling.MainDispatcherTestRule import org.fnives.test.showcase.testutils.idling.loopMainThreadFor import org.fnives.test.showcase.testutils.idling.loopMainThreadUntilIdleWithIdlingResources -import org.fnives.test.showcase.testutils.robot.RobotTestRule import org.fnives.test.showcase.testutils.safeClose import org.fnives.test.showcase.testutils.statesetup.SetupAuthenticationState.setupLogin import org.junit.After @@ -31,22 +31,24 @@ class MainActivityInstrumentedTest : KoinTest { private val mockServerScenarioSetup get() = mockServerScenarioSetupTestRule.mockServerScenarioSetup private val mainDispatcherTestRule = MainDispatcherTestRule() - private val robot = HomeRobot() + private lateinit var robot : HomeRobot @Rule @JvmField val ruleOrder: RuleChain = RuleChain.outerRule(mockServerScenarioSetupTestRule) .around(mainDispatcherTestRule) - .around(RobotTestRule(robot)) @Before fun setup() { + robot = HomeRobot() setupLogin(mainDispatcherTestRule, mockServerScenarioSetup) + Intents.init() } @After fun tearDown() { activityScenario.safeClose() + Intents.release() } /** GIVEN initialized MainActivity WHEN signout is clicked THEN user is signed out */ diff --git a/app/src/sharedTest/java/org/fnives/test/showcase/ui/login/AuthActivityInstrumentedTest.kt b/app/src/sharedTest/java/org/fnives/test/showcase/ui/login/AuthActivityInstrumentedTest.kt index af9f454..e548e18 100644 --- a/app/src/sharedTest/java/org/fnives/test/showcase/ui/login/AuthActivityInstrumentedTest.kt +++ b/app/src/sharedTest/java/org/fnives/test/showcase/ui/login/AuthActivityInstrumentedTest.kt @@ -1,15 +1,16 @@ package org.fnives.test.showcase.ui.login import androidx.test.core.app.ActivityScenario +import androidx.test.espresso.intent.Intents import androidx.test.ext.junit.runners.AndroidJUnit4 import org.fnives.test.showcase.R import org.fnives.test.showcase.network.mockserver.scenario.auth.AuthScenario import org.fnives.test.showcase.testutils.MockServerScenarioSetupResetingTestRule import org.fnives.test.showcase.testutils.idling.MainDispatcherTestRule -import org.fnives.test.showcase.testutils.robot.RobotTestRule import org.fnives.test.showcase.testutils.safeClose import org.fnives.test.showcase.ui.auth.AuthActivity import org.junit.After +import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.rules.RuleChain @@ -25,17 +26,23 @@ class AuthActivityInstrumentedTest : KoinTest { private val mockServerScenarioSetupTestRule = MockServerScenarioSetupResetingTestRule() private val mockServerScenarioSetup get() = mockServerScenarioSetupTestRule.mockServerScenarioSetup private val mainDispatcherTestRule = MainDispatcherTestRule() - private val robot = LoginRobot() + private lateinit var robot : LoginRobot @Rule @JvmField val ruleOrder: RuleChain = RuleChain.outerRule(mockServerScenarioSetupTestRule) .around(mainDispatcherTestRule) - .around(RobotTestRule(robot)) + + @Before + fun setup() { + Intents.init() + robot = LoginRobot() + } @After fun tearDown() { activityScenario.safeClose() + Intents.release() } /** GIVEN non empty password and username and successful response WHEN signIn THEN no error is shown and navigating to home */ diff --git a/app/src/sharedTest/java/org/fnives/test/showcase/ui/login/LoginRobot.kt b/app/src/sharedTest/java/org/fnives/test/showcase/ui/login/LoginRobot.kt index 1210057..a07bb63 100644 --- a/app/src/sharedTest/java/org/fnives/test/showcase/ui/login/LoginRobot.kt +++ b/app/src/sharedTest/java/org/fnives/test/showcase/ui/login/LoginRobot.kt @@ -9,38 +9,26 @@ import androidx.test.espresso.action.ViewActions import androidx.test.espresso.assertion.ViewAssertions import androidx.test.espresso.intent.Intents import androidx.test.espresso.intent.Intents.intended -import androidx.test.espresso.intent.Intents.intending import androidx.test.espresso.intent.matcher.IntentMatchers.hasComponent import androidx.test.espresso.matcher.ViewMatchers import androidx.test.espresso.matcher.ViewMatchers.isDisplayed import androidx.test.espresso.matcher.ViewMatchers.withId import org.fnives.test.showcase.R import org.fnives.test.showcase.testutils.configuration.SnackbarVerificationHelper -import org.fnives.test.showcase.testutils.configuration.SnackbarVerificationTestRule -import org.fnives.test.showcase.testutils.robot.Robot import org.fnives.test.showcase.testutils.viewactions.ReplaceProgressBarDrawableToStatic import org.fnives.test.showcase.testutils.viewactions.notIntended import org.fnives.test.showcase.ui.home.MainActivity import org.hamcrest.core.IsNot.not class LoginRobot( - private val snackbarVerificationHelper: SnackbarVerificationHelper = SnackbarVerificationTestRule() -) : Robot { - - override fun init() { - Intents.init() - setupIntentResults() - } + private val snackbarVerificationHelper: SnackbarVerificationHelper = SnackbarVerificationHelper() +){ fun setupIntentResults() { - intending(hasComponent(MainActivity::class.java.canonicalName)) + Intents.intending(hasComponent(MainActivity::class.java.canonicalName)) .respondWith(Instrumentation.ActivityResult(Activity.RESULT_OK, Intent())) } - override fun release() { - Intents.release() - } - /** * Needed because Espresso idling waits until mainThread is idle. * diff --git a/app/src/sharedTest/java/org/fnives/test/showcase/ui/splash/SplashActivityInstrumentedTest.kt b/app/src/sharedTest/java/org/fnives/test/showcase/ui/splash/SplashActivityInstrumentedTest.kt index 50c5359..9191338 100644 --- a/app/src/sharedTest/java/org/fnives/test/showcase/ui/splash/SplashActivityInstrumentedTest.kt +++ b/app/src/sharedTest/java/org/fnives/test/showcase/ui/splash/SplashActivityInstrumentedTest.kt @@ -2,14 +2,15 @@ package org.fnives.test.showcase.ui.splash import androidx.lifecycle.Lifecycle import androidx.test.core.app.ActivityScenario +import androidx.test.espresso.intent.Intents import androidx.test.ext.junit.runners.AndroidJUnit4 import org.fnives.test.showcase.testutils.MockServerScenarioSetupResetingTestRule import org.fnives.test.showcase.testutils.idling.MainDispatcherTestRule -import org.fnives.test.showcase.testutils.robot.RobotTestRule import org.fnives.test.showcase.testutils.safeClose import org.fnives.test.showcase.testutils.statesetup.SetupAuthenticationState.setupLogin import org.fnives.test.showcase.testutils.statesetup.SetupAuthenticationState.setupLogout import org.junit.After +import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.rules.RuleChain @@ -25,17 +26,23 @@ class SplashActivityInstrumentedTest : KoinTest { private val mainDispatcherTestRule = MainDispatcherTestRule() private val mockServerScenarioSetupTestRule = MockServerScenarioSetupResetingTestRule() - private val robot = SplashRobot() + private lateinit var robot : SplashRobot @Rule @JvmField val ruleOrder: RuleChain = RuleChain.outerRule(mockServerScenarioSetupTestRule) .around(mainDispatcherTestRule) - .around(RobotTestRule(robot)) + + @Before + fun setup() { + Intents.init() + robot = SplashRobot() + } @After fun tearDown() { activityScenario.safeClose() + Intents.release() } /** GIVEN loggedInState WHEN opened after some time THEN MainActivity is started */ diff --git a/app/src/sharedTest/java/org/fnives/test/showcase/ui/splash/SplashRobot.kt b/app/src/sharedTest/java/org/fnives/test/showcase/ui/splash/SplashRobot.kt index 095fea3..0e941b6 100644 --- a/app/src/sharedTest/java/org/fnives/test/showcase/ui/splash/SplashRobot.kt +++ b/app/src/sharedTest/java/org/fnives/test/showcase/ui/splash/SplashRobot.kt @@ -1,20 +1,21 @@ package org.fnives.test.showcase.ui.splash +import android.app.Activity +import android.app.Instrumentation +import android.content.Intent import androidx.test.espresso.intent.Intents import androidx.test.espresso.intent.matcher.IntentMatchers -import org.fnives.test.showcase.testutils.robot.Robot import org.fnives.test.showcase.testutils.viewactions.notIntended import org.fnives.test.showcase.ui.auth.AuthActivity import org.fnives.test.showcase.ui.home.MainActivity -class SplashRobot : Robot { +class SplashRobot { - override fun init() { - Intents.init() - } - - override fun release() { - Intents.release() + fun setupIntentResults() { + Intents.intending(IntentMatchers.hasComponent(MainActivity::class.java.canonicalName)) + .respondWith(Instrumentation.ActivityResult(Activity.RESULT_OK, Intent())) + Intents.intending(IntentMatchers.hasComponent(AuthActivity::class.java.canonicalName)) + .respondWith(Instrumentation.ActivityResult(Activity.RESULT_OK, Intent())) } fun assertHomeIsStarted() = apply { diff --git a/app/src/test/resources/robolectric.properties b/app/src/test/resources/robolectric.properties index bcd587b..4efdf90 100644 --- a/app/src/test/resources/robolectric.properties +++ b/app/src/test/resources/robolectric.properties @@ -1,3 +1,3 @@ sdk=28 -shadows=org.fnives.test.showcase.testutils.shadow.ShadowSnackbar +#shadows=org.fnives.test.showcase.testutils.shadow.ShadowSnackbar instrumentedPackages=androidx.loader.content diff --git a/codekata/robolectric.instructionset.md b/codekata/robolectric.instructionset.md index 7f2c2ee..121c454 100644 --- a/codekata/robolectric.instructionset.md +++ b/codekata/robolectric.instructionset.md @@ -343,5 +343,351 @@ However this is an optional exercise. If you want to check it out, `FavouriteContentLocalStorageImplInstrumentedTest` does exactly that. +## Login UI Test + +We can do much more with Robolectric than just test our Database or SharedPreferences. +We can write UI Tests as well. It is still not as good as Running tests on a Real Device. But depending on your need it might still be helpful. + +> Note we get to the section where I am the least comfortable with, I don't think I have written enough UI Tests yet, so from now on take evrything with a big grain of salt. Feel free to modify your approach to your need. You may also correct me via issues on GitHub, would be a great pleasure to learn for me. + +We can write UI tests that have mocked out UseCases and Business Logic, but I prefer to do a full screen Integration Tests, cause I think my UI changes enough at it is, wouldn't want to maintain one extra testing layer. +So this will be showcased here. But you should be able to write pure UI tests, if you can follow along this section as well if you choose to do so + +### Setup + +Our System Under Test will be mainly the `org.fnives.test.showcase.ui.codekata.CodeKataAuthActivityInstrumentedTest`. + +First of all we will use [Espresso](https://developer.android.com/training/testing/espresso) to simulate user actions on our UI. +We need quite a bunch of setup, but first let's start with our Robot. + +#### Robot Pattern +Robot Pattern presented by Jake Wharton here: https://academy.realm.io/posts/kau-jake-wharton-testing-robots/ and as described Kotlin specific here: https://medium.com/android-bits/espresso-robot-pattern-in-kotlin-fc820ce250f7 + +There is also a Kotlin specific article [here](https://medium.com/android-bits/espresso-robot-pattern-in-kotlin-fc820ce250f7). + +Is the idea to separate the logic of finding your views from the logic of the test. +So basically if for example a View Id changes, it doesn't make our behaviour change too, so in this case only our Robot will change, while the Test Class stays the same. + +For now I will keep the synthetic sugar to the minimum, and just declare my actions and verifications there. Feel free to have as much customization there as you think is necessary to make your tests clearer. + +Let's open our robot: `org.fnives.test.showcase.ui.codekata.CodeKataLoginRobot` + +Here is a list of actions we want to do: +- we want to be able to type in the username +- we want to be able to type in the password +- we want to be able the username or password is indeed shows on the UI +- we want to be able to click on signin +- we want to be able verify if we are loading or not +- we want to verify if an error is shown or not +- we want to check if we navigated to Main or not + +##### So here is the code for our the UI interactions +. +```kotlin +fun setUsername(username: String) = apply { + onView(withId(R.id.user_edit_text)) + .perform(ViewActions.replaceText(username), ViewActions.closeSoftKeyboard()) +} + +fun setPassword(password: String) = apply { + onView(withId(R.id.password_edit_text)) + .perform(ViewActions.replaceText(password), ViewActions.closeSoftKeyboard()) +} + +fun clickOnLogin() = apply { + onView(withId(R.id.login_cta)) + .perform(ViewActions.click()) +} + +fun assertPassword(password: String) = apply { + onView(withId((R.id.password_edit_text))) + .check(ViewAssertions.matches(ViewMatchers.withText(password))) +} + +fun assertUsername(username: String) = apply { + onView(withId((R.id.user_edit_text))) + .check(ViewAssertions.matches(ViewMatchers.withText(username))) +} + +fun assertLoadingBeforeRequests() = apply { + onView(withId(R.id.loading_indicator)) + .check(ViewAssertions.matches(isDisplayed())) +} + +fun assertNotLoading() = apply { + onView(withId(R.id.loading_indicator)) + .check(ViewAssertions.matches(not(isDisplayed()))) +} +``` + +Here we took advantage of Espresso. It helps us by being able to perform action such as click, find Views, such as by ID, and assert View States such as withText. +To know what Espresso matchers,assertions are there you just have to use them. It's also easy to extend so if one of your views doesn't have that option, then you can create your own matcher. + +##### Next up, we need to verify if we navigated: +. +```kotlin +fun assertNavigatedToHome() = apply { + intended(hasComponent(MainActivity::class.java.canonicalName)) +} + +fun assertNotNavigatedToHome() = apply { + notIntended(hasComponent(MainActivity::class.java.canonicalName)) +} +``` + +Here we use Espresso's intents, with this we can verify if an Intent was sent out we can also Intercept it to send a result back. + +##### Lastly let's verify Errors +For Snackbar we still gonna use Espresso, but we have a helper class for that because of we may reuse it in other places. +So let's add that: +```kotlin +class CodeKataLoginRobot( + private val snackbarVerificationHelper: SnackbarVerificationHelper = SnackbarVerificationHelper() +) +``` + +Add our functions as well: +```kotlin +fun assertErrorIsShown(@StringRes stringResID: Int) = apply { + snackbarVerificationHelper.assertIsShownWithText(stringResID) +} + +fun assertErrorIsNotShown() = apply { + snackbarVerificationHelper.assertIsNotShown() +} +``` + +With that our Robot is done, we can almost start Testing. We still need setup in our Test class. + +#### Test class setup + +We open the `org.fnives.test.showcase.ui.codekata.CodeKataAuthActivityInstrumentedTest`. + +We declare a couple of fields, it will be described later what exacty are those things. +```kotlin +private lateinit var activityScenario: ActivityScenario +private lateinit var robot: RobolectricLoginRobot +private lateinit var testDispatcher: TestDispatcher +private lateinit var mockServerScenarioSetup: MockServerScenarioSetup +private lateinit var disposable : Disposable +``` + +##### Espresso Intents +We add the intent initialization: +```kotlin +@Before +fun setup() { + Intents.init() +} + +@After +fun tearDown() { + stopKoin() + Intents.release() +} +``` + +##### Networking syncronization and mocking +We have a helper method for that, but the basic idea is that, we use our MockWebSetup and synchronize with Espresso using idling resources. +```kotlin +@Before +fun setup() { + //... + mockServerScenarioSetup = NetworkTestConfigurationHelper.startWithHTTPSMockWebServer() + + val idlingResources = NetworkTestConfigurationHelper.getOkHttpClients() + .associateBy(keySelector = { it.toString() }) + .map { (key, client) -> OkHttp3IdlingResource.create(key, client) } + .map(::IdlingResourceDisposable) + disposable = CompositeDisposable(idlingResources) +} + +@After +fun tearDown() { + stopKoin() + Intents.release() + mockServerScenarioSetup.stop() + disposable.dispose() +} +``` + +Idling Resources makes sure that Espresso awaits the Idling Resource before touching the UI components. Disposable is just a way to remove them from Espresso when we no longer need it. + +##### Coroutine Test Setup +We use a TestDispatcher and initialze our database with it as well. + +```kotlin +@Before +fun setup() { + //... + val dispatcher = StandardTestDispatcher(TestCoroutineScheduler()) + Dispatchers.setMain(dispatcher) + testDispatcher = dispatcher + DatabaseInitialization.dispatcher = dispatcher +} + +@After +fun tearDown() { + stopKoin() + Dispatchers.resetMain() + mockServerScenarioSetup.stop() + disposable.dispose() + Intents.release() +} +``` + +##### Finally we initialize our UI + +We create our Robot. And we take advantage or `ActivityScenario` to handle the lifecycle of the Activity. +```kotlin +@Before +fun setup() { + //... + robot = RobolectricLoginRobot() + activityScenario = ActivityScenario.launch(AuthActivity::class.java) + activityScenario.moveToState(Lifecycle.State.RESUMED) +} + +@After +fun tearDown() { + //... + activityScenario.safeClose() +} +``` + +`safeClose` is a workaround which ActivityScenario has, when an activity is finished from code. + +Finally we are done with the setup, now we can start to test + +### 1. `properLoginResultsInNavigationToHome` + +With this setup our test should be pretty simple. + +First we mock our request: + +```kotlin +mockServerScenarioSetup.setScenario( + AuthScenario.Success(password = "alma", username = "banan"), + validateArguments = true) +) +``` + +Next via the Robot we input the data and click on the signin: +```kotlin +robot.setPassword("alma") + .setUsername("banan") + .assertPassword("alma") + .assertUsername("banan") + .clickOnLogin() + .assertLoadingBeforeRequests() + .assertErrorIsNotShown() +``` + +Finally we sync Coroutines and Espresso then verify that we navigated: +```kotlin +testDispatcher.advanceUntilIdleWithIdlingResources() +robot.assertNavigatedToHome() +``` + +### 2. `emptyPasswordShowsProperErrorMessage` + +Next up we verify what happens if the user doesn't set their password. We don't need a request in this case. + +```kotlin +robot.setUsername("banan") + .assertUsername("banan") + .clickOnLogin() + .assertLoadingBeforeRequests() +``` + +Finally we let coroutines go and verify the error is shown and we have not navigated: +```kotlin +testDispatcher.advanceUntilIdleWithIdlingResources() +robot.assertErrorIsShown(R.string.password_is_invalid) + .assertNotNavigatedToHome() + .assertNotLoading() +``` + +### 3. `emptyUserNameShowsProperErrorMessage` + +This will be really similar as the previous test, so try to do it on your own. The error is `R.string.username_is_invalid` + +Still, here is the complete code: +```kotlin +robot.setPassword("banan") + .assertPassword("banan") + .clickOnLogin() + .assertLoadingBeforeRequests() + +testDispatcher.advanceUntilIdleWithIdlingResources() +robot.assertErrorIsShown(R.string.username_is_invalid) + .assertNotNavigatedToHome() + .assertNotLoading() +``` + +### 4. `invalidCredentialsGivenShowsProperErrorMessage` + +Now we verify network erros. First let's setup the response: +```kotlin +mockServerScenarioSetup.setScenario( + AuthScenario.InvalidCredentials(username = "alma", password = "banan"), + validateArguments = true +) +``` + +Now let's input the data like the user would: +```kotlin +robot + .setUsername("alma") + .setPassword("banan") + .assertUsername("alma") + .assertPassword("banan") + .clickOnLogin() + .assertLoadingBeforeRequests() + .assertErrorIsNotShown() +``` + +Now at the end verify the error is shown properly: +```kotlin +testDispatcher.advanceUntilIdleWithIdlingResources() +robot.assertErrorIsShown(R.string.credentials_invalid) + .assertNotNavigatedToHome() + .assertNotLoading() +``` + +### 5. `networkErrorShowsProperErrorMessage` + +Finally we verify the `AuthScenario.GenericError`. This will be really similar as the previous, except the error will be `R.string.something_went_wrong`. +You should try to do this on your own. + +Here is the code for verification: +```kotlin +mockServerScenarioSetup.setScenario( + AuthScenario.GenericError(username = "alma", password = "banan"), + validateArguments = true +) +robot.setUsername("alma") + .setPassword("banan") + .assertUsername("alma") + .assertPassword("banan") + .clickOnLogin() + .assertLoadingBeforeRequests() + .assertErrorIsNotShown() + +testDispatcher.advanceUntilIdleWithIdlingResources() +robot.assertErrorIsShown(R.string.something_went_wrong) + .assertNotNavigatedToHome() + .assertNotLoading() +``` + ## Conclusion +With that we finished our Robolectric tests, setup might be a bit tidious but we can use TestRules to make the setup reusable. In fact we will do that in the next session. + +What we have learned: +- How to use Robolectric to verify context dependent classes +- We learned about verifying Fakes +- Robolectric starts an Application instance for each test +- We can write UI tests with Espresso +- We learned about the Robot Pattern and how it clears up our UI tests + From c952f4f34d83e9e42dc0167c9b3521079cca8358 Mon Sep 17 00:00:00 2001 From: Gergely Hegedus Date: Sat, 29 Jan 2022 00:33:59 +0200 Subject: [PATCH 3/5] Issue#13 Fix codeAnalysis errors --- .../test/showcase/storage/CodeKataUserDataLocalStorageTest.kt | 2 +- .../showcase/ui/RobolectricAuthActivityInstrumentedTest.kt | 2 +- .../java/org/fnives/test/showcase/ui/RobolectricLoginRobot.kt | 2 -- .../ui/codekata/CodeKataAuthActivityInstrumentedTest.kt | 2 +- .../org/fnives/test/showcase/ui/codekata/CodeKataLoginRobot.kt | 3 +-- .../test/showcase/ui/home/MainActivityInstrumentedTest.kt | 2 +- .../java/org/fnives/test/showcase/ui/login/LoginRobot.kt | 2 +- .../test/showcase/ui/splash/SplashActivityInstrumentedTest.kt | 2 +- 8 files changed, 7 insertions(+), 10 deletions(-) diff --git a/app/src/robolectricTest/java/org/fnives/test/showcase/storage/CodeKataUserDataLocalStorageTest.kt b/app/src/robolectricTest/java/org/fnives/test/showcase/storage/CodeKataUserDataLocalStorageTest.kt index 1316dd8..420fd0f 100644 --- a/app/src/robolectricTest/java/org/fnives/test/showcase/storage/CodeKataUserDataLocalStorageTest.kt +++ b/app/src/robolectricTest/java/org/fnives/test/showcase/storage/CodeKataUserDataLocalStorageTest.kt @@ -25,4 +25,4 @@ class CodeKataUserDataLocalStorageTest { @Test fun sessionSetToNullWillStayNull() { } -} \ No newline at end of file +} diff --git a/app/src/robolectricTest/java/org/fnives/test/showcase/ui/RobolectricAuthActivityInstrumentedTest.kt b/app/src/robolectricTest/java/org/fnives/test/showcase/ui/RobolectricAuthActivityInstrumentedTest.kt index b18c6c6..ec43a9b 100644 --- a/app/src/robolectricTest/java/org/fnives/test/showcase/ui/RobolectricAuthActivityInstrumentedTest.kt +++ b/app/src/robolectricTest/java/org/fnives/test/showcase/ui/RobolectricAuthActivityInstrumentedTest.kt @@ -163,4 +163,4 @@ class RobolectricAuthActivityInstrumentedTest : KoinTest { .assertNotNavigatedToHome() .assertNotLoading() } -} \ No newline at end of file +} diff --git a/app/src/robolectricTest/java/org/fnives/test/showcase/ui/RobolectricLoginRobot.kt b/app/src/robolectricTest/java/org/fnives/test/showcase/ui/RobolectricLoginRobot.kt index 72e723f..8ed9cf6 100644 --- a/app/src/robolectricTest/java/org/fnives/test/showcase/ui/RobolectricLoginRobot.kt +++ b/app/src/robolectricTest/java/org/fnives/test/showcase/ui/RobolectricLoginRobot.kt @@ -1,6 +1,5 @@ package org.fnives.test.showcase.ui -//import org.fnives.test.showcase.testutils.shadow.ShadowSnackbar import androidx.annotation.StringRes import androidx.test.espresso.Espresso.onView import androidx.test.espresso.action.ViewActions @@ -70,5 +69,4 @@ class RobolectricLoginRobot( fun assertNotNavigatedToHome() = apply { notIntended(hasComponent(MainActivity::class.java.canonicalName)) } - } diff --git a/app/src/robolectricTest/java/org/fnives/test/showcase/ui/codekata/CodeKataAuthActivityInstrumentedTest.kt b/app/src/robolectricTest/java/org/fnives/test/showcase/ui/codekata/CodeKataAuthActivityInstrumentedTest.kt index 82b147e..91d7a8f 100644 --- a/app/src/robolectricTest/java/org/fnives/test/showcase/ui/codekata/CodeKataAuthActivityInstrumentedTest.kt +++ b/app/src/robolectricTest/java/org/fnives/test/showcase/ui/codekata/CodeKataAuthActivityInstrumentedTest.kt @@ -48,4 +48,4 @@ class CodeKataAuthActivityInstrumentedTest : KoinTest { @Test fun networkErrorShowsProperErrorMessage() { } -} \ No newline at end of file +} diff --git a/app/src/robolectricTest/java/org/fnives/test/showcase/ui/codekata/CodeKataLoginRobot.kt b/app/src/robolectricTest/java/org/fnives/test/showcase/ui/codekata/CodeKataLoginRobot.kt index 8ac678b..458a9fa 100644 --- a/app/src/robolectricTest/java/org/fnives/test/showcase/ui/codekata/CodeKataLoginRobot.kt +++ b/app/src/robolectricTest/java/org/fnives/test/showcase/ui/codekata/CodeKataLoginRobot.kt @@ -1,4 +1,3 @@ package org.fnives.test.showcase.ui.codekata -class CodeKataLoginRobot { -} \ No newline at end of file +class CodeKataLoginRobot diff --git a/app/src/sharedTest/java/org/fnives/test/showcase/ui/home/MainActivityInstrumentedTest.kt b/app/src/sharedTest/java/org/fnives/test/showcase/ui/home/MainActivityInstrumentedTest.kt index dad765a..2900305 100644 --- a/app/src/sharedTest/java/org/fnives/test/showcase/ui/home/MainActivityInstrumentedTest.kt +++ b/app/src/sharedTest/java/org/fnives/test/showcase/ui/home/MainActivityInstrumentedTest.kt @@ -31,7 +31,7 @@ class MainActivityInstrumentedTest : KoinTest { private val mockServerScenarioSetup get() = mockServerScenarioSetupTestRule.mockServerScenarioSetup private val mainDispatcherTestRule = MainDispatcherTestRule() - private lateinit var robot : HomeRobot + private lateinit var robot: HomeRobot @Rule @JvmField diff --git a/app/src/sharedTest/java/org/fnives/test/showcase/ui/login/LoginRobot.kt b/app/src/sharedTest/java/org/fnives/test/showcase/ui/login/LoginRobot.kt index a07bb63..0419eec 100644 --- a/app/src/sharedTest/java/org/fnives/test/showcase/ui/login/LoginRobot.kt +++ b/app/src/sharedTest/java/org/fnives/test/showcase/ui/login/LoginRobot.kt @@ -22,7 +22,7 @@ import org.hamcrest.core.IsNot.not class LoginRobot( private val snackbarVerificationHelper: SnackbarVerificationHelper = SnackbarVerificationHelper() -){ +) { fun setupIntentResults() { Intents.intending(hasComponent(MainActivity::class.java.canonicalName)) diff --git a/app/src/sharedTest/java/org/fnives/test/showcase/ui/splash/SplashActivityInstrumentedTest.kt b/app/src/sharedTest/java/org/fnives/test/showcase/ui/splash/SplashActivityInstrumentedTest.kt index 9191338..cb12061 100644 --- a/app/src/sharedTest/java/org/fnives/test/showcase/ui/splash/SplashActivityInstrumentedTest.kt +++ b/app/src/sharedTest/java/org/fnives/test/showcase/ui/splash/SplashActivityInstrumentedTest.kt @@ -26,7 +26,7 @@ class SplashActivityInstrumentedTest : KoinTest { private val mainDispatcherTestRule = MainDispatcherTestRule() private val mockServerScenarioSetupTestRule = MockServerScenarioSetupResetingTestRule() - private lateinit var robot : SplashRobot + private lateinit var robot: SplashRobot @Rule @JvmField From 070f2821031228656a1ee5c43dbb35adee0a565c Mon Sep 17 00:00:00 2001 From: Gergely Hegedus Date: Sat, 29 Jan 2022 00:35:04 +0200 Subject: [PATCH 4/5] Issue#13 Fix typos --- codekata/robolectric.instructionset.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/codekata/robolectric.instructionset.md b/codekata/robolectric.instructionset.md index 121c454..868888f 100644 --- a/codekata/robolectric.instructionset.md +++ b/codekata/robolectric.instructionset.md @@ -487,7 +487,7 @@ fun tearDown() { } ``` -##### Networking syncronization and mocking +##### Networking synchronization and mocking We have a helper method for that, but the basic idea is that, we use our MockWebSetup and synchronize with Espresso using idling resources. ```kotlin @Before @@ -514,7 +514,7 @@ fun tearDown() { Idling Resources makes sure that Espresso awaits the Idling Resource before touching the UI components. Disposable is just a way to remove them from Espresso when we no longer need it. ##### Coroutine Test Setup -We use a TestDispatcher and initialze our database with it as well. +We use a TestDispatcher and initialize our database with it as well. ```kotlin @Before @@ -572,7 +572,7 @@ mockServerScenarioSetup.setScenario( ) ``` -Next via the Robot we input the data and click on the signin: +Next via the Robot we input the data and click on the sign in: ```kotlin robot.setPassword("alma") .setUsername("banan") @@ -627,7 +627,7 @@ robot.assertErrorIsShown(R.string.username_is_invalid) ### 4. `invalidCredentialsGivenShowsProperErrorMessage` -Now we verify network erros. First let's setup the response: +Now we verify network errors. First let's setup the response: ```kotlin mockServerScenarioSetup.setScenario( AuthScenario.InvalidCredentials(username = "alma", password = "banan"), @@ -682,7 +682,7 @@ robot.assertErrorIsShown(R.string.something_went_wrong) ## Conclusion -With that we finished our Robolectric tests, setup might be a bit tidious but we can use TestRules to make the setup reusable. In fact we will do that in the next session. +With that we finished our Robolectric tests, setup might be a bit tedious but we can use TestRules to make the setup reusable. In fact we will do that in the next session. What we have learned: - How to use Robolectric to verify context dependent classes From 833636ac694d2b308201dcd4642a7046f218e9b5 Mon Sep 17 00:00:00 2001 From: Gergely Hegedus Date: Sat, 29 Jan 2022 00:39:04 +0200 Subject: [PATCH 5/5] Issue#13 Fix more codeAnalysis errors --- .../fnives/test/showcase/storage/UserDataLocalStorageTest.kt | 2 +- .../test/showcase/ui/login/AuthActivityInstrumentedTest.kt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/robolectricTest/java/org/fnives/test/showcase/storage/UserDataLocalStorageTest.kt b/app/src/robolectricTest/java/org/fnives/test/showcase/storage/UserDataLocalStorageTest.kt index f378186..efbde57 100644 --- a/app/src/robolectricTest/java/org/fnives/test/showcase/storage/UserDataLocalStorageTest.kt +++ b/app/src/robolectricTest/java/org/fnives/test/showcase/storage/UserDataLocalStorageTest.kt @@ -70,4 +70,4 @@ class UserDataLocalStorageTest( ::createReal ) } -} \ No newline at end of file +} diff --git a/app/src/sharedTest/java/org/fnives/test/showcase/ui/login/AuthActivityInstrumentedTest.kt b/app/src/sharedTest/java/org/fnives/test/showcase/ui/login/AuthActivityInstrumentedTest.kt index e548e18..a54e6f1 100644 --- a/app/src/sharedTest/java/org/fnives/test/showcase/ui/login/AuthActivityInstrumentedTest.kt +++ b/app/src/sharedTest/java/org/fnives/test/showcase/ui/login/AuthActivityInstrumentedTest.kt @@ -26,7 +26,7 @@ class AuthActivityInstrumentedTest : KoinTest { private val mockServerScenarioSetupTestRule = MockServerScenarioSetupResetingTestRule() private val mockServerScenarioSetup get() = mockServerScenarioSetupTestRule.mockServerScenarioSetup private val mainDispatcherTestRule = MainDispatcherTestRule() - private lateinit var robot : LoginRobot + private lateinit var robot: LoginRobot @Rule @JvmField