Issue#106 Update SharedTest instruction.set

This commit is contained in:
Gergely Hegedus 2022-09-26 18:32:12 +03:00
parent d5ce57769f
commit e6349363d7
9 changed files with 258 additions and 7 deletions

View file

@ -0,0 +1,52 @@
package org.fnives.test.showcase.ui.login.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
import org.koin.test.KoinTest
@RunWith(AndroidJUnit4::class)
@OptIn(ExperimentalCoroutinesApi::class)
@Ignore("CodeKata")
@Suppress("EmptyFunctionBlock")
open class CodeKataAuthActivitySharedTest : KoinTest {
@Before
fun setup() {
}
@After
fun tearDown() {
GlobalContext.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() {
}
}

View file

@ -0,0 +1,69 @@
package org.fnives.test.showcase.ui.login.codekata
import androidx.annotation.StringRes
import androidx.test.espresso.Espresso
import androidx.test.espresso.action.ViewActions
import androidx.test.espresso.assertion.ViewAssertions
import androidx.test.espresso.intent.Intents
import androidx.test.espresso.intent.matcher.IntentMatchers
import androidx.test.espresso.matcher.ViewMatchers
import org.fnives.test.showcase.R
import org.fnives.test.showcase.android.testutil.intent.notIntended
import org.fnives.test.showcase.android.testutil.snackbar.SnackbarVerificationHelper.assertSnackBarIsNotShown
import org.fnives.test.showcase.android.testutil.snackbar.SnackbarVerificationHelper.assertSnackBarIsShownWithText
import org.fnives.test.showcase.ui.home.MainActivity
import org.hamcrest.core.IsNot
class CodeKataSharedRobotTest {
fun setUsername(username: String): CodeKataSharedRobotTest = apply {
Espresso.onView(ViewMatchers.withId(R.id.user_edit_text))
.perform(ViewActions.replaceText(username), ViewActions.closeSoftKeyboard())
}
fun setPassword(password: String): CodeKataSharedRobotTest = apply {
Espresso.onView(ViewMatchers.withId(R.id.password_edit_text))
.perform(ViewActions.replaceText(password), ViewActions.closeSoftKeyboard())
}
fun clickOnLogin(): CodeKataSharedRobotTest = apply {
Espresso.onView(ViewMatchers.withId(R.id.login_cta))
.perform(ViewActions.click())
}
fun assertPassword(password: String): CodeKataSharedRobotTest = apply {
Espresso.onView(ViewMatchers.withId((R.id.password_edit_text)))
.check(ViewAssertions.matches(ViewMatchers.withText(password)))
}
fun assertUsername(username: String): CodeKataSharedRobotTest = apply {
Espresso.onView(ViewMatchers.withId((R.id.user_edit_text)))
.check(ViewAssertions.matches(ViewMatchers.withText(username)))
}
fun assertLoadingBeforeRequests(): CodeKataSharedRobotTest = apply {
Espresso.onView(ViewMatchers.withId(R.id.loading_indicator))
.check(ViewAssertions.matches(ViewMatchers.isDisplayed()))
}
fun assertNotLoading(): CodeKataSharedRobotTest = apply {
Espresso.onView(ViewMatchers.withId(R.id.loading_indicator))
.check(ViewAssertions.matches(IsNot.not(ViewMatchers.isDisplayed())))
}
fun assertErrorIsShown(@StringRes stringResID: Int): CodeKataSharedRobotTest = apply {
assertSnackBarIsShownWithText(stringResID)
}
fun assertErrorIsNotShown(): CodeKataSharedRobotTest = apply {
assertSnackBarIsNotShown()
}
fun assertNavigatedToHome(): CodeKataSharedRobotTest = apply {
Intents.intended(IntentMatchers.hasComponent(MainActivity::class.java.canonicalName))
}
fun assertNotNavigatedToHome(): CodeKataSharedRobotTest = apply {
notIntended(IntentMatchers.hasComponent(MainActivity::class.java.canonicalName))
}
}

View file

@ -0,0 +1,16 @@
package org.fnives.test.showcase.ui.login.codekata.rule.dispatcher
import kotlinx.coroutines.ExperimentalCoroutinesApi
import org.junit.rules.TestRule
import org.junit.runner.Description
import org.junit.runners.model.Statement
@OptIn(ExperimentalCoroutinesApi::class)
class CodeKataMainDispatcherRule : TestRule {
override fun apply(base: Statement, description: Description): Statement =
object : Statement() {
override fun evaluate() {
TODO("Not yet implemented")
}
}
}

View file

@ -0,0 +1,41 @@
package org.fnives.test.showcase.ui.login.codekata.rule.dispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestDispatcher
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
import org.fnives.test.showcase.storage.database.DatabaseInitialization
import org.fnives.test.showcase.testutils.storage.TestDatabaseInitialization
import org.junit.rules.TestRule
import org.junit.runner.Description
import org.junit.runners.model.Statement
/**
* Sets up the Dispatcher as Main and as the [DatabaseInitialization]'s dispatcher.
*/
@OptIn(ExperimentalCoroutinesApi::class)
class PlainMainDispatcherRule(private val useStandard: Boolean = true) : TestRule {
private var _testDispatcher: TestDispatcher? = null
val testDispatcher
get() = _testDispatcher
?: throw IllegalStateException("TestDispatcher is accessed before it is initialized!")
override fun apply(base: Statement, description: Description): Statement = object : Statement() {
override fun evaluate() {
try {
val dispatcher = if (useStandard) StandardTestDispatcher() else UnconfinedTestDispatcher()
Dispatchers.setMain(dispatcher)
TestDatabaseInitialization.overwriteDatabaseInitialization(dispatcher)
_testDispatcher = dispatcher
base.evaluate()
} finally {
_testDispatcher = null
Dispatchers.resetMain()
}
}
}
}

View file

@ -0,0 +1,11 @@
package org.fnives.test.showcase.ui.login.codekata.rule.intent
import org.junit.rules.TestRule
import org.junit.runner.Description
import org.junit.runners.model.Statement
class CodeKataIntentInitRule : TestRule {
override fun apply(base: Statement, description: Description): Statement {
TODO()
}
}

View file

@ -0,0 +1,25 @@
package org.fnives.test.showcase.ui.login.codekata.rule.intent
import androidx.test.espresso.intent.Intents
import org.junit.rules.TestRule
import org.junit.runner.Description
import org.junit.runners.model.Statement
/**
* Takes care of [Intents] initialization.
*/
class PlainIntentInitRule : TestRule {
override fun apply(base: Statement, description: Description): Statement {
return object : Statement() {
@Throws(Throwable::class)
override fun evaluate() {
try {
Intents.init()
base.evaluate()
} finally {
Intents.release()
}
}
}
}
}

View file

@ -0,0 +1,9 @@
package org.fnives.test.showcase.ui.login.codekata
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Ignore
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
@Ignore("CodeKata")
class CodeKataAuthActivityTest : CodeKataAuthActivitySharedTest()

View file

@ -0,0 +1,9 @@
package org.fnives.test.showcase.ui.login.codekata
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Ignore
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
@Ignore("CodeKata")
class CodeKataAuthActivityTest : CodeKataAuthActivitySharedTest()

View file

@ -13,7 +13,12 @@ In this testing instruction set you will learn how to write simple tests running
## Login UI Test
Instead of writing new tests from scratch, we will modify our existing Robolectric tests so they can be run on a Real Android device as well.N
For this we already have a `sharedTest` package.
For this we already have a ~~`sharedTest` package~~ a separate app-shared-test module.
> Sharing sourceSets between unitTest and androidTest is no longer supported in Android Studio.
> You may find a bunch of artiles referencing that way of sharing or even on Robolectric sites, but these are now outdated.
> We are using the recommendation to that issue which is a shared-test module which depends on :app and it's tests depend on the module. testImplementation project(:shared-test) androidTestImplementation project(:shared-test).
> This may seem circular at first, but it's not: shared-test depends on app's main, while app's Tests depend on shared-test.
Our classes will be `CodeKataAuthActivitySharedTest` and `CodeKataSharedRobotTest`.
@ -34,8 +39,8 @@ Let's open `org.fnives.test.showcase.ui.login.codekata.CodeKataAuthActivityShare
We can see it's identical as our original `org.fnives.test.showcase.ui.codekata.CodeKataAuthActivityInstrumentedTest`.
So let's copy our existing code from the Robolectric test here. For that we can use the body of `org.fnives.test.showcase.ui.RobolectricAuthActivityInstrumentedTest`.
You immediately notice that there are no import issues. That's because sharedTest package is added to the test sources. You may check out the `app/build.gradle` to see how that's done.
However we need to modify our robot:
Of course keep the `open` and the `CodeKataAuthActivitySharedTest` class name and package.
We need to modify our robot:
```kotlin
// Instead of this:
private lateinit var robot: RobolectricLoginRobot
@ -51,11 +56,25 @@ robot = CodeKataSharedRobotTest()
For our starting point, this is all the setup we need. What we now will do is modify this piece of class, so it not only runs via Robolectric, but it can run on Real Devices as well.
Now, go to the classes that extend this and remove the `@Ignore("CodeKata")` annotation: `CodeKataAuthActivityTest` in both `unitTest` and `androidTest`.
> This child classes run when testing so we are sharing a base class, but the child classess will run every Test of the Base class.
### 1. Threads
So to discover the differences, let's handle them one by one, by Running our Test.
In shared tests, at least for me, it defaults to Android Test when running the class. So make sure your device is connected, and run the `invalidCredentialsGivenShowsProperErrorMessage` Test. It should start on your device and shall crash.
You will see something similar:
Open `CodeKataAuthActivityTest` inside `androidTest` and overwrite `invalidCredentialsGivenShowsProperErrorMessage`:
```kotlin
@RunWith(AndroidJUnit4::class)
class CodeKataAuthActivityTest : CodeKataAuthActivitySharedTest() {
// if invalidCredentialsGivenShowsProperErrorMessage is not open in base, open it
override fun invalidCredentialsGivenShowsProperErrorMessage() {
super.invalidCredentialsGivenShowsProperErrorMessage()
}
}
```
Make sure your device (tested on API=30) is connected, and run the `invalidCredentialsGivenShowsProperErrorMessage` Test. It should start on your device and shall crash.
You will see something similar in logcat:
```kotlin
java.lang.IllegalStateException: Cannot invoke setValue on a background thread
at androidx.lifecycle.LiveData.assertMainThread(LiveData.java:487)
@ -66,7 +85,7 @@ So that brings us to the first difference: *while Robolectric uses the same thre
So the issue is with this line: `testDispatcher.advanceUntilIdleWithIdlingResources()`. Since we are in the InstrumentedTest's thread, all our coroutines will run there as well, which doesn't play well with LiveData.
One idea would be to use LiveData `ArchTaskExecutor.getInstance()` and ensure our LiveData doesn't care about the Thread they are set from, **but** then we would touch our Views from Non-Main Thread, which is still an issue.
**So Instead** What we need to do is run our coroutines on the actual mainThread. We have a handy `runOnUIAwaitOnCurrent` function for that, so let's use it in our `invalidCredentialsGivenShowsProperErrorMessage` test, wrap around our dispatcher call.
**So Instead** What we need to do is run our coroutines on the actual mainThread. We have a handy `runOnUIAwaitOnCurrent` function for that, so let's use it in our `invalidCredentialsGivenShowsProperErrorMessage` (inside the base class) test, wrap around our dispatcher call.
The full function now will look like this:
```kotlin
@ -458,7 +477,7 @@ To resolve this fast, a possible way is like this:
Another issue can be that Crashlytics or similar services is enabled in your tests. This can be resolved by the same principle as the HiltTestApplication issue, aka custom `AndroidJunitRunner`. Your custom TestClass will initialize only what it needs to.
#### 4. Dialogs
Dialogs cannot be tested properly via Robolectric without usage of Shadows, but they can be on Real Device. So what I usually do is setup a function which does one thing in one sourceset while does something else in another. You can see such example like `SpecificTestConfigurationsFactory`. To ease the usage I usually put a function in the sharedTest which uses the object `SpecificTestConfigurationsFactory`.
Dialogs cannot be tested properly via Robolectric without usage of Shadows, but they can be on Real Device. So what I usually do is setup a function which does one thing in one sourceset while does something else in another. So either you will have to test them only on real device, or you can create helper module which in AndroidTest uses actual Espresso calls, while if Robolectric is active it uses the Shadow. See how `SharedMigrationTestRule` is setup and extended.
#### 5. Resource Access
Accessing test Resource files can also be an issue, you might not able to access your same test/res folder in AndroidTests. A way to do this is to declare the same folder as androidTest/assets in build gradle and similar to dialogs, create a function which uses Assets in Android Tests and uses Resources in Robolectric tests.