Skip to content
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
Member

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
Member

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