Skip to content

Example UI test implementation #13

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 8 commits into
base: develop
Choose a base branch
from
16 changes: 16 additions & 0 deletions project/app-common/build.gradle
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.kapt)
alias(libs.plugins.hilt)
}

android {
Expand Down Expand Up @@ -29,7 +31,21 @@ android {
}
}

hilt {
// This flag reduces incremental compilation times by reducing how often an incremental change causes a rebuild of the Dagger components.
// See https://dagger.dev/hilt/gradle-setup.html#aggregating-task
enableAggregatingTask = true
}

kapt {
// If Hilt is used in a Kotlin project, then Kapt should be configured to keep the correct error types.
// See https://dagger.dev/hilt/gradle-setup.html#using-hilt-with-kotlin
correctErrorTypes = true
}

dependencies {
libsHelper.addDependencyInjectionDependencies(it)

implementation libs.kotlin
coreLibraryDesugaring libs.jdk.desugar

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package mobi.lab.sample.app.common.di

import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import mobi.lab.sample.app.common.test.Idler
import mobi.lab.sample.app.common.test.NoOpIdler
import javax.inject.Singleton

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe just-in-case a comment here that this is the provider for UI test idler and this one provides the default no-op one and safe for production?

@Module
@InstallIn(SingletonComponent::class)
object IdlerModule {

@Provides
@Singleton
internal fun provideIdler(): Idler = NoOpIdler()
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package mobi.lab.sample.app.common.test

interface Idler {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe one-lin comment on how to use the Idler? Eg:
"Create and set busy before the works starts, mark as done when the job is done."

/**
* Create a new [IdlerToken]. This does not mark the token as busy
*
* @return new [IdlerToken]
*/
fun token(): IdlerToken

/**
* Create a new [IdlerToken] and mark it as busy.
*
* @return new [IdlerToken]
*/
fun busy(): IdlerToken

/**
* Create a new [IdlerToken] from the given key and mark it as busy.
*
* @param key Any object to use as a value for the token.
* @return new [IdlerToken] using the key
*/
fun busy(key: Any): IdlerToken

/**
* Mark the [IdlerToken] as busy.
*
* @param token [IdlerToken]
*/
fun busy(token: IdlerToken)

/**
* Mark work identified by key as done.
*
* @param key Any object that identifies the work
*/
fun done(key: Any)

/**
* Mark work identified by token as done.
*
* @param token [IdlerToken]
*/
fun done(token: IdlerToken)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
package mobi.lab.sample.app.common.test

data class IdlerToken(val key: Any)
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package mobi.lab.sample.app.common.test

class NoOpIdler : Idler {

override fun token(): IdlerToken {
return IdlerToken(Any())
}

override fun busy(): IdlerToken {
return IdlerToken(Any())
}

override fun busy(key: Any): IdlerToken {
return IdlerToken(key)
}

override fun busy(token: IdlerToken) {
// Do nothing
}

override fun done(key: Any) {
// Do nothing
}

override fun done(token: IdlerToken) {
// Do nothing
}
}
2 changes: 2 additions & 0 deletions project/app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ android {
targetSdkVersion(libs.versions.android.sdk.target.get())
minSdkVersion(libs.versions.android.sdk.min.get())
applicationId = "mobi.lab.sample"
testInstrumentationRunner = "mobi.lab.sample.util.CustomTestRunner"

versionCode = project.ext.versionCode
versionName = project.ext.versionName
Expand Down Expand Up @@ -158,6 +159,7 @@ tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).configureEach {
dependencies {
libsHelper.addDependencyInjectionDependencies(it)
libsHelper.addUnitTestDependencies(it)
libsHelper.addInstrumentationTestDependencies(it)

implementation libs.kotlin
implementation libs.androidx.legacy
Expand Down
7 changes: 7 additions & 0 deletions project/app/src/androidTest/java/mobi/lab/sample/TestApp.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package mobi.lab.sample

import dagger.hilt.android.testing.CustomTestApplication

// Hilt will generate an application class that extends TestAppBase
@CustomTestApplication(TestAppBase::class)
interface TestApp
16 changes: 16 additions & 0 deletions project/app/src/androidTest/java/mobi/lab/sample/TestAppBase.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package mobi.lab.sample

import android.app.Application
import androidx.test.espresso.IdlingRegistry
import mobi.lab.sample.util.RealIdler
import timber.log.Timber

open class TestAppBase : Application() {

override fun onCreate() {
super.onCreate()
// Any other relevant setup we need to make
Timber.plant(Timber.DebugTree())
IdlingRegistry.getInstance().register(RealIdler.idlingResource)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
package mobi.lab.sample.demo.login

import androidx.test.espresso.Espresso
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.action.ViewActions.click
import androidx.test.espresso.action.ViewActions.typeText
import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.intent.Intents
import androidx.test.espresso.intent.matcher.IntentMatchers
import androidx.test.espresso.matcher.RootMatchers.isDialog
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
import androidx.test.espresso.matcher.ViewMatchers.withId
import androidx.test.espresso.matcher.ViewMatchers.withText
import androidx.test.ext.junit.rules.activityScenarioRule
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import mobi.lab.sample.R
import mobi.lab.sample.common.rx.SchedulerProvider
import mobi.lab.sample.demo.main.MainActivity
import mobi.lab.sample.util.hasNoTextInputLayoutError
import mobi.lab.sample.util.hasTextInputLayoutError
import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import javax.inject.Inject

@HiltAndroidTest
class LoginActivityTest {

@get:Rule
var hiltRule = HiltAndroidRule(this)

/**
* Use [androidx.test.ext.junit.rules.ActivityScenarioRule] to create and launch the activity under test before each test,
* and close it after each test. This is a replacement for
* [androidx.test.rule.ActivityTestRule].
*/
@get:Rule
val activityScenarioRule = activityScenarioRule<LoginActivity>()

@Inject
lateinit var schedulers: SchedulerProvider

private lateinit var activity: LoginActivity

@Before
fun setup() {
hiltRule.inject()
Intents.init()
activityScenarioRule.scenario.onActivity {
activity = it
}
}

@After
fun tearDown() {
Intents.release()
}

@Test
fun show_input_error_when_fields_are_empty_rxidler() {
onView(withId(R.id.button_login)).perform(click())

onView(withId(R.id.input_layout_email)).check(matches(hasTextInputLayoutError(TEXT_ID_REQUIRED)))
onView(withId(R.id.input_layout_password)).check(matches(hasTextInputLayoutError(TEXT_ID_REQUIRED)))

Intents.assertNoUnverifiedIntents()
}

@Test
fun show_input_error_when_only_username_is_filled_rxidler() {
onView(withId(R.id.edit_text_email)).perform(typeText("asd"))
onView(withId(R.id.button_login)).perform(click())

onView(withId(R.id.input_layout_email)).check(matches(hasNoTextInputLayoutError()))
onView(withId(R.id.input_layout_password)).check(matches(hasTextInputLayoutError(TEXT_ID_REQUIRED)))

Intents.assertNoUnverifiedIntents()
}

@Test
fun show_input_error_when_only_password_is_filled_rxidler() {
onView(withId(R.id.edit_text_password)).perform(typeText("asd"))
onView(withId(R.id.button_login)).perform(click())

onView(withId(R.id.input_layout_email)).check(matches(hasTextInputLayoutError(TEXT_ID_REQUIRED)))
onView(withId(R.id.input_layout_password)).check(matches(hasNoTextInputLayoutError()))

Intents.assertNoUnverifiedIntents()
}

@Test
fun login_success_when_fields_are_filled_rxidler() {
onView(withId(R.id.edit_text_email)).perform(typeText("asd"))
onView(withId(R.id.edit_text_password)).perform(typeText("asd"))
onView(withId(R.id.button_login)).perform(click())

Intents.intended(IntentMatchers.hasComponent(MainActivity::class.java.name))
}

@Test
fun show_error_dialog_when_login_fails_rxidler() {
// "test" is a keyword to trigger an error response. See LoginUseCase implementation
onView(withId(R.id.edit_text_email)).perform(typeText("test"))
onView(withId(R.id.edit_text_password)).perform(typeText("asd"))
onView(withId(R.id.button_login)).perform(click())

// Validate the dialog and close it
onView(withText(R.string.error_generic))
.inRoot(isDialog())
.check(matches(isDisplayed()))
Espresso.pressBack()

onView(withId(R.id.input_layout_email)).check(matches(hasNoTextInputLayoutError()))
onView(withId(R.id.input_layout_password)).check(matches(hasNoTextInputLayoutError()))

Intents.assertNoUnverifiedIntents()
}

companion object {
private val TEXT_ID_REQUIRED = R.string.demo_text_required
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package mobi.lab.sample.di

import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.schedulers.Schedulers
import mobi.lab.sample.common.rx.SchedulerProvider
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import javax.inject.Inject
import kotlin.test.assertSame

/**
* A sample test showing how to inject dependencies via Dagger.
* Verifies that our TestAppComponent gets its schedulers from TestSchedulerModule
*/
@HiltAndroidTest
class SchedulerModuleTest {

@get:Rule
var hiltRule = HiltAndroidRule(this)

@Inject
lateinit var schedulers: SchedulerProvider

@Before
fun setup() {
hiltRule.inject()
}

@Test
fun verify_test_schedulers() {
assertSame(schedulers.main, AndroidSchedulers.mainThread())
assertSame(schedulers.io, Schedulers.io())
assertSame(schedulers.computation, Schedulers.computation())
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package mobi.lab.sample.di

import dagger.Module
import dagger.Provides
import dagger.hilt.components.SingletonComponent
import dagger.hilt.testing.TestInstallIn
import mobi.lab.sample.app.common.di.IdlerModule
import mobi.lab.sample.app.common.test.Idler
import mobi.lab.sample.util.RealIdler
import javax.inject.Singleton

@Module
@TestInstallIn(
components = [SingletonComponent::class],
replaces = [IdlerModule::class]
)
object TestIdlerModule {

@Singleton
@Provides
fun provideIdler(): Idler {
return RealIdler
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package mobi.lab.sample.di

import dagger.Module
import dagger.Provides
import dagger.hilt.components.SingletonComponent
import dagger.hilt.testing.TestInstallIn
import mobi.lab.sample.app.common.test.Idler
import mobi.lab.sample.common.rx.SchedulerProvider
import mobi.lab.sample.util.TestSchedulerProvider
import javax.inject.Singleton

@Module
@TestInstallIn(
components = [SingletonComponent::class],
replaces = [SchedulerModule::class]
)
object TestSchedulerModule {

@Singleton
@Provides
fun provideSchedulerProvider(idler: Idler): SchedulerProvider {
return TestSchedulerProvider(idler)
}
}
Loading