Issue#13 Add CodeKata for Robolectric Tests
This commit is contained in:
parent
c38e608c8c
commit
03e413fba6
25 changed files with 758 additions and 296 deletions
|
|
@ -38,7 +38,7 @@ android {
|
|||
|
||||
sourceSets {
|
||||
androidTest {
|
||||
java.srcDirs += "src/sharedTest/java"
|
||||
// java.srcDirs += "src/sharedTest/java"
|
||||
assets.srcDirs += files("$projectDir/schemas".toString())
|
||||
}
|
||||
test {
|
||||
|
|
|
|||
|
|
@ -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<View> = 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<View>(com.google.android.material.R.id.snackbar_text) != null) {
|
||||
uiController.loopMainThreadForAtLeast(100)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -2,9 +2,6 @@ package org.fnives.test.showcase.testutils.configuration
|
|||
|
||||
object SpecificTestConfigurationsFactory : TestConfigurationsFactory {
|
||||
|
||||
override fun createSnackbarVerification(): SnackbarVerificationHelper =
|
||||
AndroidTestSnackbarVerificationHelper
|
||||
|
||||
override fun createSharedMigrationTestRuleFactory(): SharedMigrationTestRuleFactory =
|
||||
AndroidMigrationTestRuleFactory
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -2,9 +2,6 @@ package org.fnives.test.showcase.testutils.configuration
|
|||
|
||||
object SpecificTestConfigurationsFactory : TestConfigurationsFactory {
|
||||
|
||||
override fun createSnackbarVerification(): SnackbarVerificationHelper =
|
||||
RobolectricSnackbarVerificationHelper
|
||||
|
||||
override fun createSharedMigrationTestRuleFactory(): SharedMigrationTestRuleFactory =
|
||||
RobolectricMigrationTestHelperFactory
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<ShadowSnackbar>()
|
||||
|
||||
@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
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<AuthActivity>
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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() {
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
package org.fnives.test.showcase.ui.codekata
|
||||
|
||||
class CodeKataLoginRobot {
|
||||
}
|
||||
|
|
@ -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<View> = 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<View>(com.google.android.material.R.id.snackbar_text) != null) {
|
||||
uiController.loopMainThreadForAtLeast(100)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("TestFunctionName")
|
||||
fun SnackbarVerificationTestRule(): SnackbarVerificationHelper =
|
||||
SpecificTestConfigurationsFactory.createSnackbarVerification()
|
||||
|
|
|
|||
|
|
@ -8,7 +8,5 @@ package org.fnives.test.showcase.testutils.configuration
|
|||
*/
|
||||
interface TestConfigurationsFactory {
|
||||
|
||||
fun createSnackbarVerification(): SnackbarVerificationHelper
|
||||
|
||||
fun createSharedMigrationTestRuleFactory(): SharedMigrationTestRuleFactory
|
||||
}
|
||||
|
|
|
|||
|
|
@ -46,7 +46,8 @@ class MainDispatcherTestRule : TestRule {
|
|||
testDispatcher.scheduler.advanceTimeBy(delayInMillis)
|
||||
}
|
||||
|
||||
private fun TestDispatcher.advanceUntilIdleWithIdlingResources() {
|
||||
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
|
||||
|
|
@ -54,4 +55,5 @@ class MainDispatcherTestRule : TestRule {
|
|||
}
|
||||
scheduler.advanceUntilIdle()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +0,0 @@
|
|||
package org.fnives.test.showcase.testutils.robot
|
||||
|
||||
interface Robot {
|
||||
|
||||
fun init()
|
||||
|
||||
fun release()
|
||||
}
|
||||
|
|
@ -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<T : Robot>(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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -21,38 +21,48 @@ object SetupAuthenticationState : KoinTest {
|
|||
mockServerScenarioSetup: MockServerScenarioSetup,
|
||||
resetIntents: Boolean = true
|
||||
) {
|
||||
resetIntentsIfNeeded(resetIntents) {
|
||||
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()
|
||||
|
||||
mainDispatcherTestRule.advanceUntilIdleWithIdlingResources()
|
||||
|
||||
activityScenario.safeClose()
|
||||
resetIntentsIfNeeded(resetIntents)
|
||||
}
|
||||
}
|
||||
|
||||
fun setupLogout(
|
||||
mainDispatcherTestRule: MainDispatcherTestRule,
|
||||
resetIntents: Boolean = true
|
||||
) {
|
||||
resetIntentsIfNeeded(resetIntents) {
|
||||
val activityScenario = ActivityScenario.launch(MainActivity::class.java)
|
||||
activityScenario.moveToState(Lifecycle.State.RESUMED)
|
||||
HomeRobot().clickSignOut()
|
||||
|
||||
val homeRobot = HomeRobot()
|
||||
homeRobot.setupIntentResults()
|
||||
homeRobot.clickSignOut()
|
||||
mainDispatcherTestRule.advanceUntilIdleWithIdlingResources()
|
||||
|
||||
activityScenario.safeClose()
|
||||
resetIntentsIfNeeded(resetIntents)
|
||||
}
|
||||
}
|
||||
|
||||
private fun resetIntentsIfNeeded(resetIntents: Boolean) {
|
||||
if (resetIntents && IntentStubberRegistry.isLoaded()) {
|
||||
private fun resetIntentsIfNeeded(resetIntents: Boolean, action: () -> Unit) {
|
||||
val wasInitialized = IntentStubberRegistry.isLoaded()
|
||||
if (!wasInitialized) {
|
||||
Intents.init()
|
||||
}
|
||||
action()
|
||||
Intents.release()
|
||||
if (resetIntents && wasInitialized) {
|
||||
Intents.init()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<AuthActivity>
|
||||
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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue