From 211e1257a4fd4a784284090ce161d616d97a5424 Mon Sep 17 00:00:00 2001 From: lauris Date: Wed, 10 Apr 2024 09:30:38 +0300 Subject: [PATCH 1/5] Add hilt UI tests with a custom Idler implementation to wrap an IdlingResource --- project/app-common/build.gradle | 16 +++ .../lab/sample/app/common/di/IdlerModule.kt | 18 +++ .../mobi/lab/sample/app/common/test/Idler.kt | 6 + .../lab/sample/app/common/test/NoOpIdler.kt | 11 ++ project/app/build.gradle | 2 + .../java/mobi/lab/sample/TestApp.kt | 7 + .../java/mobi/lab/sample/TestAppBase.kt | 16 +++ .../sample/demo/login/LoginActivityTest.kt | 122 ++++++++++++++++++ .../mobi/lab/sample/di/SchedulerModuleTest.kt | 38 ++++++ .../mobi/lab/sample/di/TestIdlerModule.kt | 24 ++++ .../mobi/lab/sample/di/TestSchedulerModule.kt | 24 ++++ .../mobi/lab/sample/util/CustomMatchers.kt | 49 +++++++ .../mobi/lab/sample/util/CustomTestRunner.kt | 18 +++ .../java/mobi/lab/sample/util/RealIdler.kt | 16 +++ .../lab/sample/util/TestSchedulerProvider.kt | 58 +++++++++ .../common/rx/AndroidSchedulerProvider.kt | 2 +- .../lab/sample/common/rx/SchedulerProvider.kt | 37 +++++- .../java/mobi/lab/sample/di/AppComponent.kt | 31 ----- project/dependencies.gradle | 20 ++- project/gradle/libs.versions.toml | 9 ++ .../gradle/wrapper/gradle-wrapper.properties | 2 +- 21 files changed, 488 insertions(+), 38 deletions(-) create mode 100644 project/app-common/src/main/java/mobi/lab/sample/app/common/di/IdlerModule.kt create mode 100644 project/app-common/src/main/java/mobi/lab/sample/app/common/test/Idler.kt create mode 100644 project/app-common/src/main/java/mobi/lab/sample/app/common/test/NoOpIdler.kt create mode 100644 project/app/src/androidTest/java/mobi/lab/sample/TestApp.kt create mode 100644 project/app/src/androidTest/java/mobi/lab/sample/TestAppBase.kt create mode 100644 project/app/src/androidTest/java/mobi/lab/sample/demo/login/LoginActivityTest.kt create mode 100644 project/app/src/androidTest/java/mobi/lab/sample/di/SchedulerModuleTest.kt create mode 100644 project/app/src/androidTest/java/mobi/lab/sample/di/TestIdlerModule.kt create mode 100644 project/app/src/androidTest/java/mobi/lab/sample/di/TestSchedulerModule.kt create mode 100644 project/app/src/androidTest/java/mobi/lab/sample/util/CustomMatchers.kt create mode 100644 project/app/src/androidTest/java/mobi/lab/sample/util/CustomTestRunner.kt create mode 100644 project/app/src/androidTest/java/mobi/lab/sample/util/RealIdler.kt create mode 100644 project/app/src/androidTest/java/mobi/lab/sample/util/TestSchedulerProvider.kt delete mode 100644 project/app/src/main/java/mobi/lab/sample/di/AppComponent.kt diff --git a/project/app-common/build.gradle b/project/app-common/build.gradle index 322468a..7790ee0 100644 --- a/project/app-common/build.gradle +++ b/project/app-common/build.gradle @@ -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 { @@ -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 diff --git a/project/app-common/src/main/java/mobi/lab/sample/app/common/di/IdlerModule.kt b/project/app-common/src/main/java/mobi/lab/sample/app/common/di/IdlerModule.kt new file mode 100644 index 0000000..341bb63 --- /dev/null +++ b/project/app-common/src/main/java/mobi/lab/sample/app/common/di/IdlerModule.kt @@ -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 + +@Module +@InstallIn(SingletonComponent::class) +object IdlerModule { + + @Provides + @Singleton + internal fun provideIdler(): Idler = NoOpIdler() +} diff --git a/project/app-common/src/main/java/mobi/lab/sample/app/common/test/Idler.kt b/project/app-common/src/main/java/mobi/lab/sample/app/common/test/Idler.kt new file mode 100644 index 0000000..01f6f92 --- /dev/null +++ b/project/app-common/src/main/java/mobi/lab/sample/app/common/test/Idler.kt @@ -0,0 +1,6 @@ +package mobi.lab.sample.app.common.test + +interface Idler { + fun increment() + fun decrement() +} diff --git a/project/app-common/src/main/java/mobi/lab/sample/app/common/test/NoOpIdler.kt b/project/app-common/src/main/java/mobi/lab/sample/app/common/test/NoOpIdler.kt new file mode 100644 index 0000000..7af9cb4 --- /dev/null +++ b/project/app-common/src/main/java/mobi/lab/sample/app/common/test/NoOpIdler.kt @@ -0,0 +1,11 @@ +package mobi.lab.sample.app.common.test + +class NoOpIdler : Idler { + override fun increment() { + // No-op + } + + override fun decrement() { + // No-op + } +} diff --git a/project/app/build.gradle b/project/app/build.gradle index b15eddd..46cc3c8 100644 --- a/project/app/build.gradle +++ b/project/app/build.gradle @@ -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 @@ -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 diff --git a/project/app/src/androidTest/java/mobi/lab/sample/TestApp.kt b/project/app/src/androidTest/java/mobi/lab/sample/TestApp.kt new file mode 100644 index 0000000..85bcec2 --- /dev/null +++ b/project/app/src/androidTest/java/mobi/lab/sample/TestApp.kt @@ -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 diff --git a/project/app/src/androidTest/java/mobi/lab/sample/TestAppBase.kt b/project/app/src/androidTest/java/mobi/lab/sample/TestAppBase.kt new file mode 100644 index 0000000..0c3a5b9 --- /dev/null +++ b/project/app/src/androidTest/java/mobi/lab/sample/TestAppBase.kt @@ -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) + } +} diff --git a/project/app/src/androidTest/java/mobi/lab/sample/demo/login/LoginActivityTest.kt b/project/app/src/androidTest/java/mobi/lab/sample/demo/login/LoginActivityTest.kt new file mode 100644 index 0000000..114ef42 --- /dev/null +++ b/project/app/src/androidTest/java/mobi/lab/sample/demo/login/LoginActivityTest.kt @@ -0,0 +1,122 @@ +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.closeSoftKeyboard +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.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() + + @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"), closeSoftKeyboard()) + 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"), closeSoftKeyboard()) + 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"), closeSoftKeyboard()) + onView(withId(R.id.edit_text_password)).perform(typeText("asd"), closeSoftKeyboard()) + 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"), closeSoftKeyboard()) + onView(withId(R.id.edit_text_password)).perform(typeText("asd"), closeSoftKeyboard()) + onView(withId(R.id.button_login)).perform(click()) + + // Validate the dialog and close it + onView(withText(R.string.demo_error_generic)).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 + } +} diff --git a/project/app/src/androidTest/java/mobi/lab/sample/di/SchedulerModuleTest.kt b/project/app/src/androidTest/java/mobi/lab/sample/di/SchedulerModuleTest.kt new file mode 100644 index 0000000..492f022 --- /dev/null +++ b/project/app/src/androidTest/java/mobi/lab/sample/di/SchedulerModuleTest.kt @@ -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()) + } +} diff --git a/project/app/src/androidTest/java/mobi/lab/sample/di/TestIdlerModule.kt b/project/app/src/androidTest/java/mobi/lab/sample/di/TestIdlerModule.kt new file mode 100644 index 0000000..6b5b21d --- /dev/null +++ b/project/app/src/androidTest/java/mobi/lab/sample/di/TestIdlerModule.kt @@ -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 + } +} diff --git a/project/app/src/androidTest/java/mobi/lab/sample/di/TestSchedulerModule.kt b/project/app/src/androidTest/java/mobi/lab/sample/di/TestSchedulerModule.kt new file mode 100644 index 0000000..62445a2 --- /dev/null +++ b/project/app/src/androidTest/java/mobi/lab/sample/di/TestSchedulerModule.kt @@ -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) + } +} diff --git a/project/app/src/androidTest/java/mobi/lab/sample/util/CustomMatchers.kt b/project/app/src/androidTest/java/mobi/lab/sample/util/CustomMatchers.kt new file mode 100644 index 0000000..cdb3565 --- /dev/null +++ b/project/app/src/androidTest/java/mobi/lab/sample/util/CustomMatchers.kt @@ -0,0 +1,49 @@ +package mobi.lab.sample.util + +import android.view.View +import androidx.annotation.StringRes +import androidx.test.platform.app.InstrumentationRegistry +import com.google.android.material.textfield.TextInputLayout +import mobi.lab.sample.app.common.isStringEmpty +import mobi.lab.sample.app.common.stringEquals +import org.hamcrest.Description +import org.hamcrest.Matcher +import org.hamcrest.TypeSafeMatcher + +fun hasNoTextInputLayoutError(): Matcher { + return object : TypeSafeMatcher() { + + override fun describeTo(description: Description?) { + description?.appendText("Expected no error text") + } + + override fun matchesSafely(view: View?): Boolean { + if (view !is TextInputLayout) { + return false + } + + return isStringEmpty(view.error) + } + } +} + +fun hasTextInputLayoutError(expectedErrorText: String): Matcher { + return object : TypeSafeMatcher() { + + override fun describeTo(description: Description?) { + description?.appendText("Expected error $expectedErrorText not found") + } + + override fun matchesSafely(view: View?): Boolean { + if (view !is TextInputLayout) { + return false + } + + return stringEquals(expectedErrorText, view.error) + } + } +} + +fun hasTextInputLayoutError(@StringRes id: Int): Matcher { + return hasTextInputLayoutError(InstrumentationRegistry.getInstrumentation().targetContext.getString(id)) +} diff --git a/project/app/src/androidTest/java/mobi/lab/sample/util/CustomTestRunner.kt b/project/app/src/androidTest/java/mobi/lab/sample/util/CustomTestRunner.kt new file mode 100644 index 0000000..502c40c --- /dev/null +++ b/project/app/src/androidTest/java/mobi/lab/sample/util/CustomTestRunner.kt @@ -0,0 +1,18 @@ +package mobi.lab.sample.util + +import android.app.Application +import android.content.Context +import androidx.test.runner.AndroidJUnitRunner +import mobi.lab.sample.TestApp_Application + +/** + * A runner to provide a different Application class from the regular application code. + * Referenced from app/build.gradle. + */ +@Suppress("unused") +class CustomTestRunner : AndroidJUnitRunner() { + + override fun newApplication(cl: ClassLoader?, className: String?, context: Context?): Application { + return super.newApplication(cl, TestApp_Application::class.java.name, context) + } +} diff --git a/project/app/src/androidTest/java/mobi/lab/sample/util/RealIdler.kt b/project/app/src/androidTest/java/mobi/lab/sample/util/RealIdler.kt new file mode 100644 index 0000000..b6d374d --- /dev/null +++ b/project/app/src/androidTest/java/mobi/lab/sample/util/RealIdler.kt @@ -0,0 +1,16 @@ +package mobi.lab.sample.util + +import androidx.test.espresso.idling.CountingIdlingResource +import mobi.lab.sample.app.common.test.Idler + +object RealIdler : Idler { + val idlingResource: CountingIdlingResource = CountingIdlingResource("RealIdler", true) + + override fun increment() { + idlingResource.increment() + } + + override fun decrement() { + idlingResource.decrement() + } +} diff --git a/project/app/src/androidTest/java/mobi/lab/sample/util/TestSchedulerProvider.kt b/project/app/src/androidTest/java/mobi/lab/sample/util/TestSchedulerProvider.kt new file mode 100644 index 0000000..bb2fcac --- /dev/null +++ b/project/app/src/androidTest/java/mobi/lab/sample/util/TestSchedulerProvider.kt @@ -0,0 +1,58 @@ +package mobi.lab.sample.util + +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers +import io.reactivex.rxjava3.core.Completable +import io.reactivex.rxjava3.core.Maybe +import io.reactivex.rxjava3.core.Observable +import io.reactivex.rxjava3.core.Single +import io.reactivex.rxjava3.schedulers.Schedulers +import mobi.lab.sample.app.common.test.Idler +import mobi.lab.sample.common.rx.SchedulerProvider + +class TestSchedulerProvider(private val idler: Idler) : SchedulerProvider() { + override val main = AndroidSchedulers.mainThread() + override val computation = Schedulers.computation() + override val io = Schedulers.io() + + override fun Observable.transformObservableInternal(): Observable { + val key = key() + return this + .doOnSubscribe { busy(key) } + .doFinally { done(key) } + } + + override fun Single.transformSingleInternal(): Single { + val key = key() + return this + .doOnSubscribe { busy(key) } + .doFinally { done(key) } + } + + override fun Completable.transformCompletableInternal(): Completable { + val key = key() + return this + .doOnSubscribe { busy(key) } + .doFinally { done(key) } + } + + override fun Maybe.transformMaybeInternal(): Maybe { + val key = key() + return this + .doOnSubscribe { busy(key) } + .doFinally { done(key) } + } + + private fun key(): Any { + return System.currentTimeMillis() + } + + private fun busy(key: Any) { + idler.increment() +// BusyBee.singleton().busyWith(key) + } + + private fun done(key: Any) { + idler.decrement() +// BusyBee.singleton().completed(key) + } +} diff --git a/project/app/src/main/java/mobi/lab/sample/common/rx/AndroidSchedulerProvider.kt b/project/app/src/main/java/mobi/lab/sample/common/rx/AndroidSchedulerProvider.kt index d407318..1ef98c4 100644 --- a/project/app/src/main/java/mobi/lab/sample/common/rx/AndroidSchedulerProvider.kt +++ b/project/app/src/main/java/mobi/lab/sample/common/rx/AndroidSchedulerProvider.kt @@ -4,7 +4,7 @@ import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers import io.reactivex.rxjava3.core.Scheduler import io.reactivex.rxjava3.schedulers.Schedulers -class AndroidSchedulerProvider : SchedulerProvider { +class AndroidSchedulerProvider : SchedulerProvider() { override val main: Scheduler = AndroidSchedulers.mainThread() override val computation: Scheduler = Schedulers.computation() override val io: Scheduler = Schedulers.io() diff --git a/project/app/src/main/java/mobi/lab/sample/common/rx/SchedulerProvider.kt b/project/app/src/main/java/mobi/lab/sample/common/rx/SchedulerProvider.kt index c8952d3..b34997a 100644 --- a/project/app/src/main/java/mobi/lab/sample/common/rx/SchedulerProvider.kt +++ b/project/app/src/main/java/mobi/lab/sample/common/rx/SchedulerProvider.kt @@ -1,24 +1,49 @@ package mobi.lab.sample.common.rx +import io.reactivex.rxjava3.core.Completable import io.reactivex.rxjava3.core.CompletableTransformer +import io.reactivex.rxjava3.core.Maybe import io.reactivex.rxjava3.core.MaybeTransformer +import io.reactivex.rxjava3.core.Observable import io.reactivex.rxjava3.core.ObservableTransformer import io.reactivex.rxjava3.core.Scheduler +import io.reactivex.rxjava3.core.Single import io.reactivex.rxjava3.core.SingleTransformer import mobi.lab.sample.common.platform.LogoutMonitor import mobi.lab.sample.domain.entities.DomainException import mobi.lab.sample.domain.entities.ErrorCode -interface SchedulerProvider { - val main: Scheduler - val computation: Scheduler - val io: Scheduler +abstract class SchedulerProvider { + abstract val main: Scheduler + abstract val computation: Scheduler + abstract val io: Scheduler + + protected open fun Observable.transformObservableInternal(): Observable { + // No-op. Override to add custom behaviour. Useful for testing environments. + return this + } + + protected open fun Single.transformSingleInternal(): Single { + // No-op. Override to add custom behaviour. Useful for testing environments. + return this + } + + protected open fun Completable.transformCompletableInternal(): Completable { + // No-op. Override to add custom behaviour. Useful for testing environments. + return this + } + + protected open fun Maybe.transformMaybeInternal(): Maybe { + // No-op. Override to add custom behaviour. Useful for testing environments. + return this + } fun observable(subscribeOn: Scheduler = io, observeOn: Scheduler = main): ObservableTransformer { return ObservableTransformer { it.subscribeOn(subscribeOn) .observeOn(observeOn) .doOnError(::checkUnauthorizedError) + .transformObservableInternal() } } @@ -27,6 +52,7 @@ interface SchedulerProvider { it.subscribeOn(subscribeOn) .observeOn(observeOn) .doOnError(::checkUnauthorizedError) + .transformSingleInternal() } } @@ -35,6 +61,7 @@ interface SchedulerProvider { it.subscribeOn(subscribeOn) .observeOn(observeOn) .doOnError(::checkUnauthorizedError) + .transformCompletableInternal() } } @@ -42,6 +69,7 @@ interface SchedulerProvider { return CompletableTransformer { it.subscribeOn(subscribeOn) .observeOn(observeOn) + .transformCompletableInternal() } } @@ -50,6 +78,7 @@ interface SchedulerProvider { it.subscribeOn(subscribeOn) .observeOn(observeOn) .doOnError(::checkUnauthorizedError) + .transformMaybeInternal() } } diff --git a/project/app/src/main/java/mobi/lab/sample/di/AppComponent.kt b/project/app/src/main/java/mobi/lab/sample/di/AppComponent.kt deleted file mode 100644 index 19713ae..0000000 --- a/project/app/src/main/java/mobi/lab/sample/di/AppComponent.kt +++ /dev/null @@ -1,31 +0,0 @@ -package mobi.lab.sample.di - -import dagger.Component -import mobi.lab.sample.infrastructure.di.GatewayModule -import mobi.lab.sample.infrastructure.di.MapperModule -import mobi.lab.sample.infrastructure.di.PlatformModule -import mobi.lab.sample.infrastructure.di.ResourceModule -import mobi.lab.sample.infrastructure.di.StorageModule -import javax.inject.Singleton - -/** - * Default Hilt component. - */ -@Singleton -@Component( - modules = [ - ResourceModule::class, - GatewayModule::class, - MapperModule::class, - StorageModule::class, - AppModule::class, - SchedulerModule::class, - PlatformModule::class, - /** - * BuildVariantModule that can be overridden per build variant. No default implementation exists. - * Instead, different build variants (flavours, build types) can provide a different implementation. - */ - BuildVariantModule::class - ] -) -interface AppComponent diff --git a/project/dependencies.gradle b/project/dependencies.gradle index 5e09c80..57af2f6 100644 --- a/project/dependencies.gradle +++ b/project/dependencies.gradle @@ -16,5 +16,23 @@ ext.libsHelper = [ addDependencyInjectionDependencies: { handler -> handler.implementation libs.hilt handler.kapt libs.hilt.compiler - } + }, + + addInstrumentationTestDependencies: { handler -> + handler.androidTestImplementation libs.test.junit + handler.androidTestImplementation libs.test.junit.kotlin + handler.androidTestImplementation libs.test.mockito.core + handler.androidTestImplementation libs.test.mockito.kotlin + + handler.androidTestImplementation libs.test.androidx.junit // Junit runner + handler.androidTestImplementation libs.test.androidx.archtesting + handler.androidTestImplementation libs.test.androidx.testrunner + handler.androidTestImplementation libs.test.androidx.core + handler.androidTestImplementation libs.test.androidx.espresso.core + handler.androidTestImplementation libs.test.androidx.espresso.intents + + handler.androidTestImplementation libs.hilt.android.testing + handler.androidTestAnnotationProcessor libs.hilt.android.compiler + handler.kaptAndroidTest libs.hilt.android.compiler + }, ] diff --git a/project/gradle/libs.versions.toml b/project/gradle/libs.versions.toml index 1c91e07..16d7814 100644 --- a/project/gradle/libs.versions.toml +++ b/project/gradle/libs.versions.toml @@ -71,6 +71,8 @@ rxjava-android = "io.reactivex.rxjava3:rxandroid:3.0.2" # Dagger / Hilt hilt = { module = "com.google.dagger:hilt-android", version.ref = "hilt" } hilt-compiler = { module = "com.google.dagger:hilt-compiler", version.ref = "hilt" } +hilt-android-compiler = { module = "com.google.dagger:hilt-android-compiler", version.ref = "hilt" } +hilt-android-testing = { module = "com.google.dagger:hilt-android-testing", version.ref = "hilt" } # Utilities scrolls = "mobi.lab.scrolls:scrolls:2.0.9" @@ -100,3 +102,10 @@ test-junit-kotlin = { module = "org.jetbrains.kotlin:kotlin-test-junit", version test-mockito-core = "org.mockito:mockito-core:5.11.0" test-mockito-kotlin = "com.nhaarman.mockitokotlin2:mockito-kotlin:2.2.0" test-androidx-archtesting = "androidx.arch.core:core-testing:2.2.0" + +# UI Testing +test-androidx-testrunner = "androidx.test:runner:1.5.2" +test-androidx-junit = "androidx.test.ext:junit-ktx:1.1.5" +test-androidx-core = "androidx.test:core-ktx:1.5.0" +test-androidx-espresso-core = "androidx.test.espresso:espresso-core:3.5.1" +test-androidx-espresso-intents = "androidx.test.espresso:espresso-intents:3.5.1" diff --git a/project/gradle/wrapper/gradle-wrapper.properties b/project/gradle/wrapper/gradle-wrapper.properties index 17655d0..48c0a02 100644 --- a/project/gradle/wrapper/gradle-wrapper.properties +++ b/project/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,5 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.6-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists From 4ff91e10995e404e3ea5bd504973b0ae46399ddf Mon Sep 17 00:00:00 2001 From: lauris Date: Wed, 10 Apr 2024 10:37:23 +0300 Subject: [PATCH 2/5] Clean up TestSchedulerProvider impl --- .../java/mobi/lab/sample/util/TestSchedulerProvider.kt | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/project/app/src/androidTest/java/mobi/lab/sample/util/TestSchedulerProvider.kt b/project/app/src/androidTest/java/mobi/lab/sample/util/TestSchedulerProvider.kt index bb2fcac..026f0d8 100644 --- a/project/app/src/androidTest/java/mobi/lab/sample/util/TestSchedulerProvider.kt +++ b/project/app/src/androidTest/java/mobi/lab/sample/util/TestSchedulerProvider.kt @@ -8,8 +8,10 @@ import io.reactivex.rxjava3.core.Single import io.reactivex.rxjava3.schedulers.Schedulers import mobi.lab.sample.app.common.test.Idler import mobi.lab.sample.common.rx.SchedulerProvider +import timber.log.Timber class TestSchedulerProvider(private val idler: Idler) : SchedulerProvider() { + override val main = AndroidSchedulers.mainThread() override val computation = Schedulers.computation() override val io = Schedulers.io() @@ -47,12 +49,12 @@ class TestSchedulerProvider(private val idler: Idler) : SchedulerProvider() { } private fun busy(key: Any) { + Timber.v("Idler busy with $key") idler.increment() -// BusyBee.singleton().busyWith(key) } private fun done(key: Any) { + Timber.v("Idler done with $key") idler.decrement() -// BusyBee.singleton().completed(key) } } From 7a208c24fb138e9103b7d2717de1dfc4c3b2e0dd Mon Sep 17 00:00:00 2001 From: lauris Date: Wed, 10 Apr 2024 14:20:52 +0300 Subject: [PATCH 3/5] Remove closeKeyboard calls from LoginActivityTest and fix unit tests' TestSchedulerProvider --- .../mobi/lab/sample/demo/login/LoginActivityTest.kt | 13 ++++++------- .../lab/sample/common/rx/TestSchedulerProvider.kt | 2 +- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/project/app/src/androidTest/java/mobi/lab/sample/demo/login/LoginActivityTest.kt b/project/app/src/androidTest/java/mobi/lab/sample/demo/login/LoginActivityTest.kt index 114ef42..f65d19a 100644 --- a/project/app/src/androidTest/java/mobi/lab/sample/demo/login/LoginActivityTest.kt +++ b/project/app/src/androidTest/java/mobi/lab/sample/demo/login/LoginActivityTest.kt @@ -3,7 +3,6 @@ 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.closeSoftKeyboard import androidx.test.espresso.action.ViewActions.typeText import androidx.test.espresso.assertion.ViewAssertions.matches import androidx.test.espresso.intent.Intents @@ -70,7 +69,7 @@ class LoginActivityTest { @Test fun show_input_error_when_only_username_is_filled_rxidler() { - onView(withId(R.id.edit_text_email)).perform(typeText("asd"), closeSoftKeyboard()) + 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())) @@ -81,7 +80,7 @@ class LoginActivityTest { @Test fun show_input_error_when_only_password_is_filled_rxidler() { - onView(withId(R.id.edit_text_password)).perform(typeText("asd"), closeSoftKeyboard()) + 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))) @@ -92,8 +91,8 @@ class LoginActivityTest { @Test fun login_success_when_fields_are_filled_rxidler() { - onView(withId(R.id.edit_text_email)).perform(typeText("asd"), closeSoftKeyboard()) - onView(withId(R.id.edit_text_password)).perform(typeText("asd"), closeSoftKeyboard()) + 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)) @@ -102,8 +101,8 @@ class LoginActivityTest { @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"), closeSoftKeyboard()) - onView(withId(R.id.edit_text_password)).perform(typeText("asd"), closeSoftKeyboard()) + 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 diff --git a/project/app/src/test/java/mobi/lab/sample/common/rx/TestSchedulerProvider.kt b/project/app/src/test/java/mobi/lab/sample/common/rx/TestSchedulerProvider.kt index 014db18..26bcce1 100644 --- a/project/app/src/test/java/mobi/lab/sample/common/rx/TestSchedulerProvider.kt +++ b/project/app/src/test/java/mobi/lab/sample/common/rx/TestSchedulerProvider.kt @@ -3,7 +3,7 @@ package mobi.lab.sample.common.rx import io.reactivex.rxjava3.core.Scheduler import io.reactivex.rxjava3.schedulers.Schedulers -object TestSchedulerProvider : SchedulerProvider { +object TestSchedulerProvider : SchedulerProvider() { override val main: Scheduler = Schedulers.trampoline() override val computation: Scheduler = Schedulers.trampoline() override val io: Scheduler = Schedulers.trampoline() From 573c05baa029d2b0ed3b57b707be4ee5a20df891 Mon Sep 17 00:00:00 2001 From: lauris Date: Fri, 19 Apr 2024 12:07:27 +0300 Subject: [PATCH 4/5] Update Idler implementation with a new interface --- .../mobi/lab/sample/app/common/test/Idler.kt | 44 +++++++++++++- .../lab/sample/app/common/test/IdlerToken.kt | 3 + .../lab/sample/app/common/test/NoOpIdler.kt | 25 ++++++-- .../sample/demo/login/LoginActivityTest.kt | 2 +- .../java/mobi/lab/sample/util/RealIdler.kt | 59 ++++++++++++++++++- .../lab/sample/util/TestSchedulerProvider.kt | 46 +++++---------- 6 files changed, 140 insertions(+), 39 deletions(-) create mode 100644 project/app-common/src/main/java/mobi/lab/sample/app/common/test/IdlerToken.kt diff --git a/project/app-common/src/main/java/mobi/lab/sample/app/common/test/Idler.kt b/project/app-common/src/main/java/mobi/lab/sample/app/common/test/Idler.kt index 01f6f92..ff3471f 100644 --- a/project/app-common/src/main/java/mobi/lab/sample/app/common/test/Idler.kt +++ b/project/app-common/src/main/java/mobi/lab/sample/app/common/test/Idler.kt @@ -1,6 +1,46 @@ package mobi.lab.sample.app.common.test interface Idler { - fun increment() - fun decrement() + /** + * 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) } diff --git a/project/app-common/src/main/java/mobi/lab/sample/app/common/test/IdlerToken.kt b/project/app-common/src/main/java/mobi/lab/sample/app/common/test/IdlerToken.kt new file mode 100644 index 0000000..3f2ca00 --- /dev/null +++ b/project/app-common/src/main/java/mobi/lab/sample/app/common/test/IdlerToken.kt @@ -0,0 +1,3 @@ +package mobi.lab.sample.app.common.test + +data class IdlerToken(val key: Any) diff --git a/project/app-common/src/main/java/mobi/lab/sample/app/common/test/NoOpIdler.kt b/project/app-common/src/main/java/mobi/lab/sample/app/common/test/NoOpIdler.kt index 7af9cb4..52d2db4 100644 --- a/project/app-common/src/main/java/mobi/lab/sample/app/common/test/NoOpIdler.kt +++ b/project/app-common/src/main/java/mobi/lab/sample/app/common/test/NoOpIdler.kt @@ -1,11 +1,28 @@ package mobi.lab.sample.app.common.test class NoOpIdler : Idler { - override fun increment() { - // No-op + + 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 decrement() { - // No-op + override fun done(token: IdlerToken) { + // Do nothing } } diff --git a/project/app/src/androidTest/java/mobi/lab/sample/demo/login/LoginActivityTest.kt b/project/app/src/androidTest/java/mobi/lab/sample/demo/login/LoginActivityTest.kt index f65d19a..61bedc5 100644 --- a/project/app/src/androidTest/java/mobi/lab/sample/demo/login/LoginActivityTest.kt +++ b/project/app/src/androidTest/java/mobi/lab/sample/demo/login/LoginActivityTest.kt @@ -106,7 +106,7 @@ class LoginActivityTest { onView(withId(R.id.button_login)).perform(click()) // Validate the dialog and close it - onView(withText(R.string.demo_error_generic)).check(matches(isDisplayed())) + onView(withText(R.string.error_generic)).check(matches(isDisplayed())) Espresso.pressBack() onView(withId(R.id.input_layout_email)).check(matches(hasNoTextInputLayoutError())) diff --git a/project/app/src/androidTest/java/mobi/lab/sample/util/RealIdler.kt b/project/app/src/androidTest/java/mobi/lab/sample/util/RealIdler.kt index b6d374d..bbf8b9b 100644 --- a/project/app/src/androidTest/java/mobi/lab/sample/util/RealIdler.kt +++ b/project/app/src/androidTest/java/mobi/lab/sample/util/RealIdler.kt @@ -2,15 +2,70 @@ package mobi.lab.sample.util import androidx.test.espresso.idling.CountingIdlingResource import mobi.lab.sample.app.common.test.Idler +import mobi.lab.sample.app.common.test.IdlerToken +import timber.log.Timber +import java.util.Collections +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap object RealIdler : Idler { + // NB! Do not forget to register this with IdlingRegistry. val idlingResource: CountingIdlingResource = CountingIdlingResource("RealIdler", true) - override fun increment() { + internal val set = Collections.newSetFromMap(ConcurrentHashMap()) + + override fun token(): IdlerToken { + return IdlerToken(newKey()) + } + + override fun busy(): IdlerToken { + return busyInternal(newKey()) + } + + override fun busy(key: Any): IdlerToken { + return busyInternal(key) + } + + override fun busy(token: IdlerToken) { + busyInternal(token.key) + } + + override fun done(key: Any) { + doneInternal(key) + } + + override fun done(token: IdlerToken) { + doneInternal(token.key) + } + + private fun busyInternal(key: Any): IdlerToken { + debugLog("busyInternal key=$key set=$set") + idlingResource.dumpStateToLogs() + + val added = set.add(key) + if (!added) { + throw RuntimeException("Idler is already busy with key $key. Invalid state when marking $key as busy") + } idlingResource.increment() + return IdlerToken(key) } - override fun decrement() { + private fun doneInternal(key: Any) { + debugLog("doneInternal key=$key set=$set") + idlingResource.dumpStateToLogs() + + val removed = set.remove(key) + if (!removed) { + throw RuntimeException("Idler is not busy with key $key. Invalid state when marking $key as done.") + } idlingResource.decrement() } + + private fun debugLog(message: String) { + Timber.v(message) + } + + private fun newKey(): Any { + return UUID.randomUUID() + } } diff --git a/project/app/src/androidTest/java/mobi/lab/sample/util/TestSchedulerProvider.kt b/project/app/src/androidTest/java/mobi/lab/sample/util/TestSchedulerProvider.kt index 026f0d8..c1d31e0 100644 --- a/project/app/src/androidTest/java/mobi/lab/sample/util/TestSchedulerProvider.kt +++ b/project/app/src/androidTest/java/mobi/lab/sample/util/TestSchedulerProvider.kt @@ -4,57 +4,43 @@ import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers import io.reactivex.rxjava3.core.Completable import io.reactivex.rxjava3.core.Maybe import io.reactivex.rxjava3.core.Observable +import io.reactivex.rxjava3.core.Scheduler import io.reactivex.rxjava3.core.Single import io.reactivex.rxjava3.schedulers.Schedulers import mobi.lab.sample.app.common.test.Idler import mobi.lab.sample.common.rx.SchedulerProvider -import timber.log.Timber class TestSchedulerProvider(private val idler: Idler) : SchedulerProvider() { - override val main = AndroidSchedulers.mainThread() - override val computation = Schedulers.computation() - override val io = Schedulers.io() + override val main: Scheduler = AndroidSchedulers.mainThread() + override val computation: Scheduler = Schedulers.computation() + override val io: Scheduler = Schedulers.io() override fun Observable.transformObservableInternal(): Observable { - val key = key() + val token = idler.token() return this - .doOnSubscribe { busy(key) } - .doFinally { done(key) } + .doOnSubscribe { idler.busy(token) } + .doFinally { idler.done(token) } } override fun Single.transformSingleInternal(): Single { - val key = key() + val token = idler.token() return this - .doOnSubscribe { busy(key) } - .doFinally { done(key) } + .doOnSubscribe { idler.busy(token) } + .doFinally { idler.done(token) } } override fun Completable.transformCompletableInternal(): Completable { - val key = key() + val token = idler.token() return this - .doOnSubscribe { busy(key) } - .doFinally { done(key) } + .doOnSubscribe { idler.busy(token) } + .doFinally { idler.done(token) } } override fun Maybe.transformMaybeInternal(): Maybe { - val key = key() + val token = idler.token() return this - .doOnSubscribe { busy(key) } - .doFinally { done(key) } - } - - private fun key(): Any { - return System.currentTimeMillis() - } - - private fun busy(key: Any) { - Timber.v("Idler busy with $key") - idler.increment() - } - - private fun done(key: Any) { - Timber.v("Idler done with $key") - idler.decrement() + .doOnSubscribe { idler.busy(token) } + .doFinally { idler.done(token) } } } From f43edf860adbfcf388cdb106b600c3d3afd3c84c Mon Sep 17 00:00:00 2001 From: lauris Date: Fri, 19 Apr 2024 13:45:33 +0300 Subject: [PATCH 5/5] Add isDialog() check to UI test that checks that a dialog with text is displayed --- .../java/mobi/lab/sample/demo/login/LoginActivityTest.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/project/app/src/androidTest/java/mobi/lab/sample/demo/login/LoginActivityTest.kt b/project/app/src/androidTest/java/mobi/lab/sample/demo/login/LoginActivityTest.kt index 61bedc5..88f5a1c 100644 --- a/project/app/src/androidTest/java/mobi/lab/sample/demo/login/LoginActivityTest.kt +++ b/project/app/src/androidTest/java/mobi/lab/sample/demo/login/LoginActivityTest.kt @@ -7,6 +7,7 @@ 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 @@ -106,7 +107,9 @@ class LoginActivityTest { onView(withId(R.id.button_login)).perform(click()) // Validate the dialog and close it - onView(withText(R.string.error_generic)).check(matches(isDisplayed())) + onView(withText(R.string.error_generic)) + .inRoot(isDialog()) + .check(matches(isDisplayed())) Espresso.pressBack() onView(withId(R.id.input_layout_email)).check(matches(hasNoTextInputLayoutError()))