Issue#41 Copy full example into separate module with Hilt Integration

This commit is contained in:
Gergely Hegedus 2022-09-27 17:16:05 +03:00
parent 69e76dc0da
commit 52a99a82fc
229 changed files with 8416 additions and 11 deletions

1
hilt/hilt-app-shared-test/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/build

View file

@ -0,0 +1,49 @@
plugins {
id 'com.android.library'
id 'org.jetbrains.kotlin.android'
}
android {
compileSdk 31
defaultConfig {
minSdk 21
targetSdk 31
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
consumerProguardFiles "consumer-rules.pro"
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
sourceSets {
main {
assets.srcDirs += files("$projectDir/../hilt-app/schemas".toString())
resources.srcDirs += files("$projectDir/../hilt-app/schemas".toString())
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = '1.8'
}
}
// since it itself contains the Test it doesn't have tests of it's own
disableTestTasks(this)
dependencies {
implementation project(":hilt:hilt-app")
implementation project(':test-util-android')
implementation testFixtures(project(':hilt:hilt-core'))
implementation "com.google.dagger:hilt-android-testing:$hilt_version"
implementation project(':test-util-shared-robolectric')
api project(':hilt:hilt-network-di-test-util')
applyAppSharedTestDependenciesTo(this)
}

View file

@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="org.fnives.test.showcase.hilt.test.shared">
</manifest>

View file

@ -0,0 +1,8 @@
package org.fnives.test.showcase.hilt.test.shared.di
import org.fnives.test.showcase.hilt.BuildConfig
object TestBaseUrlHolder {
var url = BuildConfig.BASE_URL
}

View file

@ -0,0 +1,79 @@
package org.fnives.test.showcase.hilt.test.shared.storage.migration
import androidx.room.Room
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import org.fnives.test.showcase.android.testutil.SharedMigrationTestRule
import org.fnives.test.showcase.hilt.storage.LocalDatabase
import org.fnives.test.showcase.hilt.storage.favourite.FavouriteEntity
import org.fnives.test.showcase.hilt.storage.migation.Migration1To2
import org.junit.Assert
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import java.io.IOException
/**
* reference:
* https://medium.com/androiddevelopers/testing-room-migrations-be93cdb0d975
* https://developer.android.com/training/data-storage/room/migrating-db-versions
*/
@RunWith(AndroidJUnit4::class)
open class MigrationToLatestInstrumentedSharedTest {
@get:Rule
val helper = SharedMigrationTestRule<LocalDatabase>(instrumentation = InstrumentationRegistry.getInstrumentation())
private fun getMigratedRoomDatabase(): LocalDatabase {
val database: LocalDatabase = Room.databaseBuilder(
InstrumentationRegistry.getInstrumentation().targetContext,
LocalDatabase::class.java,
TEST_DB
)
.addMigrations(Migration1To2())
.build()
// close the database and release any stream resources when the test finishes
helper.closeWhenFinished(database)
return database
}
@Test
@Throws(IOException::class)
open fun migrate1To2() {
val expectedEntities = setOf(
FavouriteEntity("123"),
FavouriteEntity("124"),
FavouriteEntity("125")
)
val version1DB = helper.createDatabase(
name = TEST_DB,
version = 1
)
version1DB.run {
execSQL("INSERT OR IGNORE INTO `FavouriteEntity` (`contentId`) VALUES (\"123\")")
execSQL("INSERT OR IGNORE INTO `FavouriteEntity` (`contentId`) VALUES (124)")
execSQL("INSERT OR IGNORE INTO `FavouriteEntity` (`contentId`) VALUES (125)")
}
version1DB.close()
val version2DB = helper.runMigrationsAndValidate(
name = TEST_DB,
version = 2,
validateDroppedTables = true,
Migration1To2()
)
version2DB.close()
val favouriteDao = getMigratedRoomDatabase().favouriteDao
val entities = runBlocking { favouriteDao.get().first() }.toSet()
Assert.assertEquals(expectedEntities, entities)
}
companion object {
private const val TEST_DB = "migration-test"
}
}

View file

@ -0,0 +1,37 @@
package org.fnives.test.showcase.hilt.test.shared.testutils
import org.fnives.test.showcase.hilt.network.testutil.HttpsConfigurationModuleTemplate
import org.fnives.test.showcase.hilt.test.shared.di.TestBaseUrlHolder
import org.fnives.test.showcase.network.mockserver.MockServerScenarioSetup
import org.junit.rules.TestRule
import org.junit.runner.Description
import org.junit.runners.model.Statement
class MockServerScenarioSetupTestRule : TestRule {
lateinit var mockServerScenarioSetup: MockServerScenarioSetup
override fun apply(base: Statement, description: Description): Statement = createStatement(base)
private fun createStatement(base: Statement) = object : Statement() {
@Throws(Throwable::class)
override fun evaluate() {
before()
try {
base.evaluate()
} finally {
after()
}
}
}
private fun before() {
val (mockServerScenarioSetup, url) = HttpsConfigurationModuleTemplate.startWithHTTPSMockWebServer()
TestBaseUrlHolder.url = url
this.mockServerScenarioSetup = mockServerScenarioSetup
}
private fun after() {
mockServerScenarioSetup.stop()
}
}

View file

@ -0,0 +1,32 @@
package org.fnives.test.showcase.hilt.test.shared.testutils.idling
import org.fnives.test.showcase.hilt.ui.shared.executor.AsyncTaskExecutor
import org.fnives.test.showcase.hilt.ui.shared.executor.TaskExecutor
import org.junit.rules.TestRule
import org.junit.runner.Description
import org.junit.runners.model.Statement
/**
* Similar Test Rule to InstantTaskExecutorRule just for the [AsyncTaskExecutor] to make AsyncDiffUtil synchronized.
*/
class AsyncDiffUtilInstantTestRule : TestRule {
override fun apply(base: Statement, description: Description): Statement =
object : Statement() {
@Throws(Throwable::class)
override fun evaluate() {
AsyncTaskExecutor.delegate = object : TaskExecutor {
override fun executeOnDiskIO(runnable: Runnable) {
runnable.run()
}
override fun postToMainThread(runnable: Runnable) {
runnable.run()
}
}
base.evaluate()
AsyncTaskExecutor.delegate = null
}
}
}

View file

@ -0,0 +1,52 @@
package org.fnives.test.showcase.hilt.test.shared.testutils.idling
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestDispatcher
import org.fnives.test.showcase.android.testutil.synchronization.idlingresources.anyResourceNotIdle
import org.fnives.test.showcase.android.testutil.synchronization.idlingresources.awaitIdlingResources
import org.fnives.test.showcase.android.testutil.synchronization.runOnUIAwaitOnCurrent
import org.fnives.test.showcase.hilt.test.shared.testutils.storage.TestDatabaseInitialization
import org.junit.rules.TestRule
import org.junit.runner.Description
import org.junit.runners.model.Statement
@OptIn(ExperimentalCoroutinesApi::class)
class DatabaseDispatcherTestRule : TestRule {
lateinit var testDispatcher: TestDispatcher
override fun apply(base: Statement, description: Description): Statement =
object : Statement() {
@Throws(Throwable::class)
override fun evaluate() {
val dispatcher = StandardTestDispatcher()
testDispatcher = dispatcher
TestDatabaseInitialization.dispatcher = dispatcher
base.evaluate()
}
}
fun advanceUntilIdleWithIdlingResources() = runOnUIAwaitOnCurrent {
testDispatcher.advanceUntilIdleWithIdlingResources()
}
fun advanceUntilIdle() = runOnUIAwaitOnCurrent {
testDispatcher.scheduler.advanceUntilIdle()
}
fun advanceTimeBy(delayInMillis: Long) = runOnUIAwaitOnCurrent {
testDispatcher.scheduler.advanceTimeBy(delayInMillis)
}
companion object {
fun TestDispatcher.advanceUntilIdleWithIdlingResources() {
scheduler.advanceUntilIdle() // advance until a request is sent
while (anyResourceNotIdle()) { // 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()
}
}
}

View file

@ -0,0 +1,14 @@
package org.fnives.test.showcase.hilt.test.shared.testutils.idling
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestDispatcher
import org.fnives.test.showcase.hilt.test.shared.testutils.storage.TestDatabaseInitialization
import org.fnives.test.showcase.android.testutil.synchronization.MainDispatcherTestRule as LibMainDispatcherTestRule
@OptIn(ExperimentalCoroutinesApi::class)
class MainDispatcherTestRule(useStandard: Boolean = true) : LibMainDispatcherTestRule(useStandard) {
override fun onTestDispatcherInitialized(testDispatcher: TestDispatcher) {
TestDatabaseInitialization.dispatcher = testDispatcher
}
}

View file

@ -0,0 +1,23 @@
package org.fnives.test.showcase.hilt.test.shared.testutils.idling
import org.fnives.test.showcase.android.testutil.synchronization.idlingresources.CompositeDisposable
import org.fnives.test.showcase.android.testutil.synchronization.idlingresources.IdlingResourceDisposable
import org.fnives.test.showcase.hilt.network.testutil.NetworkSynchronization
import javax.inject.Inject
class NetworkSynchronizationHelper @Inject constructor(private val networkSynchronization: NetworkSynchronization) {
private val compositeDisposable = CompositeDisposable()
fun setup() {
networkSynchronization.networkIdlingResources().map {
IdlingResourceDisposable(it)
}.forEach {
compositeDisposable.add(it)
}
}
fun dispose() {
compositeDisposable.dispose()
}
}

View file

@ -0,0 +1,69 @@
package org.fnives.test.showcase.hilt.test.shared.testutils.statesetup
import androidx.lifecycle.Lifecycle
import androidx.test.core.app.ActivityScenario
import androidx.test.espresso.intent.Intents
import androidx.test.runner.intent.IntentStubberRegistry
import org.fnives.test.showcase.android.testutil.activity.safeClose
import org.fnives.test.showcase.hilt.test.shared.testutils.idling.MainDispatcherTestRule
import org.fnives.test.showcase.hilt.test.shared.ui.auth.LoginRobot
import org.fnives.test.showcase.hilt.test.shared.ui.home.HomeRobot
import org.fnives.test.showcase.hilt.ui.auth.AuthActivity
import org.fnives.test.showcase.hilt.ui.home.MainActivity
import org.fnives.test.showcase.network.mockserver.MockServerScenarioSetup
import org.fnives.test.showcase.network.mockserver.scenario.auth.AuthScenario
import org.koin.test.KoinTest
object SetupAuthenticationState : KoinTest {
fun setupLogin(
mainDispatcherTestRule: MainDispatcherTestRule,
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()
}
}
fun setupLogout(
mainDispatcherTestRule: MainDispatcherTestRule,
resetIntents: Boolean = true,
) {
resetIntentsIfNeeded(resetIntents) {
val activityScenario = ActivityScenario.launch(MainActivity::class.java)
activityScenario.moveToState(Lifecycle.State.RESUMED)
val homeRobot = HomeRobot()
homeRobot.setupIntentResults()
homeRobot.clickSignOut()
mainDispatcherTestRule.advanceUntilIdleWithIdlingResources()
activityScenario.safeClose()
}
}
private fun resetIntentsIfNeeded(resetIntents: Boolean, action: () -> Unit) {
val wasInitialized = IntentStubberRegistry.isLoaded()
if (!wasInitialized) {
Intents.init()
}
action()
Intents.release()
if (resetIntents && wasInitialized) {
Intents.init()
}
}
}

View file

@ -0,0 +1,48 @@
package org.fnives.test.showcase.hilt.test.shared.testutils.storage
import android.content.Context
import androidx.room.Room
import dagger.Module
import dagger.Provides
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import dagger.hilt.testing.TestInstallIn
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.asExecutor
import org.fnives.test.showcase.hilt.di.StorageModule
import org.fnives.test.showcase.hilt.storage.LocalDatabase
import javax.inject.Singleton
/**
* Reloads the Database module, so it uses the inMemory database with the switched out Executors.
*
* This is needed so in AndroidTests not a real File based device is used.
* This speeds tests up, and isolates them better, there will be no junk in the Database file from previous tests.
*/
@Module
@TestInstallIn(
components = [SingletonComponent::class],
replaces = [StorageModule::class]
)
object TestDatabaseInitialization {
var dispatcher: CoroutineDispatcher? = null
@Suppress("ObjectPropertyName")
private val _dispatcher: CoroutineDispatcher
get() = dispatcher ?: throw IllegalStateException("TestDispatcher is not initialized")
fun create(context: Context, dispatcher: CoroutineDispatcher = this._dispatcher): LocalDatabase {
val executor = dispatcher.asExecutor()
return Room.inMemoryDatabaseBuilder(context, LocalDatabase::class.java)
.setTransactionExecutor(executor)
.setQueryExecutor(executor)
.allowMainThreadQueries()
.build()
}
@Singleton
@Provides
fun provideLocalDatabase(@ApplicationContext context: Context): LocalDatabase =
create(context)
}

View file

@ -0,0 +1,41 @@
package org.fnives.test.showcase.hilt.test.shared.ui
import dagger.hilt.android.testing.HiltAndroidRule
import org.fnives.test.showcase.hilt.test.shared.testutils.idling.NetworkSynchronizationHelper
import org.junit.After
import org.junit.Before
import org.junit.Rule
import javax.inject.Inject
open class NetworkSynchronizedActivityTest {
@Inject
lateinit var networkSynchronizationHelper: NetworkSynchronizationHelper
@get:Rule
val hiltRule = HiltAndroidRule(this)
@Before
fun setup() {
setupBeforeInjection()
hiltRule.inject()
networkSynchronizationHelper.setup()
setupAfterInjection()
}
open fun setupBeforeInjection() {
}
open fun setupAfterInjection() {
}
@After
fun tearDown() {
networkSynchronizationHelper.dispose()
additionalTearDown()
}
open fun additionalTearDown() {
}
}

View file

@ -0,0 +1,138 @@
package org.fnives.test.showcase.hilt.test.shared.ui.auth
import androidx.test.core.app.ActivityScenario
import androidx.test.espresso.intent.Intents
import org.fnives.test.showcase.android.testutil.activity.SafeCloseActivityRule
import org.fnives.test.showcase.android.testutil.intent.DismissSystemDialogsRule
import org.fnives.test.showcase.android.testutil.screenshot.ScreenshotRule
import org.fnives.test.showcase.hilt.R
import org.fnives.test.showcase.hilt.test.shared.testutils.MockServerScenarioSetupTestRule
import org.fnives.test.showcase.hilt.test.shared.testutils.idling.MainDispatcherTestRule
import org.fnives.test.showcase.hilt.test.shared.ui.NetworkSynchronizedActivityTest
import org.fnives.test.showcase.hilt.ui.auth.AuthActivity
import org.fnives.test.showcase.network.mockserver.MockServerScenarioSetup
import org.fnives.test.showcase.network.mockserver.scenario.auth.AuthScenario
import org.junit.Rule
import org.junit.Test
import org.junit.rules.RuleChain
@Suppress("TestFunctionName")
open class AuthActivityInstrumentedSharedTest : NetworkSynchronizedActivityTest() {
private lateinit var activityScenario: ActivityScenario<AuthActivity>
private val mainDispatcherTestRule = MainDispatcherTestRule()
private val mockServerScenarioSetupTestRule = MockServerScenarioSetupTestRule()
private val mockServerScenarioSetup: MockServerScenarioSetup get() = mockServerScenarioSetupTestRule.mockServerScenarioSetup
private lateinit var robot: LoginRobot
@Rule
@JvmField
val ruleOrder: RuleChain = RuleChain.outerRule(DismissSystemDialogsRule())
.around(mockServerScenarioSetupTestRule)
.around(mainDispatcherTestRule)
.around(SafeCloseActivityRule { activityScenario })
.around(ScreenshotRule("test-showcase"))
override fun setupAfterInjection() {
Intents.init()
robot = LoginRobot()
}
override fun additionalTearDown() {
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")
)
activityScenario = ActivityScenario.launch(AuthActivity::class.java)
robot
.setPassword("alma")
.setUsername("banan")
.assertPassword("alma")
.assertUsername("banan")
.clickOnLogin()
.assertLoadingBeforeRequests()
mainDispatcherTestRule.advanceUntilIdleWithIdlingResources()
robot.assertNavigatedToHome()
}
/** GIVEN empty password and username WHEN signIn THEN error password is shown */
@Test
fun emptyPasswordShowsProperErrorMessage() {
activityScenario = ActivityScenario.launch(AuthActivity::class.java)
robot
.setUsername("banan")
.assertUsername("banan")
.clickOnLogin()
.assertLoadingBeforeRequests()
mainDispatcherTestRule.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() {
activityScenario = ActivityScenario.launch(AuthActivity::class.java)
robot
.setPassword("banan")
.assertPassword("banan")
.clickOnLogin()
.assertLoadingBeforeRequests()
mainDispatcherTestRule.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")
)
activityScenario = ActivityScenario.launch(AuthActivity::class.java)
robot
.setUsername("alma")
.setPassword("banan")
.assertUsername("alma")
.assertPassword("banan")
.clickOnLogin()
.assertLoadingBeforeRequests()
mainDispatcherTestRule.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")
)
activityScenario = ActivityScenario.launch(AuthActivity::class.java)
robot
.setUsername("alma")
.setPassword("banan")
.assertUsername("alma")
.assertPassword("banan")
.clickOnLogin()
.assertLoadingBeforeRequests()
mainDispatcherTestRule.advanceUntilIdleWithIdlingResources()
robot.assertErrorIsShown(R.string.something_went_wrong)
.assertNotNavigatedToHome()
.assertNotLoading()
}
}

View file

@ -0,0 +1,94 @@
package org.fnives.test.showcase.hilt.test.shared.ui.auth
import android.app.Activity
import android.app.Instrumentation
import android.content.Intent
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
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.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.android.testutil.viewaction.progressbar.ReplaceProgressBarDrawableToStatic
import org.fnives.test.showcase.hilt.R
import org.fnives.test.showcase.hilt.ui.home.MainActivity
import org.hamcrest.core.IsNot.not
class LoginRobot {
fun setupIntentResults() {
Intents.intending(hasComponent(MainActivity::class.java.canonicalName))
.respondWith(Instrumentation.ActivityResult(Activity.RESULT_OK, Intent()))
}
/**
* Needed because Espresso idling waits until mainThread is idle.
*
* However, ProgressBar keeps the main thread active since it's animating.
*
* Another solution is described here: https://proandroiddev.com/progressbar-animations-with-espresso-57f826102187
* In short they replace the inflater to remove animations, by using custom test runner.
*/
fun replaceProgressBar() = apply {
onView(withId(R.id.loading_indicator)).perform(ReplaceProgressBarDrawableToStatic())
}
fun setUsername(username: String): LoginRobot = apply {
onView(withId(R.id.user_edit_text))
.perform(ViewActions.replaceText(username), ViewActions.closeSoftKeyboard())
}
fun setPassword(password: String): LoginRobot = apply {
onView(withId(R.id.password_edit_text))
.perform(ViewActions.replaceText(password), ViewActions.closeSoftKeyboard())
}
fun clickOnLogin() = apply {
replaceProgressBar()
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 assertErrorIsShown(@StringRes stringResID: Int) = apply {
assertSnackBarIsShownWithText(stringResID)
}
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 assertErrorIsNotShown() = apply {
assertSnackBarIsNotShown()
}
fun assertNavigatedToHome() = apply {
intended(hasComponent(MainActivity::class.java.canonicalName))
}
fun assertNotNavigatedToHome() = apply {
notIntended(hasComponent(MainActivity::class.java.canonicalName))
}
}

View file

@ -0,0 +1,123 @@
package org.fnives.test.showcase.hilt.test.shared.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
import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.contrib.RecyclerViewActions
import androidx.test.espresso.intent.Intents
import androidx.test.espresso.intent.matcher.IntentMatchers
import androidx.test.espresso.matcher.ViewMatchers
import androidx.test.espresso.matcher.ViewMatchers.hasChildCount
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
import androidx.test.espresso.matcher.ViewMatchers.withChild
import androidx.test.espresso.matcher.ViewMatchers.withEffectiveVisibility
import androidx.test.espresso.matcher.ViewMatchers.withId
import androidx.test.espresso.matcher.ViewMatchers.withParent
import androidx.test.espresso.matcher.ViewMatchers.withText
import org.fnives.test.showcase.android.testutil.intent.notIntended
import org.fnives.test.showcase.android.testutil.viewaction.imageview.WithDrawable
import org.fnives.test.showcase.android.testutil.viewaction.recycler.RemoveItemAnimations
import org.fnives.test.showcase.android.testutil.viewaction.swiperefresh.PullToRefresh
import org.fnives.test.showcase.hilt.R
import org.fnives.test.showcase.hilt.ui.auth.AuthActivity
import org.fnives.test.showcase.model.content.Content
import org.fnives.test.showcase.model.content.FavouriteContent
import org.hamcrest.Matchers.allOf
class HomeRobot {
/**
* Needed because Espresso idling sometimes not in sync with RecyclerView's animation.
* So we simply remove the item animations, the animations should be disabled anyway for test.
*
* Reference: https://github.com/android/android-test/issues/223
*/
fun removeItemAnimations() = apply {
Espresso.onView(withId(R.id.recycler)).perform(RemoveItemAnimations())
}
fun assertToolbarIsShown() = apply {
Espresso.onView(withId(R.id.toolbar))
.check(matches(withEffectiveVisibility(ViewMatchers.Visibility.VISIBLE)))
}
fun setupIntentResults() {
Intents.intending(IntentMatchers.hasComponent(AuthActivity::class.java.canonicalName))
.respondWith(Instrumentation.ActivityResult(Activity.RESULT_OK, Intent()))
}
fun assertNavigatedToAuth() = apply {
Intents.intended(IntentMatchers.hasComponent(AuthActivity::class.java.canonicalName))
}
fun assertDidNotNavigateToAuth() = apply {
notIntended(IntentMatchers.hasComponent(AuthActivity::class.java.canonicalName))
}
fun clickSignOut(setupIntentResults: Boolean = true) = apply {
if (setupIntentResults) {
Intents.intending(IntentMatchers.hasComponent(AuthActivity::class.java.canonicalName))
.respondWith(Instrumentation.ActivityResult(Activity.RESULT_OK, Intent()))
}
Espresso.onView(withId(R.id.logout_cta)).perform(click())
}
fun assertContainsItem(index: Int, item: FavouriteContent) = apply {
removeItemAnimations()
val isFavouriteResourceId = if (item.isFavourite) {
R.drawable.favorite_24
} else {
R.drawable.favorite_border_24
}
Espresso.onView(withId(R.id.recycler))
.perform(RecyclerViewActions.scrollToPosition<RecyclerView.ViewHolder>(index))
Espresso.onView(
allOf(
withChild(allOf(withText(item.content.title), withId(R.id.title))),
withChild(allOf(withText(item.content.description), withId(R.id.description))),
withChild(allOf(withId(R.id.favourite_cta), WithDrawable(isFavouriteResourceId)))
)
)
.check(matches(withEffectiveVisibility(ViewMatchers.Visibility.VISIBLE)))
}
fun clickOnContentItem(index: Int, item: Content) = apply {
removeItemAnimations()
Espresso.onView(withId(R.id.recycler))
.perform(RecyclerViewActions.scrollToPosition<RecyclerView.ViewHolder>(index))
Espresso.onView(
allOf(
withId(R.id.favourite_cta),
withParent(
allOf(
withChild(allOf(withText(item.title), withId(R.id.title))),
withChild(allOf(withText(item.description), withId(R.id.description)))
)
)
)
)
.perform(click())
}
fun swipeRefresh() = apply {
Espresso.onView(withId(R.id.swipe_refresh_layout)).perform(PullToRefresh())
}
fun assertContainsNoItems() = apply {
removeItemAnimations()
Espresso.onView(withId(R.id.recycler))
.check(matches(hasChildCount(0)))
}
fun assertContainsError() = apply {
Espresso.onView(withId(R.id.error_message))
.check(matches(allOf(isDisplayed(), withText(R.string.something_went_wrong))))
}
}

View file

@ -0,0 +1,214 @@
package org.fnives.test.showcase.hilt.test.shared.ui.home
import androidx.test.core.app.ActivityScenario
import androidx.test.espresso.intent.Intents
import org.fnives.test.showcase.android.testutil.activity.SafeCloseActivityRule
import org.fnives.test.showcase.android.testutil.activity.safeClose
import org.fnives.test.showcase.android.testutil.intent.DismissSystemDialogsRule
import org.fnives.test.showcase.android.testutil.screenshot.ScreenshotRule
import org.fnives.test.showcase.android.testutil.synchronization.loopMainThreadFor
import org.fnives.test.showcase.hilt.test.shared.testutils.MockServerScenarioSetupTestRule
import org.fnives.test.showcase.hilt.test.shared.testutils.idling.AsyncDiffUtilInstantTestRule
import org.fnives.test.showcase.hilt.test.shared.testutils.idling.MainDispatcherTestRule
import org.fnives.test.showcase.hilt.test.shared.testutils.statesetup.SetupAuthenticationState.setupLogin
import org.fnives.test.showcase.hilt.test.shared.ui.NetworkSynchronizedActivityTest
import org.fnives.test.showcase.hilt.ui.home.MainActivity
import org.fnives.test.showcase.model.content.FavouriteContent
import org.fnives.test.showcase.network.mockserver.ContentData
import org.fnives.test.showcase.network.mockserver.MockServerScenarioSetup
import org.fnives.test.showcase.network.mockserver.scenario.content.ContentScenario
import org.fnives.test.showcase.network.mockserver.scenario.refresh.RefreshTokenScenario
import org.junit.Rule
import org.junit.Test
import org.junit.rules.RuleChain
@Suppress("TestFunctionName")
open class MainActivityInstrumentedSharedTest : NetworkSynchronizedActivityTest() {
private lateinit var activityScenario: ActivityScenario<MainActivity>
private val mainDispatcherTestRule = MainDispatcherTestRule()
private val mockServerScenarioSetupTestRule = MockServerScenarioSetupTestRule()
private val mockServerScenarioSetup: MockServerScenarioSetup get() = mockServerScenarioSetupTestRule.mockServerScenarioSetup
private lateinit var robot: HomeRobot
@Rule
@JvmField
val ruleOrder: RuleChain = RuleChain.outerRule(DismissSystemDialogsRule())
.around(mockServerScenarioSetupTestRule)
.around(mainDispatcherTestRule)
.around(AsyncDiffUtilInstantTestRule())
.around(SafeCloseActivityRule { activityScenario })
.around(ScreenshotRule("test-showcase"))
override fun setupAfterInjection() {
super.setupAfterInjection()
robot = HomeRobot()
setupLogin(mainDispatcherTestRule, mockServerScenarioSetup)
Intents.init()
}
override fun additionalTearDown() {
super.additionalTearDown()
Intents.release()
}
/** GIVEN initialized MainActivity WHEN signout is clicked THEN user is signed out */
@Test
fun signOutClickedResultsInNavigation() {
mockServerScenarioSetup.setScenario(ContentScenario.Error(usingRefreshedToken = false))
activityScenario = ActivityScenario.launch(MainActivity::class.java)
mainDispatcherTestRule.advanceUntilIdleWithIdlingResources()
robot.clickSignOut()
mainDispatcherTestRule.advanceUntilIdleWithIdlingResources()
robot.assertNavigatedToAuth()
}
/** GIVEN success response WHEN data is returned THEN it is shown on the ui */
@Test
fun successfulDataLoadingShowsTheElementsOnTheUI() {
mockServerScenarioSetup.setScenario(ContentScenario.Success(usingRefreshedToken = false))
activityScenario = ActivityScenario.launch(MainActivity::class.java)
mainDispatcherTestRule.advanceUntilIdleWithIdlingResources()
ContentData.contentSuccess.forEachIndexed { index, content ->
robot.assertContainsItem(index, FavouriteContent(content, false))
}
robot.assertDidNotNavigateToAuth()
}
/** GIVEN success response WHEN item is clicked THEN ui is updated */
@Test
fun clickingOnListElementUpdatesTheElementsFavouriteState() {
mockServerScenarioSetup.setScenario(ContentScenario.Success(usingRefreshedToken = false))
activityScenario = ActivityScenario.launch(MainActivity::class.java)
mainDispatcherTestRule.advanceUntilIdleWithIdlingResources()
robot.clickOnContentItem(0, ContentData.contentSuccess.first())
mainDispatcherTestRule.advanceUntilIdleWithIdlingResources()
val expectedItem = FavouriteContent(ContentData.contentSuccess.first(), true)
robot.assertContainsItem(0, expectedItem)
.assertDidNotNavigateToAuth()
}
/** GIVEN success response WHEN item is clicked THEN ui is updated even if activity is recreated */
@Test
fun elementFavouritedIsKeptEvenIfActivityIsRecreated() {
mockServerScenarioSetup.setScenario(ContentScenario.Success(usingRefreshedToken = false))
activityScenario = ActivityScenario.launch(MainActivity::class.java)
mainDispatcherTestRule.advanceUntilIdleWithIdlingResources()
robot.clickOnContentItem(0, ContentData.contentSuccess.first())
mainDispatcherTestRule.advanceUntilIdleWithIdlingResources()
val expectedItem = FavouriteContent(ContentData.contentSuccess.first(), true)
activityScenario.safeClose()
activityScenario = ActivityScenario.launch(MainActivity::class.java)
mainDispatcherTestRule.advanceUntilIdleWithIdlingResources()
robot.assertContainsItem(0, expectedItem)
.assertDidNotNavigateToAuth()
}
/** GIVEN success response WHEN item is clicked then clicked again THEN ui is updated */
@Test
fun clickingAnElementMultipleTimesProperlyUpdatesIt() {
mockServerScenarioSetup.setScenario(ContentScenario.Success(usingRefreshedToken = false))
activityScenario = ActivityScenario.launch(MainActivity::class.java)
mainDispatcherTestRule.advanceUntilIdleWithIdlingResources()
robot.clickOnContentItem(0, ContentData.contentSuccess.first())
mainDispatcherTestRule.advanceUntilIdleWithIdlingResources()
robot.clickOnContentItem(0, ContentData.contentSuccess.first())
mainDispatcherTestRule.advanceUntilIdleWithIdlingResources()
val expectedItem = FavouriteContent(ContentData.contentSuccess.first(), false)
robot.assertContainsItem(0, expectedItem)
.assertDidNotNavigateToAuth()
}
/** GIVEN error response WHEN loaded THEN error is Shown */
@Test
fun networkErrorResultsInUIErrorStateShown() {
mockServerScenarioSetup.setScenario(ContentScenario.Error(usingRefreshedToken = false))
activityScenario = ActivityScenario.launch(MainActivity::class.java)
mainDispatcherTestRule.advanceUntilIdleWithIdlingResources()
robot.assertContainsNoItems()
.assertContainsError()
.assertDidNotNavigateToAuth()
}
/** GIVEN error response then success WHEN retried THEN success is shown */
@Test
fun retryingFromErrorStateAndSucceedingShowsTheData() {
mockServerScenarioSetup.setScenario(
ContentScenario.Error(usingRefreshedToken = false)
.then(ContentScenario.Success(usingRefreshedToken = false))
)
activityScenario = ActivityScenario.launch(MainActivity::class.java)
mainDispatcherTestRule.advanceUntilIdleWithIdlingResources()
robot.swipeRefresh()
mainDispatcherTestRule.advanceUntilIdleWithIdlingResources()
loopMainThreadFor(2000L)
ContentData.contentSuccess.forEachIndexed { index, content ->
robot.assertContainsItem(index, FavouriteContent(content, false))
}
robot.assertDidNotNavigateToAuth()
}
/** GIVEN success then error WHEN retried THEN error is shown */
@Test
fun errorIsShownIfTheDataIsFetchedAndErrorIsReceived() {
mockServerScenarioSetup.setScenario(
ContentScenario.Success(usingRefreshedToken = false)
.then(ContentScenario.Error(usingRefreshedToken = false))
)
activityScenario = ActivityScenario.launch(MainActivity::class.java)
mainDispatcherTestRule.advanceUntilIdleWithIdlingResources()
robot.swipeRefresh()
mainDispatcherTestRule.advanceUntilIdleWithIdlingResources()
robot
.assertContainsError()
.assertContainsNoItems()
.assertDidNotNavigateToAuth()
}
/** GIVEN unauthenticated then success WHEN loaded THEN success is shown */
@Test
fun authenticationIsHandledWithASingleLoading() {
mockServerScenarioSetup.setScenario(
ContentScenario.Unauthorized(usingRefreshedToken = false)
.then(ContentScenario.Success(usingRefreshedToken = true))
)
.setScenario(RefreshTokenScenario.Success)
activityScenario = ActivityScenario.launch(MainActivity::class.java)
mainDispatcherTestRule.advanceUntilIdleWithIdlingResources()
ContentData.contentSuccess.forEachIndexed { index, content ->
robot.assertContainsItem(index, FavouriteContent(content, false))
}
robot.assertDidNotNavigateToAuth()
}
/** GIVEN unauthenticated then error WHEN loaded THEN navigated to auth */
@Test
fun sessionExpirationResultsInNavigation() {
mockServerScenarioSetup.setScenario(ContentScenario.Unauthorized(usingRefreshedToken = false))
.setScenario(RefreshTokenScenario.Error)
activityScenario = ActivityScenario.launch(MainActivity::class.java)
mainDispatcherTestRule.advanceUntilIdleWithIdlingResources()
robot.assertNavigatedToAuth()
}
}

View file

@ -0,0 +1,101 @@
package org.fnives.test.showcase.hilt.test.shared.ui.splash
import androidx.lifecycle.Lifecycle
import androidx.test.core.app.ActivityScenario
import androidx.test.espresso.intent.Intents
import org.fnives.test.showcase.android.testutil.activity.SafeCloseActivityRule
import org.fnives.test.showcase.android.testutil.intent.DismissSystemDialogsRule
import org.fnives.test.showcase.android.testutil.screenshot.ScreenshotRule
import org.fnives.test.showcase.hilt.test.shared.testutils.MockServerScenarioSetupTestRule
import org.fnives.test.showcase.hilt.test.shared.testutils.idling.MainDispatcherTestRule
import org.fnives.test.showcase.hilt.test.shared.testutils.statesetup.SetupAuthenticationState.setupLogin
import org.fnives.test.showcase.hilt.test.shared.testutils.statesetup.SetupAuthenticationState.setupLogout
import org.fnives.test.showcase.hilt.test.shared.ui.NetworkSynchronizedActivityTest
import org.fnives.test.showcase.hilt.ui.splash.SplashActivity
import org.fnives.test.showcase.network.mockserver.MockServerScenarioSetup
import org.junit.Rule
import org.junit.Test
import org.junit.rules.RuleChain
@Suppress("TestFunctionName")
open class SplashActivityInstrumentedSharedTest : NetworkSynchronizedActivityTest() {
private lateinit var activityScenario: ActivityScenario<SplashActivity>
private val mainDispatcherTestRule = MainDispatcherTestRule()
private val mockServerScenarioSetupTestRule = MockServerScenarioSetupTestRule()
private val mockServerScenarioSetup: MockServerScenarioSetup get() = mockServerScenarioSetupTestRule.mockServerScenarioSetup
private lateinit var robot: SplashRobot
@Rule
@JvmField
val ruleOrder: RuleChain = RuleChain.outerRule(DismissSystemDialogsRule())
.around(mainDispatcherTestRule)
.around(mockServerScenarioSetupTestRule)
.around(SafeCloseActivityRule { activityScenario })
.around(ScreenshotRule("test-showcase"))
override fun setupAfterInjection() {
Intents.init()
robot = SplashRobot()
}
override fun additionalTearDown() {
Intents.release()
}
/** GIVEN loggedInState WHEN opened after some time THEN MainActivity is started */
@Test
fun loggedInStateNavigatesToHome() {
setupLogin(mainDispatcherTestRule, mockServerScenarioSetup)
activityScenario = ActivityScenario.launch(SplashActivity::class.java)
activityScenario.moveToState(Lifecycle.State.RESUMED)
mainDispatcherTestRule.advanceTimeBy(501)
robot.assertHomeIsStarted()
.assertAuthIsNotStarted()
}
/** GIVEN loggedOffState WHEN opened after some time THEN AuthActivity is started */
@Test
fun loggedOutStatesNavigatesToAuthentication() {
setupLogout(mainDispatcherTestRule)
activityScenario = ActivityScenario.launch(SplashActivity::class.java)
activityScenario.moveToState(Lifecycle.State.RESUMED)
mainDispatcherTestRule.advanceTimeBy(501)
robot.assertAuthIsStarted()
.assertHomeIsNotStarted()
}
/** GIVEN loggedOffState and not enough time WHEN opened THEN no activity is started */
@Test
fun loggedOutStatesNotEnoughTime() {
setupLogout(mainDispatcherTestRule)
activityScenario = ActivityScenario.launch(SplashActivity::class.java)
activityScenario.moveToState(Lifecycle.State.RESUMED)
mainDispatcherTestRule.advanceTimeBy(500)
robot.assertAuthIsNotStarted()
.assertHomeIsNotStarted()
}
/** GIVEN loggedInState and not enough time WHEN opened THEN no activity is started */
@Test
fun loggedInStatesNotEnoughTime() {
setupLogin(mainDispatcherTestRule, mockServerScenarioSetup)
activityScenario = ActivityScenario.launch(SplashActivity::class.java)
activityScenario.moveToState(Lifecycle.State.RESUMED)
mainDispatcherTestRule.advanceTimeBy(500)
robot.assertHomeIsNotStarted()
.assertAuthIsNotStarted()
}
}

View file

@ -0,0 +1,36 @@
package org.fnives.test.showcase.hilt.test.shared.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.android.testutil.intent.notIntended
import org.fnives.test.showcase.hilt.ui.auth.AuthActivity
import org.fnives.test.showcase.hilt.ui.home.MainActivity
class SplashRobot {
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 {
Intents.intended(IntentMatchers.hasComponent(MainActivity::class.java.canonicalName))
}
fun assertHomeIsNotStarted() = apply {
notIntended(IntentMatchers.hasComponent(MainActivity::class.java.canonicalName))
}
fun assertAuthIsStarted() = apply {
Intents.intended(IntentMatchers.hasComponent(AuthActivity::class.java.canonicalName))
}
fun assertAuthIsNotStarted() = apply {
notIntended(IntentMatchers.hasComponent(AuthActivity::class.java.canonicalName))
}
}