Merge pull request #98 from fknives/issue#67

Issue#67
This commit is contained in:
Gergely Hegedis 2022-07-12 16:56:42 +03:00 committed by GitHub
commit 45bcd20b2a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
88 changed files with 1082 additions and 549 deletions

27
.github/workflows/release-package.yml vendored Normal file
View file

@ -0,0 +1,27 @@
name: Library Package Publishing
on:
release:
types:
- created
env:
GITHUB_USERNAME: "fknives"
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
jobs:
publish-library:
runs-on: ubuntu-latest
permissions:
packages: write
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@v2
- name: Setup Java
uses: actions/setup-java@v2
with:
distribution: 'adopt'
java-version: '11'
- name: Publish Project
run: ./gradlew publishToGitHub

View file

@ -170,5 +170,59 @@ Open the [shared tests instruction set](./codekata/sharedtests.instructionset.md
In this section we will see how can we share Robolectric test source with AndroidTests to run our same tests on actual device.
We will also see how to write AndroidTest End to End Tests.
## Util classes
Additional modules have been added prefixed with test-util.
These contain all the reusable Test Util classes used in the showcase.
The Testing setup is extracted into a separate gradle script, which with some modifications, you should be able to easily add to your own project.
To use the TestUtil classes, you will need to add the GitHub Repository as a source for dependencies:
```groovy
// top level build.gradle
allprojects {
repositories {
// ...
maven {
url "https://maven.pkg.github.com/fknives/AndroidTest-ShowCase"
credentials {
username = project.findProperty("GITHUB_USERNAME") ?: System.getenv("GITHUB_USERNAME")
password = project.findProperty("GITHUB_TOKEN") ?: System.getenv("GITHUB_TOKEN")
}
// https://docs.github.com/en/github/authenticating-to-github/keeping-your-account-and-data-secure/creating-a-personal-access-token
}
}
}
// OR
// top level build.gradle.kts
allprojects {
repositories {
// ...
maven {
url = uri("https://maven.pkg.github.com/fknives/AndroidTest-ShowCase")
credentials {
username = extra.properties["GITHUB_USERNAME"] as String? ?: System.getenv("GITHUB_USERNAME")
password = extra.properties["GITHUB_TOKEN"] as String? ?: System.getenv("GITHUB_TOKEN")
}
// https://docs.github.com/en/github/authenticating-to-github/keeping-your-account-and-data-secure/creating-a-personal-access-token
}
}
}
```
*Latest version:*![Latest release](https://img.shields.io/github/v/release/fknives/AndroidTest-ShowCase)
and then you can use the following dependencies:
```groovy
testImplementation "org.fnives.android.testutil:android-unit-junit5:<latestVersion>" // test-util-junit5-android
testImplementation "org.fnives.android.testutil:shared-robolectric:<latestVersion>" // test-util-shared-robolectric
testImplementation "org.fnives.android.testutil:android:<latestVersion>" // test-util-android
androidTestImplementation "org.fnives.android.testutil:android:<latestVersion>" // test-util-android
androidTestImplementation "org.fnives.android.testutil:shared-android:<latestVersion>" // test-util-shared-android
```
## License
[License file](./LICENSE)

View file

@ -92,55 +92,25 @@ dependencies {
implementation "io.insert-koin:koin-android:$koin_version"
implementation "io.insert-koin:koin-androidx-compose:$koin_version"
implementation "androidx.room:room-runtime:$androidx_room_version"
kapt "androidx.room:room-compiler:$androidx_room_version"
implementation "androidx.room:room-ktx:$androidx_room_version"
implementation "androidx.room:room-runtime:$room_version"
kapt "androidx.room:room-compiler:$room_version"
implementation "androidx.room:room-ktx:$room_version"
implementation "io.coil-kt:coil:$coil_version"
implementation "io.coil-kt:coil-compose:$coil_version"
implementation project(":core")
testImplementation "androidx.room:room-testing:$androidx_room_version"
testImplementation "org.junit.jupiter:junit-jupiter-engine:$testing_junit5_version"
testImplementation "org.junit.jupiter:junit-jupiter-params:$testing_junit5_version"
testImplementation "org.mockito.kotlin:mockito-kotlin:$testing_kotlin_mockito_version"
testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutines_version"
testImplementation "com.jraska.livedata:testing-ktx:$testing_livedata_version"
testImplementation "io.insert-koin:koin-test-junit5:$koin_version"
testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine:$testing_junit5_version"
applyAppTestDependenciesTo(this)
applyComposeTestDependenciesTo(this)
// robolectric specific
testImplementation "junit:junit:$testing_junit4_version"
testImplementation "org.robolectric:robolectric:$testing_robolectric_version"
testImplementation "androidx.test:core:$testing_androidx_code_version"
testImplementation "androidx.test:runner:$testing_androidx_code_version"
testImplementation "androidx.test.ext:junit:$testing_androidx_junit_version"
testImplementation "androidx.test.espresso:espresso-core:$testing_espresso_version"
testImplementation "androidx.test.espresso:espresso-intents:$testing_espresso_version"
testImplementation "androidx.test.espresso:espresso-contrib:$testing_espresso_version"
testImplementation project(':mockserver')
testImplementation "androidx.arch.core:core-testing:$testing_androidx_arch_core_version"
testRuntimeOnly "org.junit.vintage:junit-vintage-engine:$testing_junit5_version"
androidTestImplementation "androidx.room:room-testing:$androidx_room_version"
androidTestImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutines_version"
androidTestImplementation "io.insert-koin:koin-test-junit4:$koin_version"
androidTestImplementation "junit:junit:$testing_junit4_version"
androidTestImplementation "androidx.test:core:$testing_androidx_code_version"
androidTestImplementation "androidx.test:runner:$testing_androidx_code_version"
androidTestImplementation "androidx.test.ext:junit:$testing_androidx_junit_version"
androidTestImplementation "androidx.test.espresso:espresso-core:$testing_espresso_version"
androidTestImplementation "androidx.test.espresso:espresso-intents:$testing_espresso_version"
androidTestImplementation "androidx.test.espresso:espresso-contrib:$testing_espresso_version"
androidTestImplementation "androidx.compose.ui:ui-test-junit4:$androidx_compose_version"
testImplementation "androidx.compose.ui:ui-test-junit4:$androidx_compose_version"
debugImplementation "androidx.compose.ui:ui-test-manifest:$androidx_compose_version"
androidTestImplementation project(':mockserver')
androidTestImplementation "androidx.arch.core:core-testing:$testing_androidx_arch_core_version"
androidTestRuntimeOnly "org.junit.vintage:junit-vintage-engine:$testing_junit5_version"
implementation "io.reactivex.rxjava3:rxjava:3.1.4"
testImplementation project(':test-util-junit5-android')
testImplementation project(':test-util-shared-robolectric')
testImplementation project(':test-util-android')
androidTestImplementation project(':test-util-android')
androidTestImplementation project(':test-util-shared-android')
testImplementation testFixtures(project(':core'))
androidTestImplementation testFixtures(project(':core'))

View file

@ -11,12 +11,12 @@ import androidx.test.rule.ActivityTestRule
import androidx.test.runner.AndroidJUnit4
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import org.fnives.test.showcase.R
import org.fnives.test.showcase.android.testutil.synchronization.idlingresources.CompositeDisposable
import org.fnives.test.showcase.android.testutil.synchronization.idlingresources.Disposable
import org.fnives.test.showcase.android.testutil.synchronization.idlingresources.IdlingResourceDisposable
import org.fnives.test.showcase.android.testutil.synchronization.idlingresources.OkHttp3IdlingResource
import org.fnives.test.showcase.android.testutil.synchronization.loopMainThreadFor
import org.fnives.test.showcase.network.testutil.NetworkTestConfigurationHelper
import org.fnives.test.showcase.testutils.idling.CompositeDisposable
import org.fnives.test.showcase.testutils.idling.Disposable
import org.fnives.test.showcase.testutils.idling.IdlingResourceDisposable
import org.fnives.test.showcase.testutils.idling.OkHttp3IdlingResource
import org.fnives.test.showcase.testutils.idling.loopMainThreadFor
import org.fnives.test.showcase.testutils.storage.TestDatabaseInitialization
import org.fnives.test.showcase.ui.splash.SplashActivity
import org.hamcrest.Description

View file

@ -1,41 +0,0 @@
package org.fnives.test.showcase.testutils.configuration
import android.app.Instrumentation
import androidx.room.RoomDatabase
import androidx.room.migration.AutoMigrationSpec
import androidx.sqlite.db.SupportSQLiteOpenHelper
object AndroidMigrationTestRuleFactory : SharedMigrationTestRuleFactory {
override fun createSharedMigrationTestRule(
instrumentation: Instrumentation,
databaseClass: Class<out RoomDatabase>
): SharedMigrationTestRule =
AndroidMigrationTestRule(
instrumentation,
databaseClass
)
override fun createSharedMigrationTestRule(
instrumentation: Instrumentation,
databaseClass: Class<out RoomDatabase>,
specs: List<AutoMigrationSpec>
): SharedMigrationTestRule =
AndroidMigrationTestRule(
instrumentation,
databaseClass,
specs
)
override fun createSharedMigrationTestRule(
instrumentation: Instrumentation,
databaseClass: Class<out RoomDatabase>,
specs: List<AutoMigrationSpec>,
openFactory: SupportSQLiteOpenHelper.Factory
): SharedMigrationTestRule =
AndroidMigrationTestRule(
instrumentation,
databaseClass,
specs,
openFactory,
)
}

View file

@ -1,7 +0,0 @@
package org.fnives.test.showcase.testutils.configuration
object SpecificTestConfigurationsFactory : TestConfigurationsFactory {
override fun createSharedMigrationTestRuleFactory(): SharedMigrationTestRuleFactory =
AndroidMigrationTestRuleFactory
}

View file

@ -4,13 +4,13 @@ import androidx.compose.ui.test.junit4.StateRestorationTester
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.fnives.test.showcase.R
import org.fnives.test.showcase.android.testutil.synchronization.idlingresources.anyResourceIdling
import org.fnives.test.showcase.compose.screen.AppNavigation
import org.fnives.test.showcase.core.integration.fake.FakeUserDataLocalStorage
import org.fnives.test.showcase.core.login.IsUserLoggedInUseCase
import org.fnives.test.showcase.network.mockserver.scenario.auth.AuthScenario
import org.fnives.test.showcase.testutils.MockServerScenarioSetupResetingTestRule
import org.fnives.test.showcase.testutils.idling.DispatcherTestRule
import org.fnives.test.showcase.testutils.idling.anyResourceIdling
import org.fnives.test.showcase.testutils.idling.DatabaseDispatcherTestRule
import org.junit.Before
import org.junit.Rule
import org.junit.Test
@ -27,7 +27,7 @@ class AuthComposeInstrumentedTest : KoinTest {
private val mockServerScenarioSetupTestRule = MockServerScenarioSetupResetingTestRule()
private val mockServerScenarioSetup get() = mockServerScenarioSetupTestRule.mockServerScenarioSetup
private val dispatcherTestRule = DispatcherTestRule()
private val dispatcherTestRule = DatabaseDispatcherTestRule()
private lateinit var robot: ComposeLoginRobot
private lateinit var navigationRobot: ComposeNavigationRobot

View file

@ -1,41 +0,0 @@
package org.fnives.test.showcase.testutils.configuration
import android.app.Instrumentation
import androidx.room.RoomDatabase
import androidx.room.migration.AutoMigrationSpec
import androidx.sqlite.db.SupportSQLiteOpenHelper
object RobolectricMigrationTestHelperFactory : SharedMigrationTestRuleFactory {
override fun createSharedMigrationTestRule(
instrumentation: Instrumentation,
databaseClass: Class<out RoomDatabase>
): SharedMigrationTestRule =
RobolectricMigrationTestHelper(
instrumentation,
databaseClass
)
override fun createSharedMigrationTestRule(
instrumentation: Instrumentation,
databaseClass: Class<out RoomDatabase>,
specs: List<AutoMigrationSpec>
): SharedMigrationTestRule =
RobolectricMigrationTestHelper(
instrumentation,
databaseClass,
specs
)
override fun createSharedMigrationTestRule(
instrumentation: Instrumentation,
databaseClass: Class<out RoomDatabase>,
specs: List<AutoMigrationSpec>,
openFactory: SupportSQLiteOpenHelper.Factory
): SharedMigrationTestRule =
RobolectricMigrationTestHelper(
instrumentation,
databaseClass,
specs,
openFactory
)
}

View file

@ -1,7 +0,0 @@
package org.fnives.test.showcase.testutils.configuration
object SpecificTestConfigurationsFactory : TestConfigurationsFactory {
override fun createSharedMigrationTestRuleFactory(): SharedMigrationTestRuleFactory =
RobolectricMigrationTestHelperFactory
}

View file

@ -11,15 +11,15 @@ import kotlinx.coroutines.test.TestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
import org.fnives.test.showcase.R
import org.fnives.test.showcase.android.testutil.activity.safeClose
import org.fnives.test.showcase.android.testutil.synchronization.idlingresources.CompositeDisposable
import org.fnives.test.showcase.android.testutil.synchronization.idlingresources.Disposable
import org.fnives.test.showcase.android.testutil.synchronization.idlingresources.IdlingResourceDisposable
import org.fnives.test.showcase.android.testutil.synchronization.idlingresources.OkHttp3IdlingResource
import org.fnives.test.showcase.network.mockserver.MockServerScenarioSetup
import org.fnives.test.showcase.network.mockserver.scenario.auth.AuthScenario
import org.fnives.test.showcase.network.testutil.NetworkTestConfigurationHelper
import org.fnives.test.showcase.testutils.idling.CompositeDisposable
import org.fnives.test.showcase.testutils.idling.Disposable
import org.fnives.test.showcase.testutils.idling.IdlingResourceDisposable
import org.fnives.test.showcase.testutils.idling.MainDispatcherTestRule.Companion.advanceUntilIdleWithIdlingResources
import org.fnives.test.showcase.testutils.idling.OkHttp3IdlingResource
import org.fnives.test.showcase.testutils.safeClose
import org.fnives.test.showcase.testutils.idling.DatabaseDispatcherTestRule.Companion.advanceUntilIdleWithIdlingResources
import org.fnives.test.showcase.testutils.storage.TestDatabaseInitialization
import org.fnives.test.showcase.ui.auth.AuthActivity
import org.junit.After

View file

@ -10,14 +10,13 @@ import androidx.test.espresso.matcher.ViewMatchers
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
import androidx.test.espresso.matcher.ViewMatchers.withId
import org.fnives.test.showcase.R
import org.fnives.test.showcase.testutils.configuration.SnackbarVerificationHelper
import org.fnives.test.showcase.testutils.viewactions.notIntended
import org.fnives.test.showcase.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.ui.home.MainActivity
import org.hamcrest.core.IsNot.not
class RobolectricLoginRobot(
private val snackbarVerificationHelper: SnackbarVerificationHelper = SnackbarVerificationHelper()
) {
class RobolectricLoginRobot {
fun setUsername(username: String): RobolectricLoginRobot = apply {
onView(withId(R.id.user_edit_text))
@ -55,11 +54,11 @@ class RobolectricLoginRobot(
}
fun assertErrorIsShown(@StringRes stringResID: Int) = apply {
snackbarVerificationHelper.assertIsShownWithText(stringResID)
assertSnackBarIsShownWithText(stringResID)
}
fun assertErrorIsNotShown() = apply {
snackbarVerificationHelper.assertIsNotShown()
assertSnackBarIsNotShown()
}
fun assertNavigatedToHome() = apply {

View file

@ -1,16 +1,14 @@
package org.fnives.test.showcase.storage.migration
import androidx.room.Room
import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory
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.storage.LocalDatabase
import org.fnives.test.showcase.storage.favourite.FavouriteEntity
import org.fnives.test.showcase.storage.migation.Migration1To2
import org.fnives.test.showcase.testutils.configuration.SharedMigrationTestRule
import org.fnives.test.showcase.testutils.configuration.createSharedMigrationTestRule
import org.junit.After
import org.junit.Assert
import org.junit.Rule
@ -28,11 +26,7 @@ import java.io.IOException
class MigrationToLatestInstrumentedTest {
@get:Rule
val helper: SharedMigrationTestRule = createSharedMigrationTestRule<LocalDatabase>(
InstrumentationRegistry.getInstrumentation(),
emptyList(),
FrameworkSQLiteOpenHelperFactory()
)
val helper = SharedMigrationTestRule<LocalDatabase>(instrumentation = InstrumentationRegistry.getInstrumentation())
private fun getMigratedRoomDatabase(): LocalDatabase {
val database: LocalDatabase = Room.databaseBuilder(

View file

@ -1,60 +0,0 @@
package org.fnives.test.showcase.testutils.configuration
import android.app.Instrumentation
import androidx.room.RoomDatabase
import androidx.room.migration.AutoMigrationSpec
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
import androidx.sqlite.db.SupportSQLiteOpenHelper
import org.junit.rules.TestRule
import java.io.IOException
interface SharedMigrationTestRule : TestRule {
@Throws(IOException::class)
fun createDatabase(name: String, version: Int): SupportSQLiteDatabase
@Throws(IOException::class)
fun runMigrationsAndValidate(
name: String,
version: Int,
validateDroppedTables: Boolean,
vararg migrations: Migration
): SupportSQLiteDatabase
fun closeWhenFinished(db: SupportSQLiteDatabase)
fun closeWhenFinished(db: RoomDatabase)
}
inline fun <reified DB : RoomDatabase> createSharedMigrationTestRule(
instrumentation: Instrumentation
): SharedMigrationTestRule =
SpecificTestConfigurationsFactory.createSharedMigrationTestRuleFactory()
.createSharedMigrationTestRule(
instrumentation,
DB::class.java
)
inline fun <reified DB : RoomDatabase> createSharedMigrationTestRule(
instrumentation: Instrumentation,
specs: List<AutoMigrationSpec>
): SharedMigrationTestRule =
SpecificTestConfigurationsFactory.createSharedMigrationTestRuleFactory()
.createSharedMigrationTestRule(
instrumentation,
DB::class.java,
specs
)
inline fun <reified DB : RoomDatabase> createSharedMigrationTestRule(
instrumentation: Instrumentation,
specs: List<AutoMigrationSpec>,
openFactory: SupportSQLiteOpenHelper.Factory
): SharedMigrationTestRule =
SpecificTestConfigurationsFactory.createSharedMigrationTestRuleFactory()
.createSharedMigrationTestRule(
instrumentation,
DB::class.java,
specs,
openFactory
)

View file

@ -1,27 +0,0 @@
package org.fnives.test.showcase.testutils.configuration
import android.app.Instrumentation
import androidx.room.RoomDatabase
import androidx.room.migration.AutoMigrationSpec
import androidx.sqlite.db.SupportSQLiteOpenHelper
interface SharedMigrationTestRuleFactory {
fun createSharedMigrationTestRule(
instrumentation: Instrumentation,
databaseClass: Class<out RoomDatabase>,
): SharedMigrationTestRule
fun createSharedMigrationTestRule(
instrumentation: Instrumentation,
databaseClass: Class<out RoomDatabase>,
specs: List<AutoMigrationSpec>
): SharedMigrationTestRule
fun createSharedMigrationTestRule(
instrumentation: Instrumentation,
databaseClass: Class<out RoomDatabase>,
specs: List<AutoMigrationSpec>,
openFactory: SupportSQLiteOpenHelper.Factory
): SharedMigrationTestRule
}

View file

@ -1,12 +0,0 @@
package org.fnives.test.showcase.testutils.configuration
/**
* Defines the platform specific configurations for Robolectric and AndroidTest.
*
* Each should have an object [SpecificTestConfigurationsFactory] implementing this interface so the SharedTests are
* configured properly.
*/
interface TestConfigurationsFactory {
fun createSharedMigrationTestRuleFactory(): SharedMigrationTestRuleFactory
}

View file

@ -1,19 +0,0 @@
package org.fnives.test.showcase.testutils
import android.os.Handler
import android.os.Looper
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.runBlocking
fun runOnUIAwaitOnCurrent(action: () -> Unit) {
if (Looper.myLooper() === Looper.getMainLooper()) {
action()
} else {
val deferred = CompletableDeferred<Unit>()
Handler(Looper.getMainLooper()).post {
action()
deferred.complete(Unit)
}
runBlocking { deferred.await() }
}
}

View file

@ -3,14 +3,16 @@ package org.fnives.test.showcase.testutils.idling
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestDispatcher
import org.fnives.test.showcase.testutils.runOnUIAwaitOnCurrent
import org.fnives.test.showcase.android.testutil.synchronization.idlingresources.anyResourceIdling
import org.fnives.test.showcase.android.testutil.synchronization.idlingresources.awaitIdlingResources
import org.fnives.test.showcase.android.testutil.synchronization.runOnUIAwaitOnCurrent
import org.fnives.test.showcase.testutils.storage.TestDatabaseInitialization
import org.junit.rules.TestRule
import org.junit.runner.Description
import org.junit.runners.model.Statement
@OptIn(ExperimentalCoroutinesApi::class)
class DispatcherTestRule : TestRule {
class DatabaseDispatcherTestRule : TestRule {
private lateinit var testDispatcher: TestDispatcher

View file

@ -1,6 +0,0 @@
package org.fnives.test.showcase.testutils.idling
interface Disposable {
val isDisposed: Boolean
fun dispose()
}

View file

@ -1,58 +1,14 @@
package org.fnives.test.showcase.testutils.idling
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
import org.fnives.test.showcase.testutils.runOnUIAwaitOnCurrent
import org.fnives.test.showcase.testutils.storage.TestDatabaseInitialization
import org.junit.rules.TestRule
import org.junit.runner.Description
import org.junit.runners.model.Statement
import org.fnives.test.showcase.android.testutil.synchronization.MainDispatcherTestRule as LibMainDispatcherTestRule
@OptIn(ExperimentalCoroutinesApi::class)
class MainDispatcherTestRule : TestRule {
class MainDispatcherTestRule(useStandard: Boolean = true) : LibMainDispatcherTestRule(useStandard) {
private lateinit var testDispatcher: TestDispatcher
override fun apply(base: Statement, description: Description): Statement =
object : Statement() {
@Throws(Throwable::class)
override fun evaluate() {
val dispatcher = StandardTestDispatcher()
Dispatchers.setMain(dispatcher)
testDispatcher = dispatcher
TestDatabaseInitialization.overwriteDatabaseInitialization(dispatcher)
try {
base.evaluate()
} finally {
Dispatchers.resetMain()
}
}
}
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 (anyResourceIdling()) { // 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()
}
override fun onTestDispatcherInitialized(testDispatcher: TestDispatcher) {
TestDatabaseInitialization.overwriteDatabaseInitialization(testDispatcher)
}
}

View file

@ -3,6 +3,10 @@ package org.fnives.test.showcase.testutils.idling
import androidx.annotation.CheckResult
import androidx.test.espresso.IdlingResource
import okhttp3.OkHttpClient
import org.fnives.test.showcase.android.testutil.synchronization.idlingresources.CompositeDisposable
import org.fnives.test.showcase.android.testutil.synchronization.idlingresources.Disposable
import org.fnives.test.showcase.android.testutil.synchronization.idlingresources.IdlingResourceDisposable
import org.fnives.test.showcase.android.testutil.synchronization.idlingresources.OkHttp3IdlingResource
import org.fnives.test.showcase.network.testutil.NetworkTestConfigurationHelper
import org.junit.rules.TestRule
import org.junit.runner.Description

View file

@ -4,10 +4,10 @@ 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.network.mockserver.MockServerScenarioSetup
import org.fnives.test.showcase.network.mockserver.scenario.auth.AuthScenario
import org.fnives.test.showcase.testutils.idling.MainDispatcherTestRule
import org.fnives.test.showcase.testutils.safeClose
import org.fnives.test.showcase.ui.auth.AuthActivity
import org.fnives.test.showcase.ui.home.HomeRobot
import org.fnives.test.showcase.ui.home.MainActivity
@ -19,7 +19,7 @@ object SetupAuthenticationState : KoinTest {
fun setupLogin(
mainDispatcherTestRule: MainDispatcherTestRule,
mockServerScenarioSetup: MockServerScenarioSetup,
resetIntents: Boolean = true
resetIntents: Boolean = true,
) {
resetIntentsIfNeeded(resetIntents) {
mockServerScenarioSetup.setScenario(AuthScenario.Success(username = "a", password = "b"))
@ -40,7 +40,7 @@ object SetupAuthenticationState : KoinTest {
fun setupLogout(
mainDispatcherTestRule: MainDispatcherTestRule,
resetIntents: Boolean = true
resetIntents: Boolean = true,
) {
resetIntentsIfNeeded(resetIntents) {
val activityScenario = ActivityScenario.launch(MainActivity::class.java)

View file

@ -19,11 +19,11 @@ 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.R
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.swiperefresh.PullToRefresh
import org.fnives.test.showcase.model.content.Content
import org.fnives.test.showcase.model.content.FavouriteContent
import org.fnives.test.showcase.testutils.viewactions.PullToRefresh
import org.fnives.test.showcase.testutils.viewactions.WithDrawable
import org.fnives.test.showcase.testutils.viewactions.notIntended
import org.fnives.test.showcase.ui.auth.AuthActivity
import org.hamcrest.Matchers.allOf

View file

@ -3,6 +3,8 @@ package org.fnives.test.showcase.ui.home
import androidx.test.core.app.ActivityScenario
import androidx.test.espresso.intent.Intents
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.fnives.test.showcase.android.testutil.activity.safeClose
import org.fnives.test.showcase.android.testutil.synchronization.loopMainThreadFor
import org.fnives.test.showcase.model.content.FavouriteContent
import org.fnives.test.showcase.network.mockserver.ContentData
import org.fnives.test.showcase.network.mockserver.scenario.content.ContentScenario
@ -10,9 +12,6 @@ import org.fnives.test.showcase.network.mockserver.scenario.refresh.RefreshToken
import org.fnives.test.showcase.testutils.MockServerScenarioSetupResetingTestRule
import org.fnives.test.showcase.testutils.idling.AsyncDiffUtilInstantTestRule
import org.fnives.test.showcase.testutils.idling.MainDispatcherTestRule
import org.fnives.test.showcase.testutils.idling.loopMainThreadFor
import org.fnives.test.showcase.testutils.idling.loopMainThreadUntilIdleWithIdlingResources
import org.fnives.test.showcase.testutils.safeClose
import org.fnives.test.showcase.testutils.statesetup.SetupAuthenticationState.setupLogin
import org.junit.After
import org.junit.Before
@ -175,9 +174,6 @@ class MainActivityInstrumentedTest : KoinTest {
robot.swipeRefresh()
mainDispatcherTestRule.advanceUntilIdleWithIdlingResources()
loopMainThreadUntilIdleWithIdlingResources()
mainDispatcherTestRule.advanceTimeBy(1000L)
loopMainThreadFor(1000)
robot
.assertContainsError()

View file

@ -4,10 +4,10 @@ import androidx.test.core.app.ActivityScenario
import androidx.test.espresso.intent.Intents
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.fnives.test.showcase.R
import org.fnives.test.showcase.android.testutil.activity.safeClose
import org.fnives.test.showcase.network.mockserver.scenario.auth.AuthScenario
import org.fnives.test.showcase.testutils.MockServerScenarioSetupResetingTestRule
import org.fnives.test.showcase.testutils.idling.MainDispatcherTestRule
import org.fnives.test.showcase.testutils.safeClose
import org.fnives.test.showcase.ui.auth.AuthActivity
import org.junit.After
import org.junit.Before

View file

@ -14,15 +14,14 @@ import androidx.test.espresso.matcher.ViewMatchers
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
import androidx.test.espresso.matcher.ViewMatchers.withId
import org.fnives.test.showcase.R
import org.fnives.test.showcase.testutils.configuration.SnackbarVerificationHelper
import org.fnives.test.showcase.testutils.viewactions.ReplaceProgressBarDrawableToStatic
import org.fnives.test.showcase.testutils.viewactions.notIntended
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.ui.home.MainActivity
import org.hamcrest.core.IsNot.not
class LoginRobot(
private val snackbarVerificationHelper: SnackbarVerificationHelper = SnackbarVerificationHelper()
) {
class LoginRobot {
fun setupIntentResults() {
Intents.intending(hasComponent(MainActivity::class.java.canonicalName))
@ -68,7 +67,7 @@ class LoginRobot(
}
fun assertErrorIsShown(@StringRes stringResID: Int) = apply {
snackbarVerificationHelper.assertIsShownWithText(stringResID)
assertSnackBarIsShownWithText(stringResID)
}
fun assertLoadingBeforeRequests() = apply {
@ -82,7 +81,7 @@ class LoginRobot(
}
fun assertErrorIsNotShown() = apply {
snackbarVerificationHelper.assertIsNotShown()
assertSnackBarIsNotShown()
}
fun assertNavigatedToHome() = apply {

View file

@ -8,14 +8,13 @@ import androidx.test.espresso.intent.Intents
import androidx.test.espresso.intent.matcher.IntentMatchers
import androidx.test.espresso.matcher.ViewMatchers
import org.fnives.test.showcase.R
import org.fnives.test.showcase.testutils.configuration.SnackbarVerificationHelper
import org.fnives.test.showcase.testutils.viewactions.notIntended
import org.fnives.test.showcase.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.ui.home.MainActivity
import org.hamcrest.core.IsNot
class CodeKataSharedRobotTest(
private val snackbarVerificationHelper: SnackbarVerificationHelper = SnackbarVerificationHelper()
) {
class CodeKataSharedRobotTest {
fun setUsername(username: String): CodeKataSharedRobotTest = apply {
Espresso.onView(ViewMatchers.withId(R.id.user_edit_text))
@ -53,11 +52,11 @@ class CodeKataSharedRobotTest(
}
fun assertErrorIsShown(@StringRes stringResID: Int): CodeKataSharedRobotTest = apply {
snackbarVerificationHelper.assertIsShownWithText(stringResID)
assertSnackBarIsShownWithText(stringResID)
}
fun assertErrorIsNotShown(): CodeKataSharedRobotTest = apply {
snackbarVerificationHelper.assertIsNotShown()
assertSnackBarIsNotShown()
}
fun assertNavigatedToHome(): CodeKataSharedRobotTest = apply {

View file

@ -4,9 +4,9 @@ import androidx.lifecycle.Lifecycle
import androidx.test.core.app.ActivityScenario
import androidx.test.espresso.intent.Intents
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.fnives.test.showcase.android.testutil.activity.safeClose
import org.fnives.test.showcase.testutils.MockServerScenarioSetupResetingTestRule
import org.fnives.test.showcase.testutils.idling.MainDispatcherTestRule
import org.fnives.test.showcase.testutils.safeClose
import org.fnives.test.showcase.testutils.statesetup.SetupAuthenticationState.setupLogin
import org.fnives.test.showcase.testutils.statesetup.SetupAuthenticationState.setupLogout
import org.junit.After

View file

@ -5,7 +5,7 @@ import android.app.Instrumentation
import android.content.Intent
import androidx.test.espresso.intent.Intents
import androidx.test.espresso.intent.matcher.IntentMatchers
import org.fnives.test.showcase.testutils.viewactions.notIntended
import org.fnives.test.showcase.android.testutil.intent.notIntended
import org.fnives.test.showcase.ui.auth.AuthActivity
import org.fnives.test.showcase.ui.home.MainActivity

View file

@ -1,8 +1,8 @@
package org.fnives.test.showcase.di
import android.content.Context
import org.fnives.test.showcase.android.testutil.StandardTestMainDispatcher
import org.fnives.test.showcase.model.network.BaseUrl
import org.fnives.test.showcase.testutils.TestMainDispatcher
import org.fnives.test.showcase.ui.auth.AuthViewModel
import org.fnives.test.showcase.ui.home.MainViewModel
import org.fnives.test.showcase.ui.splash.SplashViewModel
@ -20,7 +20,7 @@ import org.mockito.kotlin.doReturn
import org.mockito.kotlin.mock
import org.mockito.kotlin.whenever
@ExtendWith(TestMainDispatcher::class)
@ExtendWith(StandardTestMainDispatcher::class)
class DITest : KoinTest {
private val authViewModel by inject<AuthViewModel>()

View file

@ -1,39 +0,0 @@
package org.fnives.test.showcase.testutils
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
import org.fnives.test.showcase.testutils.TestMainDispatcher.Companion.testDispatcher
import org.junit.jupiter.api.extension.AfterEachCallback
import org.junit.jupiter.api.extension.BeforeEachCallback
import org.junit.jupiter.api.extension.ExtensionContext
/**
* Custom Junit5 Extension which replaces the main dispatcher with a [TestDispatcher]
*
* One can access the test dispatcher via [testDispatcher] static getter.
*/
@OptIn(ExperimentalCoroutinesApi::class)
class TestMainDispatcher : BeforeEachCallback, AfterEachCallback {
override fun beforeEach(context: ExtensionContext?) {
val testDispatcher = StandardTestDispatcher()
privateTestDispatcher = testDispatcher
Dispatchers.setMain(testDispatcher)
}
override fun afterEach(context: ExtensionContext?) {
Dispatchers.resetMain()
privateTestDispatcher = null
}
companion object {
private var privateTestDispatcher: TestDispatcher? = null
val testDispatcher: TestDispatcher
get() = privateTestDispatcher
?: throw IllegalStateException("TestMainDispatcher is in afterEach State")
}
}

View file

@ -3,12 +3,12 @@ package org.fnives.test.showcase.ui.auth
import com.jraska.livedata.test
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.runBlocking
import org.fnives.test.showcase.android.testutil.InstantExecutorExtension
import org.fnives.test.showcase.android.testutil.StandardTestMainDispatcher
import org.fnives.test.showcase.core.login.LoginUseCase
import org.fnives.test.showcase.model.auth.LoginCredentials
import org.fnives.test.showcase.model.auth.LoginStatus
import org.fnives.test.showcase.model.shared.Answer
import org.fnives.test.showcase.testutils.InstantExecutorExtension
import org.fnives.test.showcase.testutils.TestMainDispatcher
import org.fnives.test.showcase.ui.shared.Event
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.DisplayName
@ -27,13 +27,13 @@ import org.mockito.kotlin.whenever
import java.util.stream.Stream
@Suppress("TestFunctionName")
@ExtendWith(InstantExecutorExtension::class, TestMainDispatcher::class)
@ExtendWith(InstantExecutorExtension::class, StandardTestMainDispatcher::class)
@OptIn(ExperimentalCoroutinesApi::class)
internal class AuthViewModelTest {
private lateinit var sut: AuthViewModel
private lateinit var mockLoginUseCase: LoginUseCase
private val testScheduler get() = TestMainDispatcher.testDispatcher.scheduler
private val testScheduler get() = StandardTestMainDispatcher.testDispatcher.scheduler
@BeforeEach
fun setUp() {
@ -169,7 +169,7 @@ internal class AuthViewModelTest {
@ParameterizedTest(name = "GIVEN answer success loginStatus {0} WHEN login called THEN error {1} is shown")
fun invalidStatusResultsInErrorState(
loginStatus: LoginStatus,
errorType: AuthViewModel.ErrorType
errorType: AuthViewModel.ErrorType,
) {
runBlocking {
whenever(mockLoginUseCase.invoke(anyOrNull())).doReturn(Answer.Success(loginStatus))

View file

@ -1,22 +1,22 @@
package org.fnives.test.showcase.ui.auth
import kotlinx.coroutines.ExperimentalCoroutinesApi
import org.fnives.test.showcase.android.testutil.InstantExecutorExtension
import org.fnives.test.showcase.android.testutil.StandardTestMainDispatcher
import org.fnives.test.showcase.core.login.LoginUseCase
import org.fnives.test.showcase.testutils.InstantExecutorExtension
import org.fnives.test.showcase.testutils.TestMainDispatcher
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.ExtendWith
import org.mockito.kotlin.mock
@ExtendWith(InstantExecutorExtension::class, TestMainDispatcher::class)
@ExtendWith(InstantExecutorExtension::class, StandardTestMainDispatcher::class)
@OptIn(ExperimentalCoroutinesApi::class)
class CodeKataAuthViewModel {
private lateinit var sut: AuthViewModel
private lateinit var mockLoginUseCase: LoginUseCase
private val testScheduler get() = TestMainDispatcher.testDispatcher.scheduler
private val testScheduler get() = StandardTestMainDispatcher.testDispatcher.scheduler
@BeforeEach
fun setUp() {

View file

@ -4,6 +4,8 @@ import com.jraska.livedata.test
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.runBlocking
import org.fnives.test.showcase.android.testutil.InstantExecutorExtension
import org.fnives.test.showcase.android.testutil.StandardTestMainDispatcher
import org.fnives.test.showcase.core.content.AddContentToFavouriteUseCase
import org.fnives.test.showcase.core.content.FetchContentUseCase
import org.fnives.test.showcase.core.content.GetAllContentUseCase
@ -14,22 +16,20 @@ import org.fnives.test.showcase.model.content.ContentId
import org.fnives.test.showcase.model.content.FavouriteContent
import org.fnives.test.showcase.model.content.ImageUrl
import org.fnives.test.showcase.model.shared.Resource
import org.fnives.test.showcase.testutils.InstantExecutorExtension
import org.fnives.test.showcase.testutils.TestMainDispatcher
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.ExtendWith
import org.mockito.Mockito.verifyNoInteractions
import org.mockito.kotlin.doReturn
import org.mockito.kotlin.mock
import org.mockito.kotlin.times
import org.mockito.kotlin.verify
import org.mockito.kotlin.verifyNoInteractions
import org.mockito.kotlin.verifyNoMoreInteractions
import org.mockito.kotlin.whenever
@Suppress("TestFunctionName")
@ExtendWith(InstantExecutorExtension::class, TestMainDispatcher::class)
@ExtendWith(InstantExecutorExtension::class, StandardTestMainDispatcher::class)
@OptIn(ExperimentalCoroutinesApi::class)
internal class MainViewModelTest {
@ -39,7 +39,7 @@ internal class MainViewModelTest {
private lateinit var mockFetchContentUseCase: FetchContentUseCase
private lateinit var mockAddContentToFavouriteUseCase: AddContentToFavouriteUseCase
private lateinit var mockRemoveContentFromFavouritesUseCase: RemoveContentFromFavouritesUseCase
private val testScheduler get() = TestMainDispatcher.testDispatcher.scheduler
private val testScheduler get() = StandardTestMainDispatcher.testDispatcher.scheduler
@BeforeEach
fun setUp() {

View file

@ -2,9 +2,9 @@ package org.fnives.test.showcase.ui.splash
import com.jraska.livedata.test
import kotlinx.coroutines.ExperimentalCoroutinesApi
import org.fnives.test.showcase.android.testutil.InstantExecutorExtension
import org.fnives.test.showcase.android.testutil.StandardTestMainDispatcher
import org.fnives.test.showcase.core.login.IsUserLoggedInUseCase
import org.fnives.test.showcase.testutils.InstantExecutorExtension
import org.fnives.test.showcase.testutils.TestMainDispatcher
import org.fnives.test.showcase.ui.shared.Event
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.DisplayName
@ -14,13 +14,13 @@ import org.mockito.kotlin.doReturn
import org.mockito.kotlin.mock
import org.mockito.kotlin.whenever
@ExtendWith(InstantExecutorExtension::class, TestMainDispatcher::class)
@ExtendWith(InstantExecutorExtension::class, StandardTestMainDispatcher::class)
@OptIn(ExperimentalCoroutinesApi::class)
internal class SplashViewModelTest {
private lateinit var mockIsUserLoggedInUseCase: IsUserLoggedInUseCase
private lateinit var sut: SplashViewModel
private val testScheduler get() = TestMainDispatcher.testDispatcher.scheduler
private val testScheduler get() = StandardTestMainDispatcher.testDispatcher.scheduler
@BeforeEach
fun setUp() {

View file

@ -22,6 +22,14 @@ allprojects {
repositories {
mavenCentral()
google()
maven {
url "https://maven.pkg.github.com/fknives/AndroidTest-ShowCase"
credentials {
username = project.findProperty("GITHUB_USERNAME") ?: System.getenv("GITHUB_USERNAME")
password = project.findProperty("GITHUB_TOKEN") ?: System.getenv("GITHUB_TOKEN")
}
// https://docs.github.com/en/github/authenticating-to-github/keeping-your-account-and-data-secure/creating-a-personal-access-token
}
}
}
@ -34,3 +42,5 @@ apply from: 'gradlescripts/detekt.config.gradle'
apply from: 'gradlescripts/ktlint.gradle'
apply from: 'gradlescripts/lint.gradle'
apply from: 'gradlescripts/testoptions.gradle'
apply from: 'gradlescripts/test.tasks.gradle'
apply from: 'gradlescripts/testdependencies.gradle'

View file

@ -25,12 +25,12 @@ Our test class is `org.fnives.test.showcase.ui.splash.CodeKataSplashViewModelTes
To properly test LiveData we need to make them instant, meaning as soon as the value is set the observers are updated. To Do this we can use a `InstantExecutorExtension`.
Also We need to set MainDispatcher as TestDispatcher, for this we can use the `TestMainDispatcher` Extension.
Also We need to set MainDispatcher as TestDispatcher, for this we can use the `StandardTestMainDispatcher` Extension.
To add this to our TestClass we need to do the following:
```kotlin
@ExtendWith(InstantExecutorExtension::class, TestMainDispatcher::class)
@ExtendWith(InstantExecutorExtension::class, StandardTestMainDispatcher::class)
class CodeKataSplashViewModelTest {
```
@ -41,7 +41,7 @@ Next let's set up our System Under Test as usual:
```kotlin
private lateinit var mockIsUserLoggedInUseCase: IsUserLoggedInUseCase
private lateinit var sut: SplashViewModel
private val testScheduler get() = TestMainDispatcher.testDispatcher.scheduler // just a shortcut
private val testScheduler get() = StandardTestMainDispatcher.testDispatcher.scheduler // just a shortcut
@BeforeEach
fun setUp() {
@ -69,7 +69,7 @@ val navigateToTestObserver = sut.navigateTo.test()
Since the action takes place in the ViewModel constructor, instead of additional calls, we need to simulate that time has elapsed.
Note: the `TestMainDispatcher` Extension we are using sets `StandardTestDispatcher` as the dispatcher for `Dispatcher.Main`, that's why our test is linear and not shaky.
Note: the `StandardTestMainDispatcher` Extension we are using sets `StandardTestDispatcher` as the dispatcher for `Dispatcher.Main`, that's why our test is linear and not shaky.
```kotlin
testScheduler.advanceTimeBy(501)

View file

@ -20,15 +20,7 @@ dependencies {
api project(":model")
implementation project(":network")
testImplementation "io.insert-koin:koin-test-junit5:$koin_version"
testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutines_version"
testImplementation "org.mockito.kotlin:mockito-kotlin:$testing_kotlin_mockito_version"
testImplementation "org.junit.jupiter:junit-jupiter-engine:$testing_junit5_version"
testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine:$testing_junit5_version"
testImplementation "com.squareup.retrofit2:retrofit:$retrofit_version"
testImplementation "app.cash.turbine:turbine:$turbine_version"
testImplementation "org.junit.jupiter:junit-jupiter-params:$testing_junit5_version"
applyCoreTestDependenciesTo(this)
testImplementation project(':mockserver')
testFixturesApi testFixtures(project(':network'))

View file

@ -19,3 +19,4 @@ android.useAndroidX=true
android.enableJetifier=false
# Kotlin code style for this project: "official" or "obsolete":
kotlin.code.style=official
android.disableAutomaticComponentCreation=true

View file

@ -0,0 +1,44 @@
apply plugin: "maven-publish"
def testUtilVersion = "1.0.1"
if (!extensions.extraProperties.has("artifactId")) {
throw IllegalStateException("ext.artifactId is not set while applying deploy script")
}
def testUtilGroupId = "org.fnives.android.testutil"
def testUtilArtifactId = extensions.extraProperties.get("artifactId")
task publishToGitHub(dependsOn: "publishMavenAarPublicationToGitHubPackagesRepository") {
group = "Publishing"
}
task sourcesJar(type: Jar) {
from android.sourceSets.main.java.srcDirs
classifier "sources"
}
afterEvaluate {
publishing {
publications {
mavenAar(MavenPublication) {
from components.release
groupId "$testUtilGroupId"
println("$testUtilArtifactId")
version "$testUtilVersion"
artifactId "$testUtilArtifactId"
artifact sourcesJar
}
}
repositories {
maven {
name = "GitHubPackages"
url = uri("https://maven.pkg.github.com/fknives/AndroidTest-ShowCase")
credentials {
username = System.getenv("GITHUB_USERNAME")
password = System.getenv("GITHUB_TOKEN")
}
}
}
}
}

View file

@ -0,0 +1,16 @@
task jvmTests(dependsOn: ["app:testDebugUnitTest", "core:test", "network:test"]) {
group = 'Tests'
description = 'Run all JVM tests'
}
task robolectricTests(type: Exec) {
group = 'Tests'
description = 'Run all Robolectric tests based on the Instrumented naming convention'
// todo is there a better way?
commandLine 'sh', './gradlew', 'testDebugUnitTest', '--tests', 'org.fnives.test.*InstrumentedTest'
}
task androidTests(dependsOn: ["app:connectedDebugAndroidTest"]) {
group = 'Tests'
description = 'Run Android tests'
}

View file

@ -0,0 +1,148 @@
project.ext {
/*
------------------USAGE------------------
add line in root build.gradle:
apply from: 'gradlescripts/testdependencies.gradle' (this file)
then in your modules you can use:
------------------NETWORK(Java Module)------------------
dependencies {
applyNetworkTestDependenciesTo(this)
}
------------------CORE(Java Module)------------------
dependencies {
applyCoreTestDependenciesTo(this)
}
------------------APP(Android Module)------------------
dependencies {
applyAppTestDependenciesTo(this)
applyComposeTestDependenciesTo(this) // if you are using compose
}
------------------VERSIONS------------------
versions try to get the global value, if not found they fall back to some defaults.
You can find them just below
*/
def propertyOrNull = { key ->
if (extensions.extraProperties.has(key)) {
return extensions.extraProperties.get(key)
} else {
return null
}
}
// ------------------VERSIONS------------------
def testing_junit5_version = propertyOrNull('junit5_version') ?: "5.7.0"
def testing_junit4_version = propertyOrNull('junit4_version') ?: "4.13.2"
def testing_robolectric_version = propertyOrNull('robolectric_version') ?: "4.7"
def testing_androidx_code_version = propertyOrNull('androidx_test_version') ?: "1.4.0"
def testing_androidx_junit_version = propertyOrNull('androidx_junit_version') ?: "1.1.3"
def testing_espresso_version = propertyOrNull('espresso_version') ?: "3.4.0"
def testing_androidx_arch_core_version = propertyOrNull('arch_core_version') ?: "2.1.0"
def test_coroutines_version = propertyOrNull('coroutines_version') ?: "1.6.0"
def testing_kotlin_mockito_version = propertyOrNull('mockito_version') ?: "4.0.0"
def testing_koin_version = propertyOrNull('koin_version') ?: "3.1.2"
def testing_json_assert_version = propertyOrNull('json_assert_version') ?: "1.5.0"
def testing_okhttp3 = propertyOrNull('okhttp_version') ?: "4.9.3"
def testing_turbine_version = propertyOrNull('turbine_version') ?: "0.7.0"
def testing_androidx_room_version = propertyOrNull('room_version') ?: "2.4.2"
def testing_livedata_version = propertyOrNull('testing_livedata_version') ?: "1.2.0"
def testing_compose_version = propertyOrNull('androidx_compose_version') ?: "1.1.0"
def testing_hamcrest_version = propertyOrNull('hamcrest_version') ?: "2.2"
// ------------------PRIVATE------------------
// JUni4 + Espresso + Room
def androidSpecificTestDependencies = [
"junit:junit:$testing_junit4_version",
"androidx.room:room-testing:$testing_androidx_room_version",
"com.jraska.livedata:testing-ktx:$testing_livedata_version",
"androidx.test:core:$testing_androidx_code_version",
"androidx.test:runner:$testing_androidx_code_version",
"androidx.test.ext:junit:$testing_androidx_junit_version",
"androidx.test.espresso:espresso-core:$testing_espresso_version",
"androidx.test.espresso:espresso-intents:$testing_espresso_version",
"androidx.test.espresso:espresso-contrib:$testing_espresso_version",
"org.hamcrest:hamcrest:$testing_hamcrest_version",
"androidx.arch.core:core-testing:$testing_androidx_arch_core_version",
]
// ------------------PRIVATE------------------
def applyStandardTestDependenciesTo = { module ->
module.dependencies {
// coroutine testing
testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$test_coroutines_version"
// mockito, mocking library
testImplementation "org.mockito.kotlin:mockito-kotlin:$testing_kotlin_mockito_version"
testImplementation "io.insert-koin:koin-test-junit5:$testing_koin_version"
// junit5
testImplementation "org.junit.jupiter:junit-jupiter-engine:$testing_junit5_version"
testImplementation "org.junit.jupiter:junit-jupiter-params:$testing_junit5_version"
testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine:$testing_junit5_version"
}
}
// ------------------NETWORK------------------
applyNetworkTestDependenciesTo = { module ->
applyStandardTestDependenciesTo(module)
module.dependencies {
testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine:$testing_junit5_version"
// JSON Assert
testImplementation "org.skyscreamer:jsonassert:$testing_json_assert_version"
// mockwebserver + https support
testImplementation "com.squareup.okhttp3:mockwebserver:$testing_okhttp3"
testImplementation "com.squareup.okhttp3:okhttp-tls:$testing_okhttp3"
}
}
// ------------------CORE------------------
applyCoreTestDependenciesTo = { module ->
applyStandardTestDependenciesTo(module)
module.dependencies {
// turbine, flow testing
testImplementation "app.cash.turbine:turbine:$testing_turbine_version"
}
}
// ------------------APP------------------
applyAppTestDependenciesTo = { module ->
applyStandardTestDependenciesTo(module)
module.dependencies {
testImplementation "org.robolectric:robolectric:$testing_robolectric_version"
testRuntimeOnly "org.junit.vintage:junit-vintage-engine:$testing_junit5_version"
androidSpecificTestDependencies.forEach { dependency ->
testImplementation dependency
androidTestImplementation dependency
}
androidTestImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$test_coroutines_version"
androidTestImplementation "io.insert-koin:koin-test-junit5:$testing_koin_version"
androidTestRuntimeOnly "org.junit.vintage:junit-vintage-engine:$testing_junit5_version"
}
}
// ------------------COMPOSE------------------
applyComposeTestDependenciesTo = { module ->
module.dependencies {
testImplementation "androidx.compose.ui:ui-test-junit4:$testing_compose_version"
androidTestImplementation "androidx.compose.ui:ui-test-junit4:$testing_compose_version"
debugImplementation "androidx.compose.ui:ui-test-manifest:$testing_compose_version"
}
}
}

View file

@ -63,20 +63,3 @@ subprojects { module ->
}
}
}
task jvmTests(dependsOn: ["app:testDebugUnitTest", "core:test", "network:test"]) {
group = 'Tests'
description = 'Run all JVM tests'
}
task robolectricTests(type: Exec) {
group = 'Tests'
description = 'Run all Robolectric tests based on the Instrumented naming convention'
// todo is there a better way?
commandLine 'sh', './gradlew', 'testDebugUnitTest', '--tests', 'org.fnives.test.*InstrumentedTest'
}
task androidTests(dependsOn: ["app:connectedDebugAndroidTest"]) {
group = 'Tests'
description = 'Run Android tests'
}

View file

@ -1,11 +1,11 @@
project.ext {
androidx_core_version = "1.7.0"
androidx_core_version = "1.8.0"
androidx_appcompat_version = "1.4.1"
androidx_material_version = "1.5.0"
androidx_material_version = "1.6.1"
androidx_constraintlayout_version = "2.1.3"
androidx_livedata_version = "2.4.0"
androidx_swiperefreshlayout_version = "1.1.0"
androidx_room_version = "2.4.1"
room_version = "2.4.2"
activity_ktx_version = "1.4.0"
androidx_navigation = "2.4.0"
@ -14,21 +14,23 @@ project.ext {
androidx_compose_constraintlayout_version = "1.0.0"
coroutines_version = "1.6.0"
turbine_version = "0.7.0"
koin_version = "3.1.2"
coil_version = "1.4.0"
retrofit_version = "2.9.0"
okhttp_version = "4.9.3"
moshi_version = "1.13.0"
testing_androidx_code_version = "1.4.0"
testing_androidx_junit_version = "1.1.3"
testing_androidx_arch_core_version = "2.1.0"
// testing versions
turbine_version = "0.7.0"
androidx_test_version = "1.4.0"
androidx_junit_version = "1.1.3"
arch_core_version = "2.1.0"
testing_livedata_version = "1.2.0"
testing_kotlin_mockito_version = "4.0.0"
testing_junit5_version = "5.7.0"
testing_json_assert_version = "1.5.0"
testing_junit4_version = "4.13.2"
testing_robolectric_version = "4.7"
testing_espresso_version = "3.4.0"
mockito_version = "4.0.0"
junit5_version = "5.7.0"
json_assert_version = "1.5.0"
junit4_version = "4.13.2"
robolectric_version = "4.7"
espresso_version = "3.4.0"
hamcrest_version = "2.2"
}

View file

@ -14,7 +14,7 @@ dependencies {
api "com.squareup.okhttp3:mockwebserver:$okhttp_version"
api "com.squareup.okhttp3:okhttp-tls:$okhttp_version"
implementation "org.skyscreamer:jsonassert:$testing_json_assert_version"
implementation "junit:junit:$testing_junit4_version"
implementation "org.skyscreamer:jsonassert:$json_assert_version"
implementation "junit:junit:$junit4_version"
}

View file

@ -24,12 +24,9 @@ dependencies {
api project(":model")
testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutines_version"
testImplementation "org.mockito.kotlin:mockito-kotlin:$testing_kotlin_mockito_version"
testImplementation "org.skyscreamer:jsonassert:$testing_json_assert_version"
testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine:$testing_junit5_version"
applyNetworkTestDependenciesTo(this)
testFixturesApi project(':mockserver')
testFixturesApi "org.junit.jupiter:junit-jupiter-engine:$testing_junit5_version"
testFixturesApi "org.junit.jupiter:junit-jupiter-engine:$junit5_version"
testFixturesApi "io.insert-koin:koin-test-junit5:$koin_version"
}

View file

@ -1,6 +1,10 @@
rootProject.name = "TestShowCase"
include ':mockserver'
include ':model'
include ':core'
include ':network'
include ':app'
rootProject.name = "TestShowCase"
include ':test-util-shared-android'
include ':test-util-shared-robolectric'
include ':test-util-android'
include ':test-util-junit5-android'

1
test-util-android/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/build

View file

@ -0,0 +1,56 @@
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'
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = '1.8'
}
buildFeatures {
buildConfig = false
}
packagingOptions {
exclude 'META-INF/LGPL2.1'
exclude 'META-INF/AL2.0'
exclude 'META-INF/LICENSE.md'
exclude 'META-INF/LICENSE-notice.md'
}
}
dependencies {
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutines_version"
implementation "androidx.test:core:$androidx_test_version"
implementation "androidx.test.espresso:espresso-core:$espresso_version"
implementation "androidx.test.espresso:espresso-intents:$espresso_version"
implementation "com.squareup.okhttp3:okhttp:$okhttp_version"
implementation "com.google.android.material:material:$androidx_material_version"
implementation "androidx.swiperefreshlayout:swiperefreshlayout:$androidx_swiperefreshlayout_version"
implementation "androidx.core:core-ktx:$androidx_core_version"
}
ext.artifactId = "android"
apply from: "../gradlescripts/deploy.aar.gradle"

View file

21
test-util-android/proguard-rules.pro vendored Normal file
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.android.testutil">
</manifest>

View file

@ -1,19 +1,24 @@
package org.fnives.test.showcase.testutils
package org.fnives.test.showcase.android.testutil.activity
import android.app.Activity
import androidx.test.core.app.ActivityScenario
/**
* Workaround for issue: https://github.com/android/android-test/issues/676.
*
* Call this instead of ActivityScenario.close().
*/
fun <T : Activity> ActivityScenario<T>.safeClose() {
workaroundForActivityScenarioCLoseLockingUp()
close()
}
/**
* This should not be needed, we shouldn't use sleep ever.
* This should not be needed, we shouldn't use sleep basically ever.
* However, it seems to be and issue described here: https://github.com/android/android-test/issues/676
*
* If an activity is finished in code, the ActivityScenario.close() can hang 30 to 45 seconds.
* This sleeps let's the Activity finish it state change and unlocks the ActivityScenario.
* This sleep let's the Activity finish it's state change and unlocks the ActivityScenario.
*
* As soon as that issue is closed, this should be removed as well.
*/

View file

@ -1,10 +1,11 @@
package org.fnives.test.showcase.testutils.viewactions
package org.fnives.test.showcase.android.testutil.intent
import android.content.Intent
import androidx.test.espresso.intent.Intents.intended
import org.hamcrest.Matcher
import org.hamcrest.StringDescription
@Suppress("SwallowedException")
fun notIntended(matcher: Matcher<Intent>) {
try {
intended(matcher)

View file

@ -1,5 +1,6 @@
package org.fnives.test.showcase.testutils.configuration
package org.fnives.test.showcase.android.testutil.snackbar
import android.annotation.SuppressLint
import android.view.View
import androidx.annotation.StringRes
import androidx.test.espresso.Espresso
@ -8,22 +9,25 @@ import androidx.test.espresso.ViewAction
import androidx.test.espresso.action.ViewActions
import androidx.test.espresso.assertion.ViewAssertions
import androidx.test.espresso.matcher.ViewMatchers
import com.google.android.material.R
import com.google.android.material.snackbar.Snackbar
import org.hamcrest.Matcher
import org.hamcrest.Matchers
import com.google.android.material.R as MaterialR
class SnackbarVerificationHelper {
object SnackbarVerificationHelper {
fun assertIsShownWithText(@StringRes stringResID: Int) {
Espresso.onView(ViewMatchers.withId(R.id.snackbar_text))
@SuppressLint("RestrictedApi")
fun assertSnackBarIsShownWithText(@StringRes stringResID: Int, doDismiss: Boolean = true) {
Espresso.onView(ViewMatchers.withId(MaterialR.id.snackbar_text))
.check(ViewAssertions.matches(ViewMatchers.withText(stringResID)))
if (doDismiss) {
Espresso.onView(ViewMatchers.isAssignableFrom(Snackbar.SnackbarLayout::class.java)).perform(ViewActions.swipeRight())
Espresso.onView(ViewMatchers.isRoot()).perform(LoopMainUntilSnackbarDismissed())
}
}
fun assertIsNotShown() {
Espresso.onView(ViewMatchers.withId(R.id.snackbar_text)).check(ViewAssertions.doesNotExist())
fun assertSnackBarIsNotShown() {
Espresso.onView(ViewMatchers.withId(MaterialR.id.snackbar_text)).check(ViewAssertions.doesNotExist())
}
class LoopMainUntilSnackbarDismissed : ViewAction {

View file

@ -0,0 +1,64 @@
package org.fnives.test.showcase.android.testutil.synchronization
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestDispatcher
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
import org.fnives.test.showcase.android.testutil.synchronization.idlingresources.anyResourceIdling
import org.fnives.test.showcase.android.testutil.synchronization.idlingresources.awaitIdlingResources
import org.junit.rules.TestRule
import org.junit.runner.Description
import org.junit.runners.model.Statement
@OptIn(ExperimentalCoroutinesApi::class)
open class MainDispatcherTestRule(private val useStandard: Boolean = true) : TestRule {
private lateinit var testDispatcher: TestDispatcher
override fun apply(base: Statement, description: Description): Statement =
object : Statement() {
@Throws(Throwable::class)
override fun evaluate() {
val dispatcher = if (useStandard) StandardTestDispatcher() else UnconfinedTestDispatcher()
Dispatchers.setMain(dispatcher)
testDispatcher = dispatcher
onTestDispatcherInitialized(testDispatcher)
try {
base.evaluate()
} finally {
Dispatchers.resetMain()
onTestDispatcherReset()
}
}
}
open fun onTestDispatcherInitialized(testDispatcher: TestDispatcher) = Unit
open fun onTestDispatcherReset() = Unit
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 (anyResourceIdling()) { // 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

@ -1,4 +1,4 @@
package org.fnives.test.showcase.testutils.idling
package org.fnives.test.showcase.android.testutil.synchronization.idlingresources
class CompositeDisposable(disposable: List<Disposable> = emptyList()) : Disposable {

View file

@ -0,0 +1,6 @@
package org.fnives.test.showcase.android.testutil.synchronization.idlingresources
interface Disposable {
val isDisposed: Boolean
fun dispose()
}

View file

@ -1,9 +1,9 @@
package org.fnives.test.showcase.testutils.idling
package org.fnives.test.showcase.android.testutil.synchronization.idlingresources
import androidx.test.espresso.IdlingRegistry
import androidx.test.espresso.IdlingResource
internal class IdlingResourceDisposable(private val idlingResource: IdlingResource) : Disposable {
class IdlingResourceDisposable(private val idlingResource: IdlingResource) : Disposable {
override var isDisposed: Boolean = false
private set

View file

@ -1,12 +1,8 @@
package org.fnives.test.showcase.testutils.idling
package org.fnives.test.showcase.android.testutil.synchronization.idlingresources
import android.os.Looper
import androidx.test.espresso.Espresso
import androidx.test.espresso.IdlingRegistry
import androidx.test.espresso.IdlingResource
import androidx.test.espresso.matcher.ViewMatchers
import org.fnives.test.showcase.testutils.viewactions.LoopMainThreadFor
import org.fnives.test.showcase.testutils.viewactions.LoopMainThreadUntilIdle
import org.fnives.test.showcase.android.testutil.synchronization.loopMainThreadFor
import java.util.concurrent.Executors
// workaround, issue with idlingResources is tracked here https://github.com/robolectric/robolectric/issues/4807
@ -41,20 +37,3 @@ private fun IdlingResource.awaitUntilIdle() {
Thread.sleep(100L)
}
}
fun loopMainThreadUntilIdleWithIdlingResources() {
Espresso.onView(ViewMatchers.isRoot()).perform(LoopMainThreadUntilIdle()) // advance until a request is sent
while (anyResourceIdling()) { // check if any request is in progress
awaitIdlingResources() // complete all requests and other idling resources
Espresso.onView(ViewMatchers.isRoot()).perform(LoopMainThreadUntilIdle()) // run coroutines after request is finished
}
Espresso.onView(ViewMatchers.isRoot()).perform(LoopMainThreadUntilIdle())
}
fun loopMainThreadFor(delay: Long) {
if (Looper.getMainLooper().isCurrentThread) {
Thread.sleep(200L)
} else {
Espresso.onView(ViewMatchers.isRoot()).perform(LoopMainThreadFor(delay))
}
}

View file

@ -1,4 +1,4 @@
package org.fnives.test.showcase.testutils.idling
package org.fnives.test.showcase.android.testutil.synchronization.idlingresources
import androidx.annotation.CheckResult
import androidx.annotation.NonNull
@ -40,7 +40,7 @@ class OkHttp3IdlingResource private constructor(
* this instance using `Espresso.registerIdlingResources`.
*/
@CheckResult
@NonNull // Extra guards as a library.
@NonNull
fun create(@NonNull name: String?, @NonNull client: OkHttpClient?): OkHttp3IdlingResource {
if (name == null) throw NullPointerException("name == null")
if (client == null) throw NullPointerException("client == null")

View file

@ -0,0 +1,35 @@
package org.fnives.test.showcase.android.testutil.synchronization
import android.os.Handler
import android.os.Looper
import androidx.test.espresso.Espresso
import androidx.test.espresso.matcher.ViewMatchers
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.runBlocking
import org.fnives.test.showcase.android.testutil.viewaction.LoopMainThreadFor
/**
* Runs the given action on the MainThread and blocks currentThread, until it is completed.
*
* It is safe to call this from the MainThread.
*/
fun runOnUIAwaitOnCurrent(action: () -> Unit) {
if (Looper.myLooper() === Looper.getMainLooper()) {
action()
} else {
val deferred = CompletableDeferred<Unit>()
Handler(Looper.getMainLooper()).post {
action()
deferred.complete(Unit)
}
runBlocking { deferred.await() }
}
}
fun loopMainThreadFor(delay: Long) {
if (Looper.getMainLooper().thread == Thread.currentThread()) {
Thread.sleep(200L)
} else {
Espresso.onView(ViewMatchers.isRoot()).perform(LoopMainThreadFor(delay))
}
}

View file

@ -1,4 +1,4 @@
package org.fnives.test.showcase.testutils.viewactions
package org.fnives.test.showcase.android.testutil.viewaction
import android.view.View
import androidx.test.espresso.UiController
@ -15,13 +15,3 @@ class LoopMainThreadFor(private val delayInMillis: Long) : ViewAction {
uiController.loopMainThreadForAtLeast(delayInMillis)
}
}
class LoopMainThreadUntilIdle : ViewAction {
override fun getConstraints(): Matcher<View> = Matchers.isA(View::class.java)
override fun getDescription(): String = "loop MainThread for until Idle"
override fun perform(uiController: UiController, view: View?) {
uiController.loopMainThreadUntilIdle()
}
}

View file

@ -0,0 +1,17 @@
package org.fnives.test.showcase.android.testutil.viewaction
import android.view.View
import androidx.test.espresso.UiController
import androidx.test.espresso.ViewAction
import org.hamcrest.Matcher
import org.hamcrest.Matchers
class LoopMainThreadUntilIdle : ViewAction {
override fun getConstraints(): Matcher<View> = Matchers.isA(View::class.java)
override fun getDescription(): String = "loop MainThread for until Idle"
override fun perform(uiController: UiController, view: View?) {
uiController.loopMainThreadUntilIdle()
}
}

View file

@ -1,4 +1,4 @@
package org.fnives.test.showcase.testutils.viewactions
package org.fnives.test.showcase.android.testutil.viewaction.imageview
import android.content.res.ColorStateList
import android.graphics.PorterDuff
@ -26,7 +26,7 @@ class WithDrawable(
override fun matchesSafely(view: View): Boolean {
val context = view.context
val tintColor = tint?.let { ContextCompat.getColor(view.context, it) }
val expectedBitmap = context.getDrawable(id)?.apply {
val expectedBitmap = ContextCompat.getDrawable(context, id)?.apply {
if (tintColor != null) {
setTintList(ColorStateList.valueOf(tintColor))
setTintMode(tintMode)

View file

@ -1,4 +1,4 @@
package org.fnives.test.showcase.testutils.viewactions
package org.fnives.test.showcase.android.testutil.viewaction.progressbar
import android.graphics.Color
import android.graphics.drawable.ColorDrawable

View file

@ -1,11 +1,11 @@
package org.fnives.test.showcase.testutils.viewactions
package org.fnives.test.showcase.android.testutil.viewaction.swiperefresh
import android.view.View
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
import androidx.swiperefreshlayout.widget.listener
import androidx.test.espresso.UiController
import androidx.test.espresso.ViewAction
import org.fnives.test.showcase.testutils.runOnUIAwaitOnCurrent
import org.fnives.test.showcase.android.testutil.synchronization.runOnUIAwaitOnCurrent
import org.hamcrest.BaseMatcher
import org.hamcrest.CoreMatchers.isA
import org.hamcrest.Description

1
test-util-junit5-android/.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'
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = '1.8'
}
buildFeatures {
buildConfig = false
}
packagingOptions {
exclude 'META-INF/LGPL2.1'
exclude 'META-INF/AL2.0'
exclude 'META-INF/LICENSE.md'
exclude 'META-INF/LICENSE-notice.md'
}
}
dependencies {
implementation "org.junit.jupiter:junit-jupiter-engine:$junit5_version"
implementation "androidx.arch.core:core-runtime:$arch_core_version"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutines_version"
}
ext.artifactId = "android-unit-junit5"
apply from: "../gradlescripts/deploy.aar.gradle"

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.android.testutil">
</manifest>

View file

@ -1,5 +1,6 @@
package org.fnives.test.showcase.testutils
package org.fnives.test.showcase.android.testutil
import android.annotation.SuppressLint
import androidx.arch.core.executor.ArchTaskExecutor
import androidx.arch.core.executor.TaskExecutor
import org.junit.jupiter.api.extension.AfterEachCallback
@ -11,9 +12,11 @@ import org.junit.jupiter.api.extension.ExtensionContext
*
* reference: https://developer.android.com/reference/androidx/arch/core/executor/testing/InstantTaskExecutorRule
*
* A JUnit5 Extensions that swaps the background executor used by the Architecture Components with a different one which executes each task synchronously.
* A JUnit5 Extensions that swaps the background executor used by the Architecture Components with a different
* one which executes each task synchronously.
* You can use this extension for your host side tests that use Architecture Components.
*/
@SuppressLint("RestrictedApi")
class InstantExecutorExtension : BeforeEachCallback, AfterEachCallback {
override fun beforeEach(context: ExtensionContext?) {

View file

@ -0,0 +1,30 @@
package org.fnives.test.showcase.android.testutil
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestDispatcher
import org.fnives.test.showcase.android.testutil.StandardTestMainDispatcher.Companion.testDispatcher
/**
* Custom Junit5 Extension which replaces the main dispatcher with a Standard [TestDispatcher]
*
* One can access the test dispatcher via [testDispatcher] static getter.
*/
@OptIn(ExperimentalCoroutinesApi::class)
class StandardTestMainDispatcher : TestMainDispatcher() {
override var createdTestDispatcher: TestDispatcher?
get() = privateTestDispatcher
set(value) {
privateTestDispatcher = value
}
override fun createDispatcher(): TestDispatcher = StandardTestDispatcher()
companion object {
private var privateTestDispatcher: TestDispatcher? = null
val testDispatcher: TestDispatcher
get() = privateTestDispatcher
?: throw IllegalStateException("StandardTestMainDispatcher is in afterEach State")
}
}

View file

@ -0,0 +1,32 @@
package org.fnives.test.showcase.android.testutil
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
import org.junit.jupiter.api.extension.AfterEachCallback
import org.junit.jupiter.api.extension.BeforeEachCallback
import org.junit.jupiter.api.extension.ExtensionContext
/**
* Custom Junit5 Extension which replaces the main dispatcher with a [TestDispatcher]
*/
@OptIn(ExperimentalCoroutinesApi::class)
abstract class TestMainDispatcher : BeforeEachCallback, AfterEachCallback {
protected abstract var createdTestDispatcher: TestDispatcher?
abstract fun createDispatcher(): TestDispatcher
final override fun beforeEach(context: ExtensionContext?) {
val testDispatcher = createDispatcher()
createdTestDispatcher = testDispatcher
Dispatchers.setMain(testDispatcher)
}
final override fun afterEach(context: ExtensionContext?) {
Dispatchers.resetMain()
createdTestDispatcher = null
}
}

View file

@ -0,0 +1,30 @@
package org.fnives.test.showcase.android.testutil
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestDispatcher
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import org.fnives.test.showcase.android.testutil.UnconfinedTestMainDispatcher.Companion.testDispatcher
/**
* Custom Junit5 Extension which replaces the main dispatcher with a Unconfined [TestDispatcher]
*
* One can access the test dispatcher via [testDispatcher] static getter.
*/
@OptIn(ExperimentalCoroutinesApi::class)
class UnconfinedTestMainDispatcher : TestMainDispatcher() {
override var createdTestDispatcher: TestDispatcher?
get() = privateTestDispatcher
set(value) {
privateTestDispatcher = value
}
override fun createDispatcher(): TestDispatcher = UnconfinedTestDispatcher()
companion object {
private var privateTestDispatcher: TestDispatcher? = null
val testDispatcher: TestDispatcher
get() = privateTestDispatcher
?: throw IllegalStateException("StandardTestMainDispatcher is in afterEach State")
}
}

1
test-util-shared-android/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/build

View file

@ -0,0 +1,47 @@
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'
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = '1.8'
}
buildFeatures {
buildConfig = false
}
packagingOptions {
exclude 'META-INF/LGPL2.1'
exclude 'META-INF/AL2.0'
exclude 'META-INF/LICENSE.md'
exclude 'META-INF/LICENSE-notice.md'
}
}
dependencies {
implementation "androidx.room:room-testing:$room_version"
api project(':test-util-shared-robolectric')
}
ext.artifactId = "shared-android"
apply from: "../gradlescripts/deploy.aar.gradle"

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.android.testutil">
</manifest>

View file

@ -1,4 +1,4 @@
package org.fnives.test.showcase.testutils.configuration
package org.fnives.test.showcase.android.testutil
import android.app.Instrumentation
import androidx.room.RoomDatabase
@ -10,6 +10,9 @@ import androidx.sqlite.db.SupportSQLiteOpenHelper
import org.junit.runner.Description
import org.junit.runners.model.Statement
/**
* Wrapper around [MigrationTestHelper] so it can be created in SharedTests, in both Robolectric and on Device.
*/
class AndroidMigrationTestRule : SharedMigrationTestRule {
private val migrationTestHelper: MigrationTestHelper
@ -35,8 +38,7 @@ class AndroidMigrationTestRule : SharedMigrationTestRule {
specs: List<AutoMigrationSpec>,
openFactory: SupportSQLiteOpenHelper.Factory
) {
migrationTestHelper =
MigrationTestHelper(instrumentation, databaseClass, specs, openFactory)
migrationTestHelper = MigrationTestHelper(instrumentation, databaseClass, specs, openFactory)
}
override fun apply(base: Statement, description: Description): Statement =

View file

@ -0,0 +1 @@
/build

View file

@ -0,0 +1,47 @@
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'
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = '1.8'
}
buildFeatures {
buildConfig = false
}
packagingOptions {
exclude 'META-INF/LGPL2.1'
exclude 'META-INF/AL2.0'
exclude 'META-INF/LICENSE.md'
exclude 'META-INF/LICENSE-notice.md'
}
}
dependencies {
implementation "androidx.room:room-testing:$room_version"
implementation "androidx.arch.core:core-testing:$arch_core_version"
}
ext.artifactId = "shared-robolectric"
apply from: "../gradlescripts/deploy.aar.gradle"

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.android.testutil.robolectric">
</manifest>

View file

@ -0,0 +1,29 @@
package org.fnives.test.showcase.android.testutil
import androidx.room.RoomDatabase
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
import org.junit.rules.TestRule
import java.io.IOException
/**
* Unifying API above [MigrationTestHelper][androidx.room.testing.MigrationTestHelper].
*
* This is intended to be used in MigrationTests that are shared. Meaning the same test can run on both Device and via Robolectric.
*/
interface SharedMigrationTestRule : TestRule {
@Throws(IOException::class)
fun createDatabase(name: String, version: Int): SupportSQLiteDatabase
@Throws(IOException::class)
fun runMigrationsAndValidate(
name: String,
version: Int,
validateDroppedTables: Boolean,
vararg migrations: Migration
): SupportSQLiteDatabase
fun closeWhenFinished(db: SupportSQLiteDatabase)
fun closeWhenFinished(db: RoomDatabase)
}

View file

@ -0,0 +1,80 @@
package org.fnives.test.showcase.android.testutil
import android.app.Instrumentation
import androidx.room.RoomDatabase
import androidx.room.migration.AutoMigrationSpec
import androidx.sqlite.db.SupportSQLiteOpenHelper
import org.fnives.test.showcase.android.testutil.robolectric.RobolectricMigrationTestRule
inline fun <reified Database : RoomDatabase> SharedMigrationTestRule(
instrumentation: Instrumentation,
): SharedMigrationTestRule =
createAndroidClassOrRobolectric(
androidClassFactory = { androidClass ->
val constructor = androidClass.getConstructor(
Instrumentation::class.java,
Class::class.java
)
constructor.newInstance(instrumentation, Database::class.java) as SharedMigrationTestRule
},
robolectricFactory = {
RobolectricMigrationTestRule(instrumentation, Database::class.java)
}
)
inline fun <reified Database : RoomDatabase> SharedMigrationTestRule(
instrumentation: Instrumentation,
specs: List<AutoMigrationSpec>,
): SharedMigrationTestRule =
createAndroidClassOrRobolectric(
androidClassFactory = { androidClass ->
val constructor = androidClass.getConstructor(
Instrumentation::class.java,
Class::class.java,
List::class.java
)
constructor.newInstance(instrumentation, Database::class.java, specs) as SharedMigrationTestRule
},
robolectricFactory = {
RobolectricMigrationTestRule(instrumentation, Database::class.java, specs)
}
)
inline fun <reified Database : RoomDatabase> SharedMigrationTestRule(
instrumentation: Instrumentation,
specs: List<AutoMigrationSpec>,
openFactory: SupportSQLiteOpenHelper.Factory,
): SharedMigrationTestRule =
createAndroidClassOrRobolectric(
androidClassFactory = { androidClass ->
val constructor = androidClass.getConstructor(
Instrumentation::class.java,
Class::class.java,
List::class.java,
SupportSQLiteOpenHelper.Factory::class.java
)
constructor.newInstance(instrumentation, Database::class.java, specs, openFactory) as SharedMigrationTestRule
},
robolectricFactory = {
RobolectricMigrationTestRule(instrumentation, Database::class.java, specs, openFactory)
}
)
fun createAndroidClassOrRobolectric(
androidClassFactory: (Class<*>) -> Any,
robolectricFactory: () -> SharedMigrationTestRule,
): SharedMigrationTestRule {
val androidClass = getAndroidClass()
return if (androidClass == null) {
robolectricFactory()
} else {
androidClassFactory(androidClass) as SharedMigrationTestRule
}
}
@Suppress("SwallowedException")
private fun getAndroidClass() = try {
Class.forName("org.fnives.test.showcase.android.testutil.AndroidMigrationTestRule")
} catch (classNotFoundException: ClassNotFoundException) {
null
}

View file

@ -1,4 +1,4 @@
package org.fnives.test.showcase.testutils.configuration;
package org.fnives.test.showcase.android.testutil.robolectric;
import android.annotation.SuppressLint;
import android.app.Instrumentation;
@ -32,6 +32,7 @@ import androidx.sqlite.db.SupportSQLiteDatabase;
import androidx.sqlite.db.SupportSQLiteOpenHelper;
import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory;
import org.fnives.test.showcase.android.testutil.SharedMigrationTestRule;
import org.jetbrains.annotations.NotNull;
import org.junit.rules.TestWatcher;
import org.junit.runner.Description;
@ -56,8 +57,9 @@ import java.util.Set;
*
* reference: https://github.com/robolectric/robolectric/issues/2065
*/
public class RobolectricMigrationTestHelper extends TestWatcher implements SharedMigrationTestRule {
private static final String TAG = "RobolectricMigrationTestHelper";
@SuppressLint("RestrictedApi")
public class RobolectricMigrationTestRule extends TestWatcher implements SharedMigrationTestRule {
private static final String TAG = "RobolectricMigrationTR";
private final String mAssetsFolder;
private final SupportSQLiteOpenHelper.Factory mOpenFactory;
private List<WeakReference<SupportSQLiteDatabase>> mManagedDatabases = new ArrayList<>();
@ -77,7 +79,7 @@ public class RobolectricMigrationTestHelper extends TestWatcher implements Share
* @param instrumentation The instrumentation instance.
* @param databaseClass The Database class to be tested.
*/
public RobolectricMigrationTestHelper(@NonNull Instrumentation instrumentation,
public RobolectricMigrationTestRule(@NonNull Instrumentation instrumentation,
@NonNull Class<? extends RoomDatabase> databaseClass) {
this(instrumentation, databaseClass, new ArrayList<>(),
new FrameworkSQLiteOpenHelperFactory());
@ -96,7 +98,7 @@ public class RobolectricMigrationTestHelper extends TestWatcher implements Share
* @param specs The list of available auto migration specs that will be provided to
* Room at runtime.
*/
public RobolectricMigrationTestHelper(@NonNull Instrumentation instrumentation,
public RobolectricMigrationTestRule(@NonNull Instrumentation instrumentation,
@NonNull Class<? extends RoomDatabase> databaseClass,
@NonNull List<AutoMigrationSpec> specs) {
this(instrumentation, databaseClass, specs, new FrameworkSQLiteOpenHelperFactory());
@ -116,7 +118,7 @@ public class RobolectricMigrationTestHelper extends TestWatcher implements Share
* Room at runtime.
* @param openFactory Factory class that allows creation of {@link SupportSQLiteOpenHelper}
*/
public RobolectricMigrationTestHelper(@NonNull Instrumentation instrumentation,
public RobolectricMigrationTestRule(@NonNull Instrumentation instrumentation,
@NonNull Class<? extends RoomDatabase> databaseClass,
@NonNull List<AutoMigrationSpec> specs,
@NonNull SupportSQLiteOpenHelper.Factory openFactory