Merge pull request #50 from fknives/issue#49-core-integration-tests

Issue#49 core integration tests
This commit is contained in:
Gergely Hegedis 2022-01-28 00:46:28 +02:00 committed by GitHub
commit 792ef012e1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
73 changed files with 2016 additions and 655 deletions

View file

@ -65,11 +65,11 @@ jobs:
distribution: 'adopt'
java-version: '11'
- name: Run Unit Tests
run: ./gradlew unitTests
run: ./gradlew jvmTests
- name: Upload Test Results
uses: actions/upload-artifact@v2
if: always()
with:
name: Unit Test Results
name: JVM Test Results
path: ./**/build/reports/tests/**/index.html
retention-days: 1

View file

@ -115,18 +115,16 @@ The actual server when running the application is [mockapi.io](https://www.mocka
Download the project, open it in [Android Studio](https://developer.android.com/studio?gclid=Cj0KCQjw1PSDBhDbARIsAPeTqrfKrSx8qD_B9FegOmpVgxtPWFHhBHeqnml8n4ak-I5wPvqlwGdwrUQaAtobEALw_wcB&gclsrc=aw.ds).
* In the gradle window you can see in the root gradle there is a "tests" group. In this group you will see a unitTests and androidTests task.
* First run the unitTests.
* First run the jvmTests.
* When that finished, build the application to your phone.
* Login with whatever credentials and look over the app, what will you test.
* When finished, run androidTests.
This will ensure the testing setup is proper, the project can resolve all the dependencies and such issues won't come up during your exercise.
If everything is right, change branch to codeKata and look for into the [codekata](./codekata) folder for the instruction sets.
### Structure
The Code Kata is structured into 5 different section, each section in different what we are testing and how we are testing it.
The Code Kata is structured into 6 different section, each section in different what we are testing and how we are testing it.
Since our layering is "app", "core" and "networking", of course we will jump right into the middle and start with core.
@ -153,8 +151,14 @@ We will also see how to test with LiveData.
We will introduce Rules, aka easy to reuse "Before" and "After" components.
#### Core Again
Open the [core again instruction set](./codekata/core.again.instructionset.md).
We complicate things here. We write our first Integraiton Test.
We will verify the Authentication classes and the networking module is working together like a charm.
#### App Robolectric Unit Tests.
Open the [app storage unit tests instruction set](./codekata/storage.instructionset).
Open the [app robolectric unit tests instruction set](./codekata/robolectric.instructionset.md).
In this section we will see how to test component depending on context such as Room database.

View file

@ -121,5 +121,8 @@ dependencies {
androidTestRuntimeOnly "org.junit.vintage:junit-vintage-engine:$testing_junit5_version"
implementation "io.reactivex.rxjava3:rxjava:3.1.3"
testImplementation testFixtures(project(':core'))
androidTestImplementation testFixtures(project(':core'))
}

View file

@ -1,5 +0,0 @@
package org.fnives.test.showcase.testutils.configuration
object AndroidTestLoginRobotConfiguration : LoginRobotConfiguration {
override val assertLoadingBeforeRequest: Boolean get() = false
}

View file

@ -1,51 +0,0 @@
package org.fnives.test.showcase.testutils.configuration
import androidx.test.espresso.Espresso
import androidx.test.espresso.NoActivityResumedException
import androidx.test.espresso.assertion.ViewAssertions
import androidx.test.espresso.matcher.ViewMatchers
import kotlinx.coroutines.Dispatchers
import org.fnives.test.showcase.storage.database.DatabaseInitialization
import org.fnives.test.showcase.testutils.idling.loopMainThreadFor
import org.fnives.test.showcase.testutils.idling.loopMainThreadUntilIdleWithIdlingResources
import org.junit.runner.Description
import org.junit.runners.model.Statement
class AndroidTestMainDispatcherTestRule : MainDispatcherTestRule {
override fun apply(base: Statement, description: Description): Statement =
object : Statement() {
@Throws(Throwable::class)
override fun evaluate() {
DatabaseInitialization.dispatcher = Dispatchers.Main
base.evaluate()
}
}
override fun advanceUntilIdleWithIdlingResources() {
loopMainThreadUntilIdleWithIdlingResources()
}
override fun advanceUntilIdleOrActivityIsDestroyed() {
try {
advanceUntilIdleWithIdlingResources()
Espresso.onView(ViewMatchers.isRoot()).check(ViewAssertions.doesNotExist())
} catch (noActivityResumedException: NoActivityResumedException) {
// expected to happen
} catch (runtimeException: RuntimeException) {
if (runtimeException.message?.contains("No activities found") == true) {
// expected to happen
} else {
throw runtimeException
}
}
}
override fun advanceUntilIdle() {
loopMainThreadUntilIdleWithIdlingResources()
}
override fun advanceTimeBy(delayInMillis: Long) {
loopMainThreadFor(delayInMillis)
}
}

View file

@ -1,29 +0,0 @@
package org.fnives.test.showcase.testutils.configuration
import okhttp3.OkHttpClient
import org.fnives.test.showcase.network.mockserver.MockServerScenarioSetup
import org.fnives.test.showcase.testutils.idling.NetworkSynchronization
import org.koin.core.context.loadKoinModules
import org.koin.core.qualifier.StringQualifier
import org.koin.dsl.module
import org.koin.test.KoinTest
import org.koin.test.get
object AndroidTestServerTypeConfiguration : ServerTypeConfiguration, KoinTest {
override val useHttps: Boolean get() = true
override val url: String get() = "${MockServerScenarioSetup.HTTPS_BASE_URL}:${MockServerScenarioSetup.PORT}/"
override fun invoke(mockServerScenarioSetup: MockServerScenarioSetup) {
val handshakeCertificates = mockServerScenarioSetup.clientCertificates ?: return
val sessionless = StringQualifier(NetworkSynchronization.OkHttpClientTypes.SESSIONLESS.qualifier)
val okHttpClientWithCertificate = get<OkHttpClient>(sessionless).newBuilder()
.sslSocketFactory(handshakeCertificates.sslSocketFactory(), handshakeCertificates.trustManager)
.build()
loadKoinModules(
module {
single(qualifier = sessionless) { okHttpClientWithCertificate }
}
)
}
}

View file

@ -15,7 +15,7 @@ import org.hamcrest.Matchers
import org.junit.runner.Description
import org.junit.runners.model.Statement
object AndroidTestSnackbarVerificationTestRule : SnackbarVerificationTestRule {
object AndroidTestSnackbarVerificationHelper : SnackbarVerificationHelper {
override fun apply(base: Statement, description: Description): Statement = base

View file

@ -1,17 +1,9 @@
package org.fnives.test.showcase.testutils.configuration
object SpecificTestConfigurationsFactory : TestConfigurationsFactory {
override fun createMainDispatcherTestRule(): MainDispatcherTestRule =
AndroidTestMainDispatcherTestRule()
override fun createServerTypeConfiguration(): ServerTypeConfiguration =
AndroidTestServerTypeConfiguration
override fun createLoginRobotConfiguration(): LoginRobotConfiguration =
AndroidTestLoginRobotConfiguration
override fun createSnackbarVerification(): SnackbarVerificationTestRule =
AndroidTestSnackbarVerificationTestRule
override fun createSnackbarVerification(): SnackbarVerificationHelper =
AndroidTestSnackbarVerificationHelper
override fun createSharedMigrationTestRuleFactory(): SharedMigrationTestRuleFactory =
AndroidMigrationTestRuleFactory

View file

@ -13,7 +13,7 @@
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.TestShowCase"
tools:ignore="AllowBackup">
tools:ignore="AllowBackup,DataExtractionRules">
<activity
android:name=".ui.splash.SplashActivity"
android:exported="true">

View file

@ -1,8 +1,8 @@
package org.fnives.test.showcase
import android.app.Application
import org.fnives.test.showcase.di.BaseUrlProvider
import org.fnives.test.showcase.di.createAppModules
import org.fnives.test.showcase.model.network.BaseUrl
import org.koin.android.ext.koin.androidContext
import org.koin.core.context.startKoin
@ -10,9 +10,10 @@ class TestShowcaseApplication : Application() {
override fun onCreate() {
super.onCreate()
val baseUrl = BaseUrl(BuildConfig.BASE_URL)
startKoin {
androidContext(this@TestShowcaseApplication)
modules(createAppModules(BaseUrlProvider.get()))
modules(createAppModules(baseUrl))
}
}
}

View file

@ -1,9 +0,0 @@
package org.fnives.test.showcase.di
import org.fnives.test.showcase.BuildConfig
import org.fnives.test.showcase.model.network.BaseUrl
object BaseUrlProvider {
fun get() = BaseUrl(BuildConfig.BASE_URL)
}

View file

@ -1,6 +1,6 @@
package org.fnives.test.showcase.di
import org.fnives.test.showcase.core.di.koin.createCoreModule
import org.fnives.test.showcase.core.di.createCoreModule
import org.fnives.test.showcase.model.network.BaseUrl
import org.fnives.test.showcase.session.SessionExpirationListenerImpl
import org.fnives.test.showcase.storage.LocalDatabase

View file

@ -0,0 +1,51 @@
package org.fnives.test.showcase.storage.favourite
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Before
import org.junit.Test
import org.junit.jupiter.api.Disabled
@Disabled("CodeKata")
@OptIn(ExperimentalCoroutinesApi::class)
class CodeKataFavouriteContentLocalStorageInstrumentedTest {
@Before
fun setUp() {
}
@After
fun tearDown() {
}
/** GIVEN just created database WHEN querying THEN empty list is returned */
@Test
fun atTheStartOurDatabaseIsEmpty() = runBlocking {
}
/** GIVEN content_id WHEN added to Favourite THEN it can be read out */
@Test
fun addingContentIdToFavouriteCanBeLaterReadOut() {
}
/** GIVEN content_id added WHEN removed to Favourite THEN it no longer can be read out */
@Test
fun contentIdAddedThenRemovedCanNoLongerBeReadOut() {
}
/** GIVEN empty database WHILE observing content WHEN favourite added THEN change is emitted */
@Test
fun addingFavouriteUpdatesExistingObservers() {
}
/** GIVEN non empty database WHILE observing content WHEN favourite removed THEN change is emitted */
@Test
fun removingFavouriteUpdatesExistingObservers() {
}
/** GIVEN an observed WHEN adding and removing from it THEN we only get the expected amount of updates */
@Test
fun noUnexpectedUpdates() {
}
}

View file

@ -1,4 +1,4 @@
package org.fnives.test.showcase.favourite
package org.fnives.test.showcase.storage.favourite
import androidx.test.ext.junit.runners.AndroidJUnit4
import kotlinx.coroutines.ExperimentalCoroutinesApi
@ -26,7 +26,7 @@ import org.koin.test.inject
@Suppress("TestFunctionName")
@OptIn(ExperimentalCoroutinesApi::class)
@RunWith(AndroidJUnit4::class)
internal class FavouriteContentLocalStorageImplTest : KoinTest {
internal class FavouriteContentLocalStorageImplInstrumentedTest : KoinTest {
private val sut by inject<FavouriteContentLocalStorage>()
private lateinit var testDispatcher: TestDispatcher
@ -42,6 +42,14 @@ internal class FavouriteContentLocalStorageImplTest : KoinTest {
stopKoin()
}
/** GIVEN just created database WHEN querying THEN empty list is returned */
@Test
fun atTheStartOurDatabaseIsEmpty() = runTest(testDispatcher) {
val actual = sut.observeFavourites().first()
Assert.assertEquals(emptyList<ContentId>(), actual)
}
/** GIVEN content_id WHEN added to Favourite THEN it can be read out */
@Test
fun addingContentIdToFavouriteCanBeLaterReadOut() = runTest(testDispatcher) {
@ -69,7 +77,6 @@ internal class FavouriteContentLocalStorageImplTest : KoinTest {
@Test
fun addingFavouriteUpdatesExistingObservers() = runTest(testDispatcher) {
val expected = listOf(listOf(), listOf(ContentId("a")))
val actual = async(coroutineContext) { sut.observeFavourites().take(2).toList() }
advanceUntilIdle()
@ -95,4 +102,19 @@ internal class FavouriteContentLocalStorageImplTest : KoinTest {
Assert.assertEquals(expected, actual.getCompleted())
}
/** GIVEN an observed WHEN adding and removing from it THEN we only get the expected amount of updates */
@Test
fun noUnexpectedUpdates() = runTest(testDispatcher) {
val actual = async(coroutineContext) { sut.observeFavourites().take(4).toList() }
advanceUntilIdle()
sut.markAsFavourite(ContentId("a"))
advanceUntilIdle()
sut.deleteAsFavourite(ContentId("a"))
advanceUntilIdle()
Assert.assertFalse(actual.isCompleted)
actual.cancel()
}
}

View file

@ -1,5 +0,0 @@
package org.fnives.test.showcase.testutils.configuration
object RobolectricLoginRobotConfiguration : LoginRobotConfiguration {
override val assertLoadingBeforeRequest: Boolean = true
}

View file

@ -1,11 +0,0 @@
package org.fnives.test.showcase.testutils.configuration
import org.fnives.test.showcase.network.mockserver.MockServerScenarioSetup
object RobolectricServerTypeConfiguration : ServerTypeConfiguration {
override val useHttps: Boolean = false
override val url: String get() = "${MockServerScenarioSetup.HTTP_BASE_URL}:${MockServerScenarioSetup.PORT}/"
override fun invoke(mockServerScenarioSetup: MockServerScenarioSetup) = Unit
}

View file

@ -6,7 +6,7 @@ import org.fnives.test.showcase.testutils.shadow.ShadowSnackbarResetTestRule
import org.junit.Assert
import org.junit.rules.TestRule
object RobolectricSnackbarVerificationTestRule : SnackbarVerificationTestRule, TestRule by ShadowSnackbarResetTestRule() {
object RobolectricSnackbarVerificationHelper : SnackbarVerificationHelper, TestRule by ShadowSnackbarResetTestRule() {
override fun assertIsShownWithText(@StringRes stringResID: Int) {
val latestSnackbar = ShadowSnackbar.latestSnackbar ?: throw IllegalStateException("Snackbar not found")

View file

@ -1,17 +1,9 @@
package org.fnives.test.showcase.testutils.configuration
object SpecificTestConfigurationsFactory : TestConfigurationsFactory {
override fun createMainDispatcherTestRule(): MainDispatcherTestRule =
TestCoroutineMainDispatcherTestRule()
override fun createServerTypeConfiguration(): ServerTypeConfiguration =
RobolectricServerTypeConfiguration
override fun createLoginRobotConfiguration(): LoginRobotConfiguration =
RobolectricLoginRobotConfiguration
override fun createSnackbarVerification(): SnackbarVerificationTestRule =
RobolectricSnackbarVerificationTestRule
override fun createSnackbarVerification(): SnackbarVerificationHelper =
RobolectricSnackbarVerificationHelper
override fun createSharedMigrationTestRuleFactory(): SharedMigrationTestRuleFactory =
RobolectricMigrationTestHelperFactory

View file

@ -1,9 +0,0 @@
package org.fnives.test.showcase.di
import org.fnives.test.showcase.model.network.BaseUrl
import org.fnives.test.showcase.testutils.configuration.SpecificTestConfigurationsFactory
object BaseUrlProvider {
fun get() = BaseUrl(SpecificTestConfigurationsFactory.createServerTypeConfiguration().url)
}

View file

@ -25,7 +25,7 @@ import java.io.IOException
* https://developer.android.com/training/data-storage/room/migrating-db-versions
*/
@RunWith(AndroidJUnit4::class)
class MigrationToLatest {
class MigrationToLatestInstrumentedTest {
@get:Rule
val helper: SharedMigrationTestRule = createSharedMigrationTestRule<LocalDatabase>(

View file

@ -0,0 +1,22 @@
package org.fnives.test.showcase.testutils
import android.app.Activity
import androidx.test.core.app.ActivityScenario
fun <T : Activity> ActivityScenario<T>.safeClose() {
workaroundForActivityScenarioCLoseLockingUp()
close()
}
/**
* This should not be needed, we shouldn't use sleep 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.
*
* As soon as that issue is closed, this should be removed as well.
*/
private fun workaroundForActivityScenarioCLoseLockingUp() {
Thread.sleep(1000L)
}

View file

@ -0,0 +1,49 @@
package org.fnives.test.showcase.testutils
import org.fnives.test.showcase.network.mockserver.MockServerScenarioSetup
import org.fnives.test.showcase.network.testutil.NetworkTestConfigurationHelper
import org.fnives.test.showcase.testutils.idling.NetworkSynchronizationTestRule
import org.junit.rules.TestRule
import org.junit.runner.Description
import org.junit.runners.model.Statement
import org.koin.test.KoinTest
/**
* TestRule which ensures Koin is reseted between each tests and setups Network mocking.
*
* It First resets koin if needed.
* Then creates and starts the mockwebserver, it also injects the correct baseUrl into the OkHttp Client.
* Then synchronizes Espresso with the OkHttp Client
*/
class MockServerScenarioSetupResetingTestRule(
private val reloadKoinModulesIfNecessaryTestRule: ReloadKoinModulesIfNecessaryTestRule = ReloadKoinModulesIfNecessaryTestRule(),
private val networkSynchronizationTestRule: NetworkSynchronizationTestRule = NetworkSynchronizationTestRule()
) : TestRule, KoinTest {
lateinit var mockServerScenarioSetup: MockServerScenarioSetup
override fun apply(base: Statement, description: Description): Statement =
networkSynchronizationTestRule.apply(base, description)
.let(::createStatement)
.let { reloadKoinModulesIfNecessaryTestRule.apply(it, description) }
private fun createStatement(base: Statement) = object : Statement() {
@Throws(Throwable::class)
override fun evaluate() {
before()
try {
base.evaluate()
} finally {
after()
}
}
}
private fun before() {
mockServerScenarioSetup = NetworkTestConfigurationHelper.startWithHTTPSMockWebServer()
}
private fun after() {
mockServerScenarioSetup.stop()
}
}

View file

@ -1,36 +0,0 @@
package org.fnives.test.showcase.testutils
import org.fnives.test.showcase.network.mockserver.MockServerScenarioSetup
import org.fnives.test.showcase.testutils.configuration.ServerTypeConfiguration
import org.fnives.test.showcase.testutils.configuration.SpecificTestConfigurationsFactory
import org.junit.rules.TestRule
import org.junit.runner.Description
import org.junit.runners.model.Statement
class MockServerScenarioSetupTestRule(
val serverTypeConfiguration: ServerTypeConfiguration = SpecificTestConfigurationsFactory.createServerTypeConfiguration()
) : TestRule {
lateinit var mockServerScenarioSetup: MockServerScenarioSetup
override fun apply(base: Statement, description: Description): Statement =
object : Statement() {
@Throws(Throwable::class)
override fun evaluate() {
before()
try {
base.evaluate()
} finally {
after()
}
}
}
private fun before() {
mockServerScenarioSetup = MockServerScenarioSetup()
mockServerScenarioSetup.start(serverTypeConfiguration.useHttps)
}
private fun after() {
mockServerScenarioSetup.stop()
}
}

View file

@ -1,9 +1,10 @@
package org.fnives.test.showcase.testutils
import androidx.test.core.app.ApplicationProvider
import org.fnives.test.showcase.BuildConfig
import org.fnives.test.showcase.TestShowcaseApplication
import org.fnives.test.showcase.di.BaseUrlProvider
import org.fnives.test.showcase.di.createAppModules
import org.fnives.test.showcase.model.network.BaseUrl
import org.junit.rules.TestRule
import org.junit.runner.Description
import org.junit.runners.model.Statement
@ -11,24 +12,41 @@ import org.koin.android.ext.koin.androidContext
import org.koin.core.context.GlobalContext
import org.koin.core.context.startKoin
import org.koin.core.context.stopKoin
import org.koin.mp.KoinPlatformTools
import org.koin.test.KoinTest
class ReloadKoinModulesIfNecessaryTestRule : TestRule {
/**
* Test rule to help reinitialize the whole Koin setup.
*
* It's needed because in AndroidTest's the Application is only called once,
* meaning our koin would be shared.
*
* Note: Do not use if you want your test's to share Koin, and in such case do not stop your Koin.
*/
class ReloadKoinModulesIfNecessaryTestRule : TestRule, KoinTest {
override fun apply(base: Statement, description: Description): Statement =
object : Statement() {
override fun evaluate() {
if (GlobalContext.getOrNull() == null) {
val application =
ApplicationProvider.getApplicationContext<TestShowcaseApplication>()
startKoin {
androidContext(application)
modules(createAppModules(BaseUrlProvider.get()))
}
}
try {
base.evaluate()
} finally {
stopKoin()
}
ReinitKoinStatement(base)
class ReinitKoinStatement(private val base: Statement) : Statement() {
override fun evaluate() {
reinitKoinIfNeeded()
try {
base.evaluate()
} finally {
stopKoin()
}
}
private fun reinitKoinIfNeeded() {
if (KoinPlatformTools.defaultContext().getOrNull() != null) return
if (GlobalContext.getOrNull() != null) return
val application = ApplicationProvider.getApplicationContext<TestShowcaseApplication>()
val baseUrl = BaseUrl(BuildConfig.BASE_URL)
startKoin {
androidContext(application)
modules(createAppModules(baseUrl))
}
}
}
}

View file

@ -1,6 +0,0 @@
package org.fnives.test.showcase.testutils.configuration
interface LoginRobotConfiguration {
val assertLoadingBeforeRequest: Boolean
}

View file

@ -1,14 +0,0 @@
package org.fnives.test.showcase.testutils.configuration
import org.junit.rules.TestRule
interface MainDispatcherTestRule : TestRule {
fun advanceUntilIdleWithIdlingResources()
fun advanceUntilIdleOrActivityIsDestroyed()
fun advanceUntilIdle()
fun advanceTimeBy(delayInMillis: Long)
}

View file

@ -3,9 +3,13 @@ package org.fnives.test.showcase.testutils.configuration
import androidx.annotation.StringRes
import org.junit.rules.TestRule
interface SnackbarVerificationTestRule : TestRule {
interface SnackbarVerificationHelper : TestRule {
fun assertIsShownWithText(@StringRes stringResID: Int)
fun assertIsNotShown()
}
@Suppress("TestFunctionName")
fun SnackbarVerificationTestRule(): SnackbarVerificationHelper =
SpecificTestConfigurationsFactory.createSnackbarVerification()

View file

@ -8,13 +8,7 @@ package org.fnives.test.showcase.testutils.configuration
*/
interface TestConfigurationsFactory {
fun createMainDispatcherTestRule(): MainDispatcherTestRule
fun createServerTypeConfiguration(): ServerTypeConfiguration
fun createLoginRobotConfiguration(): LoginRobotConfiguration
fun createSnackbarVerification(): SnackbarVerificationTestRule
fun createSnackbarVerification(): SnackbarVerificationHelper
fun createSharedMigrationTestRuleFactory(): SharedMigrationTestRuleFactory
}

View file

@ -5,7 +5,7 @@ import android.os.Looper
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.runBlocking
fun doBlockinglyOnMainThread(action: () -> Unit) {
fun runOnUIAwaitOnCurrent(action: () -> Unit) {
if (Looper.myLooper() === Looper.getMainLooper()) {
action()
} else {

View file

@ -1,4 +1,4 @@
package org.fnives.test.showcase.testutils.configuration
package org.fnives.test.showcase.testutils.idling
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
@ -8,12 +8,13 @@ import kotlinx.coroutines.test.TestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
import org.fnives.test.showcase.storage.database.DatabaseInitialization
import org.fnives.test.showcase.testutils.idling.advanceUntilIdleWithIdlingResources
import org.fnives.test.showcase.testutils.runOnUIAwaitOnCurrent
import org.junit.rules.TestRule
import org.junit.runner.Description
import org.junit.runners.model.Statement
@OptIn(ExperimentalCoroutinesApi::class)
class TestCoroutineMainDispatcherTestRule : MainDispatcherTestRule {
class MainDispatcherTestRule : TestRule {
private lateinit var testDispatcher: TestDispatcher
@ -33,19 +34,24 @@ class TestCoroutineMainDispatcherTestRule : MainDispatcherTestRule {
}
}
override fun advanceUntilIdleWithIdlingResources() {
fun advanceUntilIdleWithIdlingResources() = runOnUIAwaitOnCurrent {
testDispatcher.advanceUntilIdleWithIdlingResources()
}
override fun advanceUntilIdleOrActivityIsDestroyed() {
advanceUntilIdleWithIdlingResources()
}
override fun advanceUntilIdle() {
fun advanceUntilIdle() = runOnUIAwaitOnCurrent {
testDispatcher.scheduler.advanceUntilIdle()
}
override fun advanceTimeBy(delayInMillis: Long) {
fun advanceTimeBy(delayInMillis: Long) = runOnUIAwaitOnCurrent {
testDispatcher.scheduler.advanceTimeBy(delayInMillis)
}
private 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,32 +0,0 @@
package org.fnives.test.showcase.testutils.idling
import androidx.annotation.CheckResult
import androidx.test.espresso.IdlingResource
import okhttp3.OkHttpClient
import org.koin.core.qualifier.StringQualifier
import org.koin.test.KoinTest
import org.koin.test.get
object NetworkSynchronization : KoinTest {
@CheckResult
fun registerNetworkingSynchronization(): Disposable {
val idlingResources = OkHttpClientTypes.values()
.map { it to getOkHttpClient(it) }
.associateBy { it.second.dispatcher }
.values
.map { (key, client) -> client.asIdlingResource(key.qualifier) }
.map(::IdlingResourceDisposable)
return CompositeDisposable(idlingResources)
}
private fun getOkHttpClient(type: OkHttpClientTypes): OkHttpClient = get(StringQualifier(type.qualifier))
private fun OkHttpClient.asIdlingResource(name: String): IdlingResource =
OkHttp3IdlingResource.create(name, this)
enum class OkHttpClientTypes(val qualifier: String) {
SESSION("SESSION-NETWORKING"), SESSIONLESS("SESSIONLESS-NETWORKING")
}
}

View file

@ -0,0 +1,43 @@
package org.fnives.test.showcase.testutils.idling
import androidx.annotation.CheckResult
import androidx.test.espresso.IdlingResource
import okhttp3.OkHttpClient
import org.fnives.test.showcase.network.testutil.NetworkTestConfigurationHelper
import org.junit.rules.TestRule
import org.junit.runner.Description
import org.junit.runners.model.Statement
import org.koin.test.KoinTest
class NetworkSynchronizationTestRule : TestRule, KoinTest {
private var disposable: Disposable? = null
override fun apply(base: Statement, description: Description): Statement {
return object : Statement() {
override fun evaluate() {
disposable = registerNetworkingSynchronization()
try {
base.evaluate()
} finally {
dispose()
}
}
}
}
fun dispose() = disposable?.dispose()
@CheckResult
private fun registerNetworkingSynchronization(): Disposable {
val idlingResources = NetworkTestConfigurationHelper.getOkHttpClients()
.associateBy(keySelector = { it.toString() })
.map { (key, client) -> client.asIdlingResource(key) }
.map(::IdlingResourceDisposable)
return CompositeDisposable(idlingResources)
}
private fun OkHttpClient.asIdlingResource(name: String): IdlingResource =
OkHttp3IdlingResource.create(name, this)
}

View file

@ -1,11 +1,10 @@
package org.fnives.test.showcase.testutils.idling
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 kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestDispatcher
import org.fnives.test.showcase.testutils.viewactions.LoopMainThreadFor
import org.fnives.test.showcase.testutils.viewactions.LoopMainThreadUntilIdle
import java.util.concurrent.Executors
@ -43,16 +42,6 @@ private fun IdlingResource.awaitUntilIdle() {
}
}
@OptIn(ExperimentalCoroutinesApi::class)
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()
}
fun loopMainThreadUntilIdleWithIdlingResources() {
Espresso.onView(ViewMatchers.isRoot()).perform(LoopMainThreadUntilIdle()) // advance until a request is sent
while (anyResourceIdling()) { // check if any request is in progress
@ -63,5 +52,9 @@ fun loopMainThreadUntilIdleWithIdlingResources() {
}
fun loopMainThreadFor(delay: Long) {
Espresso.onView(ViewMatchers.isRoot()).perform(LoopMainThreadFor(delay))
if (Looper.getMainLooper().isCurrentThread) {
Thread.sleep(200L)
} else {
Espresso.onView(ViewMatchers.isRoot()).perform(LoopMainThreadFor(delay))
}
}

View file

@ -2,9 +2,12 @@ package org.fnives.test.showcase.testutils.statesetup
import androidx.lifecycle.Lifecycle
import androidx.test.core.app.ActivityScenario
import androidx.test.espresso.intent.Intents
import androidx.test.runner.intent.IntentStubberRegistry
import org.fnives.test.showcase.network.mockserver.MockServerScenarioSetup
import org.fnives.test.showcase.network.mockserver.scenario.auth.AuthScenario
import org.fnives.test.showcase.testutils.configuration.MainDispatcherTestRule
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
@ -15,7 +18,8 @@ object SetupAuthenticationState : KoinTest {
fun setupLogin(
mainDispatcherTestRule: MainDispatcherTestRule,
mockServerScenarioSetup: MockServerScenarioSetup
mockServerScenarioSetup: MockServerScenarioSetup,
resetIntents: Boolean = true
) {
mockServerScenarioSetup.setScenario(AuthScenario.Success(username = "a", password = "b"))
val activityScenario = ActivityScenario.launch(AuthActivity::class.java)
@ -27,22 +31,29 @@ object SetupAuthenticationState : KoinTest {
.setUsername("a")
.clickOnLogin()
mainDispatcherTestRule.advanceUntilIdleOrActivityIsDestroyed()
mainDispatcherTestRule.advanceUntilIdleWithIdlingResources()
activityScenario.close()
activityScenario.safeClose()
resetIntentsIfNeeded(resetIntents)
}
fun setupLogout(
mainDispatcherTestRule: MainDispatcherTestRule
mainDispatcherTestRule: MainDispatcherTestRule,
resetIntents: Boolean = true
) {
val activityScenario = ActivityScenario.launch(MainActivity::class.java)
activityScenario.moveToState(Lifecycle.State.RESUMED)
val homeRobot = HomeRobot()
homeRobot
.clickSignOut()
HomeRobot().clickSignOut()
mainDispatcherTestRule.advanceUntilIdleWithIdlingResources()
mainDispatcherTestRule.advanceUntilIdleOrActivityIsDestroyed()
activityScenario.safeClose()
resetIntentsIfNeeded(resetIntents)
}
activityScenario.close()
private fun resetIntentsIfNeeded(resetIntents: Boolean) {
if (resetIntents && IntentStubberRegistry.isLoaded()) {
Intents.release()
Intents.init()
}
}
}

View file

@ -5,7 +5,7 @@ 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.doBlockinglyOnMainThread
import org.fnives.test.showcase.testutils.runOnUIAwaitOnCurrent
import org.hamcrest.BaseMatcher
import org.hamcrest.CoreMatchers.isA
import org.hamcrest.Description
@ -36,7 +36,7 @@ class PullToRefresh : ViewAction {
override fun perform(uiController: UiController, view: View) {
val swipeRefreshLayout = view as SwipeRefreshLayout
doBlockinglyOnMainThread {
runOnUIAwaitOnCurrent {
swipeRefreshLayout.isRefreshing = true
swipeRefreshLayout.listener().onRefresh()
}

View file

@ -0,0 +1,24 @@
package org.fnives.test.showcase.testutils.viewactions
import android.graphics.Color
import android.graphics.drawable.ColorDrawable
import android.view.View
import android.widget.ProgressBar
import androidx.test.espresso.UiController
import androidx.test.espresso.ViewAction
import androidx.test.espresso.matcher.ViewMatchers.isAssignableFrom
import org.hamcrest.Matcher
class ReplaceProgressBarDrawableToStatic : ViewAction {
override fun getConstraints(): Matcher<View> =
isAssignableFrom(ProgressBar::class.java)
override fun getDescription(): String =
"replace the ProgressBar drawable"
override fun perform(uiController: UiController, view: View) {
val progressBar: ProgressBar = view as ProgressBar
progressBar.indeterminateDrawable = ColorDrawable(Color.GREEN)
uiController.loopMainThreadUntilIdle()
}
}

View file

@ -19,10 +19,7 @@ import androidx.test.espresso.matcher.ViewMatchers.withText
import org.fnives.test.showcase.R
import org.fnives.test.showcase.model.content.Content
import org.fnives.test.showcase.model.content.FavouriteContent
import org.fnives.test.showcase.network.mockserver.MockServerScenarioSetup
import org.fnives.test.showcase.testutils.configuration.MainDispatcherTestRule
import org.fnives.test.showcase.testutils.robot.Robot
import org.fnives.test.showcase.testutils.statesetup.SetupAuthenticationState
import org.fnives.test.showcase.testutils.viewactions.PullToRefresh
import org.fnives.test.showcase.testutils.viewactions.WithDrawable
import org.fnives.test.showcase.testutils.viewactions.notIntended
@ -33,8 +30,6 @@ class HomeRobot : Robot {
override fun init() {
Intents.init()
Intents.intending(IntentMatchers.hasComponent(AuthActivity::class.java.canonicalName))
.respondWith(Instrumentation.ActivityResult(0, null))
}
override fun release() {
@ -50,6 +45,9 @@ class HomeRobot : Robot {
}
fun clickSignOut() = apply {
Intents.intending(IntentMatchers.hasComponent(AuthActivity::class.java.canonicalName))
.respondWith(Instrumentation.ActivityResult(0, null))
Espresso.onView(withId(R.id.logout_cta)).perform(click())
}
@ -103,11 +101,4 @@ class HomeRobot : Robot {
Espresso.onView(withId(R.id.error_message))
.check(matches(allOf(isDisplayed(), withText(R.string.something_went_wrong))))
}
fun setupLogin(
mainDispatcherTestRule: MainDispatcherTestRule,
mockServerScenarioSetup: MockServerScenarioSetup
) {
SetupAuthenticationState.setupLogin(mainDispatcherTestRule, mockServerScenarioSetup)
}
}

View file

@ -1,79 +1,52 @@
package org.fnives.test.showcase.ui.home
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import androidx.test.core.app.ActivityScenario
import androidx.test.ext.junit.runners.AndroidJUnit4
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
import org.fnives.test.showcase.network.mockserver.scenario.refresh.RefreshTokenScenario
import org.fnives.test.showcase.testutils.MockServerScenarioSetupTestRule
import org.fnives.test.showcase.testutils.ReloadKoinModulesIfNecessaryTestRule
import org.fnives.test.showcase.testutils.configuration.SpecificTestConfigurationsFactory
import org.fnives.test.showcase.testutils.idling.Disposable
import org.fnives.test.showcase.testutils.idling.NetworkSynchronization
import org.fnives.test.showcase.testutils.MockServerScenarioSetupResetingTestRule
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.robot.RobotTestRule
import org.fnives.test.showcase.testutils.safeClose
import org.fnives.test.showcase.testutils.statesetup.SetupAuthenticationState.setupLogin
import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.rules.RuleChain
import org.junit.runner.RunWith
import org.koin.test.KoinTest
@Suppress("TestFunctionName")
@RunWith(AndroidJUnit4::class)
class MainActivityTest : KoinTest {
class MainActivityInstrumentedTest : KoinTest {
private lateinit var activityScenario: ActivityScenario<MainActivity>
@Rule
@JvmField
val instantTaskExecutorRule = InstantTaskExecutorRule()
private val mockServerScenarioSetupTestRule = MockServerScenarioSetupResetingTestRule()
private val mockServerScenarioSetup
get() = mockServerScenarioSetupTestRule.mockServerScenarioSetup
private val mainDispatcherTestRule = MainDispatcherTestRule()
private val robot = HomeRobot()
@Rule
@JvmField
val snackbarVerificationTestRule =
SpecificTestConfigurationsFactory.createSnackbarVerification()
@Rule
@JvmField
val robotRule = RobotTestRule(HomeRobot())
private val homeRobot get() = robotRule.robot
@Rule
@JvmField
val mockServerScenarioSetupTestRule = MockServerScenarioSetupTestRule()
val mockServerScenarioSetup get() = mockServerScenarioSetupTestRule.mockServerScenarioSetup
@Rule
@JvmField
val mainDispatcherTestRule = SpecificTestConfigurationsFactory.createMainDispatcherTestRule()
@Rule
@JvmField
val reloadKoinModulesIfNecessaryTestRule = ReloadKoinModulesIfNecessaryTestRule()
private lateinit var disposable: Disposable
val ruleOrder: RuleChain = RuleChain.outerRule(mockServerScenarioSetupTestRule)
.around(mainDispatcherTestRule)
.around(RobotTestRule(robot))
@Before
fun setUp() {
SpecificTestConfigurationsFactory.createServerTypeConfiguration()
.invoke(mockServerScenarioSetup)
disposable = NetworkSynchronization.registerNetworkingSynchronization()
homeRobot.setupLogin(
mainDispatcherTestRule,
mockServerScenarioSetup
)
fun setup() {
setupLogin(mainDispatcherTestRule, mockServerScenarioSetup)
}
@After
fun tearDown() {
activityScenario.close()
disposable.dispose()
activityScenario.safeClose()
}
/** GIVEN initialized MainActivity WHEN signout is clicked THEN user is signed out */
@ -83,10 +56,10 @@ class MainActivityTest : KoinTest {
activityScenario = ActivityScenario.launch(MainActivity::class.java)
mainDispatcherTestRule.advanceUntilIdleWithIdlingResources()
homeRobot.clickSignOut()
mainDispatcherTestRule.advanceUntilIdleOrActivityIsDestroyed()
robot.clickSignOut()
mainDispatcherTestRule.advanceUntilIdleWithIdlingResources()
homeRobot.assertNavigatedToAuth()
robot.assertNavigatedToAuth()
}
/** GIVEN success response WHEN data is returned THEN it is shown on the ui */
@ -97,9 +70,9 @@ class MainActivityTest : KoinTest {
mainDispatcherTestRule.advanceUntilIdleWithIdlingResources()
ContentData.contentSuccess.forEachIndexed { index, content ->
homeRobot.assertContainsItem(index, FavouriteContent(content, false))
robot.assertContainsItem(index, FavouriteContent(content, false))
}
homeRobot.assertDidNotNavigateToAuth()
robot.assertDidNotNavigateToAuth()
}
/** GIVEN success response WHEN item is clicked THEN ui is updated */
@ -109,11 +82,11 @@ class MainActivityTest : KoinTest {
activityScenario = ActivityScenario.launch(MainActivity::class.java)
mainDispatcherTestRule.advanceUntilIdleWithIdlingResources()
homeRobot.clickOnContentItem(0, ContentData.contentSuccess.first())
robot.clickOnContentItem(0, ContentData.contentSuccess.first())
mainDispatcherTestRule.advanceUntilIdleWithIdlingResources()
val expectedItem = FavouriteContent(ContentData.contentSuccess.first(), true)
homeRobot.assertContainsItem(0, expectedItem)
robot.assertContainsItem(0, expectedItem)
.assertDidNotNavigateToAuth()
}
@ -124,16 +97,16 @@ class MainActivityTest : KoinTest {
activityScenario = ActivityScenario.launch(MainActivity::class.java)
mainDispatcherTestRule.advanceUntilIdleWithIdlingResources()
homeRobot.clickOnContentItem(0, ContentData.contentSuccess.first())
robot.clickOnContentItem(0, ContentData.contentSuccess.first())
mainDispatcherTestRule.advanceUntilIdleWithIdlingResources()
val expectedItem = FavouriteContent(ContentData.contentSuccess.first(), true)
activityScenario.close()
activityScenario.safeClose()
activityScenario = ActivityScenario.launch(MainActivity::class.java)
mainDispatcherTestRule.advanceUntilIdleWithIdlingResources()
homeRobot.assertContainsItem(0, expectedItem)
robot.assertContainsItem(0, expectedItem)
.assertDidNotNavigateToAuth()
}
@ -144,13 +117,13 @@ class MainActivityTest : KoinTest {
activityScenario = ActivityScenario.launch(MainActivity::class.java)
mainDispatcherTestRule.advanceUntilIdleWithIdlingResources()
homeRobot.clickOnContentItem(0, ContentData.contentSuccess.first())
robot.clickOnContentItem(0, ContentData.contentSuccess.first())
mainDispatcherTestRule.advanceUntilIdleWithIdlingResources()
homeRobot.clickOnContentItem(0, ContentData.contentSuccess.first())
robot.clickOnContentItem(0, ContentData.contentSuccess.first())
mainDispatcherTestRule.advanceUntilIdleWithIdlingResources()
val expectedItem = FavouriteContent(ContentData.contentSuccess.first(), false)
homeRobot.assertContainsItem(0, expectedItem)
robot.assertContainsItem(0, expectedItem)
.assertDidNotNavigateToAuth()
}
@ -161,7 +134,7 @@ class MainActivityTest : KoinTest {
activityScenario = ActivityScenario.launch(MainActivity::class.java)
mainDispatcherTestRule.advanceUntilIdleWithIdlingResources()
homeRobot.assertContainsNoItems()
robot.assertContainsNoItems()
.assertContainsError()
.assertDidNotNavigateToAuth()
}
@ -176,14 +149,14 @@ class MainActivityTest : KoinTest {
activityScenario = ActivityScenario.launch(MainActivity::class.java)
mainDispatcherTestRule.advanceUntilIdleWithIdlingResources()
homeRobot.swipeRefresh()
robot.swipeRefresh()
mainDispatcherTestRule.advanceUntilIdleWithIdlingResources()
loopMainThreadFor(2000L)
ContentData.contentSuccess.forEachIndexed { index, content ->
homeRobot.assertContainsItem(index, FavouriteContent(content, false))
robot.assertContainsItem(index, FavouriteContent(content, false))
}
homeRobot.assertDidNotNavigateToAuth()
robot.assertDidNotNavigateToAuth()
}
/** GIVEN success then error WHEN retried THEN error is shown */
@ -196,13 +169,13 @@ class MainActivityTest : KoinTest {
activityScenario = ActivityScenario.launch(MainActivity::class.java)
mainDispatcherTestRule.advanceUntilIdleWithIdlingResources()
homeRobot.swipeRefresh()
robot.swipeRefresh()
mainDispatcherTestRule.advanceUntilIdleWithIdlingResources()
loopMainThreadUntilIdleWithIdlingResources()
mainDispatcherTestRule.advanceTimeBy(1000L)
loopMainThreadFor(1000)
homeRobot
robot
.assertContainsError()
.assertContainsNoItems()
.assertDidNotNavigateToAuth()
@ -221,9 +194,9 @@ class MainActivityTest : KoinTest {
mainDispatcherTestRule.advanceUntilIdleWithIdlingResources()
ContentData.contentSuccess.forEachIndexed { index, content ->
homeRobot.assertContainsItem(index, FavouriteContent(content, false))
robot.assertContainsItem(index, FavouriteContent(content, false))
}
homeRobot.assertDidNotNavigateToAuth()
robot.assertDidNotNavigateToAuth()
}
/** GIVEN unauthenticated then error WHEN loaded THEN navigated to auth */
@ -235,6 +208,6 @@ class MainActivityTest : KoinTest {
activityScenario = ActivityScenario.launch(MainActivity::class.java)
mainDispatcherTestRule.advanceUntilIdleWithIdlingResources()
homeRobot.assertNavigatedToAuth()
robot.assertNavigatedToAuth()
}
}

View file

@ -1,69 +1,41 @@
package org.fnives.test.showcase.ui.login
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import androidx.test.core.app.ActivityScenario
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.fnives.test.showcase.R
import org.fnives.test.showcase.network.mockserver.scenario.auth.AuthScenario
import org.fnives.test.showcase.testutils.MockServerScenarioSetupTestRule
import org.fnives.test.showcase.testutils.ReloadKoinModulesIfNecessaryTestRule
import org.fnives.test.showcase.testutils.configuration.SpecificTestConfigurationsFactory
import org.fnives.test.showcase.testutils.idling.Disposable
import org.fnives.test.showcase.testutils.idling.NetworkSynchronization
import org.fnives.test.showcase.testutils.MockServerScenarioSetupResetingTestRule
import org.fnives.test.showcase.testutils.idling.MainDispatcherTestRule
import org.fnives.test.showcase.testutils.robot.RobotTestRule
import org.fnives.test.showcase.testutils.safeClose
import org.fnives.test.showcase.ui.auth.AuthActivity
import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.rules.RuleChain
import org.junit.runner.RunWith
import org.koin.test.KoinTest
@Suppress("TestFunctionName")
@RunWith(AndroidJUnit4::class)
class AuthActivityTest : KoinTest {
class AuthActivityInstrumentedTest : KoinTest {
private lateinit var activityScenario: ActivityScenario<AuthActivity>
@Rule
@JvmField
val instantTaskExecutorRule = InstantTaskExecutorRule()
private val mockServerScenarioSetupTestRule = MockServerScenarioSetupResetingTestRule()
private val mockServerScenarioSetup get() = mockServerScenarioSetupTestRule.mockServerScenarioSetup
private val mainDispatcherTestRule = MainDispatcherTestRule()
private val robot = LoginRobot()
@Rule
@JvmField
val snackbarVerificationTestRule = SpecificTestConfigurationsFactory.createSnackbarVerification()
@Rule
@JvmField
val robotRule = RobotTestRule(LoginRobot())
private val loginRobot get() = robotRule.robot
@Rule
@JvmField
val mockServerScenarioSetupTestRule = MockServerScenarioSetupTestRule()
val mockServerScenarioSetup get() = mockServerScenarioSetupTestRule.mockServerScenarioSetup
@Rule
@JvmField
val mainDispatcherTestRule = SpecificTestConfigurationsFactory.createMainDispatcherTestRule()
@Rule
@JvmField
val reloadKoinModulesIfNecessaryTestRule = ReloadKoinModulesIfNecessaryTestRule()
private lateinit var disposable: Disposable
@Before
fun setUp() {
SpecificTestConfigurationsFactory.createServerTypeConfiguration()
.invoke(mockServerScenarioSetup)
disposable = NetworkSynchronization.registerNetworkingSynchronization()
}
val ruleOrder: RuleChain = RuleChain.outerRule(mockServerScenarioSetupTestRule)
.around(mainDispatcherTestRule)
.around(RobotTestRule(robot))
@After
fun tearDown() {
activityScenario.close()
disposable.dispose()
activityScenario.safeClose()
}
/** GIVEN non empty password and username and successful response WHEN signIn THEN no error is shown and navigating to home */
@ -73,7 +45,7 @@ class AuthActivityTest : KoinTest {
AuthScenario.Success(password = "alma", username = "banan")
)
activityScenario = ActivityScenario.launch(AuthActivity::class.java)
loginRobot
robot
.setPassword("alma")
.setUsername("banan")
.assertPassword("alma")
@ -81,22 +53,22 @@ class AuthActivityTest : KoinTest {
.clickOnLogin()
.assertLoadingBeforeRequests()
mainDispatcherTestRule.advanceUntilIdleOrActivityIsDestroyed()
loginRobot.assertNavigatedToHome()
mainDispatcherTestRule.advanceUntilIdleWithIdlingResources()
robot.assertNavigatedToHome()
}
/** GIVEN empty password and username WHEN signIn THEN error password is shown */
@Test
fun emptyPasswordShowsProperErrorMessage() {
activityScenario = ActivityScenario.launch(AuthActivity::class.java)
loginRobot
robot
.setUsername("banan")
.assertUsername("banan")
.clickOnLogin()
.assertLoadingBeforeRequests()
mainDispatcherTestRule.advanceUntilIdleWithIdlingResources()
loginRobot.assertErrorIsShown(R.string.password_is_invalid)
robot.assertErrorIsShown(R.string.password_is_invalid)
.assertNotNavigatedToHome()
.assertNotLoading()
}
@ -105,14 +77,14 @@ class AuthActivityTest : KoinTest {
@Test
fun emptyUserNameShowsProperErrorMessage() {
activityScenario = ActivityScenario.launch(AuthActivity::class.java)
loginRobot
robot
.setPassword("banan")
.assertPassword("banan")
.clickOnLogin()
.assertLoadingBeforeRequests()
mainDispatcherTestRule.advanceUntilIdleWithIdlingResources()
loginRobot.assertErrorIsShown(R.string.username_is_invalid)
robot.assertErrorIsShown(R.string.username_is_invalid)
.assertNotNavigatedToHome()
.assertNotLoading()
}
@ -124,7 +96,7 @@ class AuthActivityTest : KoinTest {
AuthScenario.InvalidCredentials(username = "alma", password = "banan")
)
activityScenario = ActivityScenario.launch(AuthActivity::class.java)
loginRobot
robot
.setUsername("alma")
.setPassword("banan")
.assertUsername("alma")
@ -133,7 +105,7 @@ class AuthActivityTest : KoinTest {
.assertLoadingBeforeRequests()
mainDispatcherTestRule.advanceUntilIdleWithIdlingResources()
loginRobot.assertErrorIsShown(R.string.credentials_invalid)
robot.assertErrorIsShown(R.string.credentials_invalid)
.assertNotNavigatedToHome()
.assertNotLoading()
}
@ -145,7 +117,7 @@ class AuthActivityTest : KoinTest {
AuthScenario.GenericError(username = "alma", password = "banan")
)
activityScenario = ActivityScenario.launch(AuthActivity::class.java)
loginRobot
robot
.setUsername("alma")
.setPassword("banan")
.assertUsername("alma")
@ -154,7 +126,7 @@ class AuthActivityTest : KoinTest {
.assertLoadingBeforeRequests()
mainDispatcherTestRule.advanceUntilIdleWithIdlingResources()
loginRobot.assertErrorIsShown(R.string.something_went_wrong)
robot.assertErrorIsShown(R.string.something_went_wrong)
.assertNotNavigatedToHome()
.assertNotLoading()
}

View file

@ -15,26 +15,18 @@ 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.LoginRobotConfiguration
import org.fnives.test.showcase.testutils.configuration.SnackbarVerificationHelper
import org.fnives.test.showcase.testutils.configuration.SnackbarVerificationTestRule
import org.fnives.test.showcase.testutils.configuration.SpecificTestConfigurationsFactory
import org.fnives.test.showcase.testutils.configuration.TestConfigurationsFactory
import org.fnives.test.showcase.testutils.robot.Robot
import org.fnives.test.showcase.testutils.viewactions.ReplaceProgressBarDrawableToStatic
import org.fnives.test.showcase.testutils.viewactions.notIntended
import org.fnives.test.showcase.ui.home.MainActivity
import org.hamcrest.core.IsNot.not
class LoginRobot(
private val loginRobotConfiguration: LoginRobotConfiguration,
private val snackbarVerificationTestRule: SnackbarVerificationTestRule
private val snackbarVerificationHelper: SnackbarVerificationHelper = SnackbarVerificationTestRule()
) : Robot {
constructor(testConfigurationsFactory: TestConfigurationsFactory = SpecificTestConfigurationsFactory) :
this(
loginRobotConfiguration = testConfigurationsFactory.createLoginRobotConfiguration(),
snackbarVerificationTestRule = testConfigurationsFactory.createSnackbarVerification()
)
override fun init() {
Intents.init()
setupIntentResults()
@ -49,6 +41,18 @@ class LoginRobot(
Intents.release()
}
/**
* Needed because Espresso idling waits until mainThread is idle.
*
* However, ProgressBar keeps the main thread active since it's animating.
*
* Another solution is described here: https://proandroiddev.com/progressbar-animations-with-espresso-57f826102187
* In short they replace the inflater to remove animations, by using custom test runner.
*/
fun replaceProgressBar() = apply {
onView(withId(R.id.loading_indicator)).perform(ReplaceProgressBarDrawableToStatic())
}
fun setUsername(username: String): LoginRobot = apply {
onView(withId(R.id.user_edit_text))
.perform(ViewActions.replaceText(username), ViewActions.closeSoftKeyboard())
@ -60,6 +64,7 @@ class LoginRobot(
}
fun clickOnLogin() = apply {
replaceProgressBar()
onView(withId(R.id.login_cta))
.perform(ViewActions.click())
}
@ -75,14 +80,12 @@ class LoginRobot(
}
fun assertErrorIsShown(@StringRes stringResID: Int) = apply {
snackbarVerificationTestRule.assertIsShownWithText(stringResID)
snackbarVerificationHelper.assertIsShownWithText(stringResID)
}
fun assertLoadingBeforeRequests() = apply {
if (loginRobotConfiguration.assertLoadingBeforeRequest) {
onView(withId(R.id.loading_indicator))
.check(ViewAssertions.matches(isDisplayed()))
}
onView(withId(R.id.loading_indicator))
.check(ViewAssertions.matches(isDisplayed()))
}
fun assertNotLoading() = apply {
@ -91,7 +94,7 @@ class LoginRobot(
}
fun assertErrorIsNotShown() = apply {
snackbarVerificationTestRule.assertIsNotShown()
snackbarVerificationHelper.assertIsNotShown()
}
fun assertNavigatedToHome() = apply {

View file

@ -0,0 +1,94 @@
package org.fnives.test.showcase.ui.splash
import androidx.lifecycle.Lifecycle
import androidx.test.core.app.ActivityScenario
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.fnives.test.showcase.testutils.MockServerScenarioSetupResetingTestRule
import org.fnives.test.showcase.testutils.idling.MainDispatcherTestRule
import org.fnives.test.showcase.testutils.robot.RobotTestRule
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
import org.junit.Rule
import org.junit.Test
import org.junit.rules.RuleChain
import org.junit.runner.RunWith
import org.koin.test.KoinTest
@Suppress("TestFunctionName")
@RunWith(AndroidJUnit4::class)
class SplashActivityInstrumentedTest : KoinTest {
private lateinit var activityScenario: ActivityScenario<SplashActivity>
private val mainDispatcherTestRule = MainDispatcherTestRule()
private val mockServerScenarioSetupTestRule = MockServerScenarioSetupResetingTestRule()
private val robot = SplashRobot()
@Rule
@JvmField
val ruleOrder: RuleChain = RuleChain.outerRule(mockServerScenarioSetupTestRule)
.around(mainDispatcherTestRule)
.around(RobotTestRule(robot))
@After
fun tearDown() {
activityScenario.safeClose()
}
/** GIVEN loggedInState WHEN opened after some time THEN MainActivity is started */
@Test
fun loggedInStateNavigatesToHome() {
setupLogin(mainDispatcherTestRule, mockServerScenarioSetupTestRule.mockServerScenarioSetup)
activityScenario = ActivityScenario.launch(SplashActivity::class.java)
activityScenario.moveToState(Lifecycle.State.RESUMED)
mainDispatcherTestRule.advanceTimeBy(501)
robot.assertHomeIsStarted()
.assertAuthIsNotStarted()
}
/** GIVEN loggedOffState WHEN opened after some time THEN AuthActivity is started */
@Test
fun loggedOutStatesNavigatesToAuthentication() {
setupLogout(mainDispatcherTestRule)
activityScenario = ActivityScenario.launch(SplashActivity::class.java)
activityScenario.moveToState(Lifecycle.State.RESUMED)
mainDispatcherTestRule.advanceTimeBy(501)
robot.assertAuthIsStarted()
.assertHomeIsNotStarted()
}
/** GIVEN loggedOffState and not enough time WHEN opened THEN no activity is started */
@Test
fun loggedOutStatesNotEnoughTime() {
setupLogout(mainDispatcherTestRule)
activityScenario = ActivityScenario.launch(SplashActivity::class.java)
activityScenario.moveToState(Lifecycle.State.RESUMED)
mainDispatcherTestRule.advanceTimeBy(500)
robot.assertAuthIsNotStarted()
.assertHomeIsNotStarted()
}
/** GIVEN loggedInState and not enough time WHEN opened THEN no activity is started */
@Test
fun loggedInStatesNotEnoughTime() {
setupLogin(mainDispatcherTestRule, mockServerScenarioSetupTestRule.mockServerScenarioSetup)
activityScenario = ActivityScenario.launch(SplashActivity::class.java)
activityScenario.moveToState(Lifecycle.State.RESUMED)
mainDispatcherTestRule.advanceTimeBy(500)
robot.assertHomeIsNotStarted()
.assertAuthIsNotStarted()
}
}

View file

@ -1,128 +0,0 @@
package org.fnives.test.showcase.ui.splash
import androidx.lifecycle.Lifecycle
import androidx.test.core.app.ActivityScenario
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.fnives.test.showcase.testutils.MockServerScenarioSetupTestRule
import org.fnives.test.showcase.testutils.ReloadKoinModulesIfNecessaryTestRule
import org.fnives.test.showcase.testutils.configuration.SpecificTestConfigurationsFactory
import org.fnives.test.showcase.testutils.idling.Disposable
import org.fnives.test.showcase.testutils.idling.NetworkSynchronization
import org.fnives.test.showcase.testutils.robot.RobotTestRule
import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.koin.test.KoinTest
@Suppress("TestFunctionName")
@RunWith(AndroidJUnit4::class)
class SplashActivityTest : KoinTest {
private lateinit var activityScenario: ActivityScenario<SplashActivity>
private val splashRobot: SplashRobot get() = robotTestRule.robot
@Rule
@JvmField
val robotTestRule = RobotTestRule(SplashRobot())
@Rule
@JvmField
val mainDispatcherTestRule = SpecificTestConfigurationsFactory.createMainDispatcherTestRule()
@Rule
@JvmField
val mockServerScenarioSetupTestRule = MockServerScenarioSetupTestRule()
@Rule
@JvmField
val reloadKoinModulesIfNecessaryTestRule = ReloadKoinModulesIfNecessaryTestRule()
lateinit var disposable: Disposable
@Before
fun setUp() {
SpecificTestConfigurationsFactory.createServerTypeConfiguration()
.invoke(mockServerScenarioSetupTestRule.mockServerScenarioSetup)
disposable = NetworkSynchronization.registerNetworkingSynchronization()
}
@After
fun tearDown() {
activityScenario.close()
disposable.dispose()
}
/** GIVEN loggedInState WHEN opened after some time THEN MainActivity is started */
@Test
fun loggedInStateNavigatesToHome() {
splashRobot.setupLoggedInState(mainDispatcherTestRule, mockServerScenarioSetupTestRule.mockServerScenarioSetup)
activityScenario = ActivityScenario.launch(SplashActivity::class.java)
activityScenario.moveToState(Lifecycle.State.RESUMED)
mainDispatcherTestRule.advanceTimeBy(501)
splashRobot.assertHomeIsStarted()
.assertAuthIsNotStarted()
workaroundForActivityScenarioCLoseLockingUp()
}
/** GIVEN loggedOffState WHEN opened after some time THEN AuthActivity is started */
@Test
fun loggedOutStatesNavigatesToAuthentication() {
splashRobot.setupLoggedOutState(mainDispatcherTestRule)
activityScenario = ActivityScenario.launch(SplashActivity::class.java)
activityScenario.moveToState(Lifecycle.State.RESUMED)
mainDispatcherTestRule.advanceTimeBy(501)
splashRobot.assertAuthIsStarted()
.assertHomeIsNotStarted()
workaroundForActivityScenarioCLoseLockingUp()
}
/** GIVEN loggedOffState and not enough time WHEN opened THEN no activity is started */
@Test
fun loggedOutStatesNotEnoughTime() {
splashRobot.setupLoggedOutState(mainDispatcherTestRule)
activityScenario = ActivityScenario.launch(SplashActivity::class.java)
activityScenario.moveToState(Lifecycle.State.RESUMED)
mainDispatcherTestRule.advanceTimeBy(10)
splashRobot.assertAuthIsNotStarted()
.assertHomeIsNotStarted()
}
/** GIVEN loggedInState and not enough time WHEN opened THEN no activity is started */
@Test
fun loggedInStatesNotEnoughTime() {
splashRobot.setupLoggedInState(mainDispatcherTestRule, mockServerScenarioSetupTestRule.mockServerScenarioSetup)
activityScenario = ActivityScenario.launch(SplashActivity::class.java)
activityScenario.moveToState(Lifecycle.State.RESUMED)
mainDispatcherTestRule.advanceTimeBy(10)
splashRobot.assertHomeIsNotStarted()
.assertAuthIsNotStarted()
}
/**
* This should not be needed, we shouldn't use sleep 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.
*
* As soon as that issue is closed, this should be removed as well.
*/
private fun workaroundForActivityScenarioCLoseLockingUp() {
Thread.sleep(1000L)
}
}

View file

@ -1,12 +1,8 @@
package org.fnives.test.showcase.ui.splash
import android.app.Instrumentation
import androidx.test.espresso.intent.Intents
import androidx.test.espresso.intent.matcher.IntentMatchers
import org.fnives.test.showcase.network.mockserver.MockServerScenarioSetup
import org.fnives.test.showcase.testutils.configuration.MainDispatcherTestRule
import org.fnives.test.showcase.testutils.robot.Robot
import org.fnives.test.showcase.testutils.statesetup.SetupAuthenticationState
import org.fnives.test.showcase.testutils.viewactions.notIntended
import org.fnives.test.showcase.ui.auth.AuthActivity
import org.fnives.test.showcase.ui.home.MainActivity
@ -15,33 +11,12 @@ class SplashRobot : Robot {
override fun init() {
Intents.init()
Intents.intending(IntentMatchers.hasComponent(MainActivity::class.java.canonicalName))
.respondWith(Instrumentation.ActivityResult(0, null))
Intents.intending(IntentMatchers.hasComponent(AuthActivity::class.java.canonicalName))
.respondWith(Instrumentation.ActivityResult(0, null))
}
override fun release() {
Intents.release()
}
fun setupLoggedInState(
mainDispatcherTestRule: MainDispatcherTestRule,
mockServerScenarioSetup: MockServerScenarioSetup
) {
SetupAuthenticationState.setupLogin(mainDispatcherTestRule, mockServerScenarioSetup)
release()
init()
}
fun setupLoggedOutState(
mainDispatcherTestRule: MainDispatcherTestRule
) {
SetupAuthenticationState.setupLogout(mainDispatcherTestRule)
release()
init()
}
fun assertHomeIsStarted() = apply {
Intents.intended(IntentMatchers.hasComponent(MainActivity::class.java.canonicalName))
}

View file

@ -8,7 +8,7 @@ buildscript {
maven { url "https://plugins.gradle.org/m2/" }
}
dependencies {
classpath 'com.android.tools.build:gradle:7.0.4'
classpath 'com.android.tools.build:gradle:7.1.0'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
classpath "org.jlleitschuh.gradle:ktlint-gradle:10.2.1"
}
@ -29,21 +29,6 @@ task clean(type: Delete) {
delete rootProject.buildDir
}
task unitTests(dependsOn: ["app:testDebugUnitTest", "core:test", "network:test"]){
group = 'Tests'
description = 'Run all unit tests'
}
task robolectricTests(dependsOn: ["app:testDebugUnitTest"]){
group = 'Tests'
description = 'Run all robolectric tests'
}
task androidTests(dependsOn: ["app:connectedDebugAndroidTest"]){
group = 'Tests'
description = 'Run all Android tests'
}
apply from: 'gradlescripts/versions.gradle'
apply from: 'gradlescripts/detekt.config.gradle'
apply from: 'gradlescripts/ktlint.gradle'

View file

@ -0,0 +1,334 @@
# 4. Starting of integration testing
You probably got bored of Unit Testing if you got to this point, so let's switch it up a little.
In this testing instruction set you will learn how to write simple Integration tests for your Java module:
- How to write integration tests
- How to use Fakes
- How to depend on test modules
- Exercise parametrized tests
- Exercise Junit Extensions
## AuthIntegrationTest test
Our System Under Test will be all Authentication related public classes of Core module, so namely:
- `org.fnives.test.showcase.core.login.IsUserLoggedInUseCase`
- `org.fnives.test.showcase.core.login.LoginUseCase`
- `org.fnives.test.showcase.core.login.LogoutUseCase`
What we want to test here, is that all components hidden behind these classes together let the user login, store their session and logout.
### Setup
So let's open up our test class: `org.fnives.test.showcase.core.integration.CodeKataAuthIntegrationTest`
First, we want to take advantage of our DI module, so let's inject our actual classes:
```kotlin
private val isUserLoggedInUseCase by inject<IsUserLoggedInUseCase>()
private val loginUseCase by inject<LoginUseCase>()
private val logoutUseCase by inject<LogoutUseCase>()
```
Now let's startKoin in our setup method:
```kotlin
@BeforeEach
fun setup() {
startKoin {
modules(
createCoreModule(
baseUrl = BaseUrl(mockServerScenarioSetupExtensions.url),
enableNetworkLogging = true,
favouriteContentLocalStorageProvider = { fakeFavouriteContentLocalStorage },
sessionExpirationListenerProvider = { mockSessionExpirationListener },
userDataLocalStorageProvider = { fakeUserDataLocalStorage }
).toList()
)
}
}
@AfterEach
fun tearDown() {
stopKoin()
}
```
Okay, a couple of things are missing. First of what are those fakes? Let's start with them
#### Fakes
So the `FavouriteContentLocalStorage` and `UserDataLocalStorage` will be injected into our modules.
However, we expect a specific behaviour from them.
So instead of mocking them, let's create simple fakes, that we can use in our tests, as they were the real class.
Let's start with `FakeUserDataLocalStorage`.
###### Let's open `CodeKataUserDataLocalStorage`.
This has to extend the `UserDataLocalStorage` interface, so add that. And the only required implementation is a modifiable field. So add it as a constructor argument and that's it.
```kotlin
class CodeKataUserDataLocalStorage(override var session: Session? = null) : UserDataLocalStorage
```
###### Now let's open `CodeKataFavouriteContentLocalStorage`.
This is a bit more tricky, there are multiple methods.
First of all we need a flow, so let's just use a SharedFlow and initialize it:
```kotlin
private val dataFlow = MutableSharedFlow<List<ContentId>>(
replay = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
init {
dataFlow.tryEmit(emptyList())
}
```
With that we can return our flow from `observeFavourites`:
```kotlin
override fun observeFavourites(): Flow<List<ContentId>> = dataFlow.asSharedFlow()
```
And our methods just need to update the flow as it would be expected:
```kotlin
override suspend fun markAsFavourite(contentId: ContentId) {
dataFlow.emit(dataFlow.replayCache.first().plus(contentId))
}
override suspend fun deleteAsFavourite(contentId: ContentId) {
dataFlow.emit(dataFlow.replayCache.first().minus(contentId))
}
```
Okay, we have our fakes. Let's navigate back to `CodeKataAuthIntegrationTest`
#### Continue Setup
Let's just declare our fakes and initialize them in the setup:
```kotlin
private lateinit var fakeFavouriteContentLocalStorage: FavouriteContentLocalStorage
private lateinit var mockSessionExpirationListener: SessionExpirationListener
private lateinit var fakeUserDataLocalStorage: UserDataLocalStorage
@Before
fun setup() {
mockSessionExpirationListener = mock() // we are using mock, since it only has 1 function so we just want to verify if it's called
fakeFavouriteContentLocalStorage = FakeFavouriteContentLocalStorage()
fakeUserDataLocalStorage = FakeUserDataLocalStorage(null)
startKoin {
///...
}
}
```
We are still missing `mockServerScenarioSetupExtensions` this will be our TestExtension, to initialize MockWebServer.
`MockServerScenarioSetupExtensions` is declared in the `:network` test module.
However we are still able to import it.
That's because of [java-test-fixtures](https://docs.gradle.org/current/userguide/java_testing.html#sec:java_test_fixtures) plugin. It is can be used to depend on a specific test module "textFixtures".
Check out the build.gradle's to see how that's done.
This can be useful to share some static Test Data, or extensions in our case.
So let's add this extension:
```kotlin
@RegisterExtension
@JvmField
val mockServerScenarioSetupExtensions = MockServerScenarioSetupExtensions()
private val mockServerScenarioSetup get() = mockServerScenarioSetupExtensions.mockServerScenarioSetup
```
This extension is a wrapper around MockWebServer containing setups of requests, request verifications and ContentData.
It is useful to mock our requests with this extension from now on so we don't repeat ourselves.
With that let's start testing:
### 1. `withoutSessionTheUserIsNotLoggedIn`
As usual, we start with the simplest test. Let's verify that if the session object is null, we are indeed logged out:
```kotlin
@DisplayName("GIVEN no session saved WHEN checking if user is logged in THEN they are not")
@Test
fun withoutSessionTheUserIsNotLoggedIn() = runTest {
fakeUserDataLocalStorage.session = null
val actual = isUserLoggedInUseCase.invoke()
Assertions.assertFalse(actual, "User is expected to be not logged in")
verifyZeroInteractions(mockSessionExpirationListener)
```
### 2. `loginSuccess`
Let's test that given good credentials and success response, our user can login in.
First we setup our mock server and the expected session:
```kotlin
mockServerScenarioSetup.setScenario(AuthScenario.Success(username = "usr", password = "sEc"), validateArguments = true) // validate arguments just verifies the request path, body, headers etc.
val expectedSession = ContentData.loginSuccessResponse
```
Now we login, and then check if we are actually logged in:
```kotlin
val answer = loginUseCase.invoke(LoginCredentials(username = "usr", password = "sEc"))
val actual = isUserLoggedInUseCase.invoke()
```
And just verify:
```kotlin
Assertions.assertEquals(Answer.Success(LoginStatus.SUCCESS), answer)
Assertions.assertTrue(actual, "User is expected to be logged in")
Assertions.assertEquals(expectedSession, fakeUserDataLocalStorage.session)
verifyZeroInteractions(mockSessionExpirationListener)
```
With this, looks like our Integration works correctly. Requests are called, proper response is received, login state is changed.
### 3. `localInputError`
We have to expected errors, that are returned even before running requests, if the username or password is empty.
This two tests would be really similar, so let's do Parametrized tests.
First we modify our method signature:
```kotlin
@MethodSource("localInputErrorArguments")
@ParameterizedTest(name = "GIVEN {0} credentials WHEN login called THEN error {1} is shown")
fun localInputError(credentials: LoginCredentials, loginError: LoginStatus)
```
Now let's declare our action:
```kotlin
val answer = loginUseCase.invoke(credentials)
val actual = isUserLoggedInUseCase.invoke()
```
And do our verifications, aka not logged in, not session expired and the correct error:
```kotlin
Assertions.assertEquals(Answer.Success(loginError), answer)
Assertions.assertFalse(actual, "User is expected to be not logged in")
Assertions.assertEquals(null, fakeUserDataLocalStorage.session)
verifyZeroInteractions(mockSessionExpirationListener)
```
Now we just need to declare our parameters for our test:
```kotlin
companion object {
@JvmStatic
fun localInputErrorArguments() = Stream.of(
Arguments.of(LoginCredentials("", "password"), LoginStatus.INVALID_USERNAME),
Arguments.of(LoginCredentials("username", ""), LoginStatus.INVALID_PASSWORD)
)
}
```
With that we covered both of these errors.
### 4. `networkInputError`
Now let's do the same with network inputs. This will be really similar, only difference is we will initialize our mockserver with the AuthScenario.
Try to do it yourself, however for completeness sake, as usual, here is the code:
```kotlin
@MethodSource("networkErrorArguments")
@ParameterizedTest(name = "GIVEN {0} network response WHEN login called THEN error is shown")
fun networkInputError(authScenario: AuthScenario) = runTest {
mockServerScenarioSetup.setScenario(authScenario, validateArguments = true)
val credentials = LoginCredentials(username = authScenario.username, password = authScenario.password)
val answer = loginUseCase.invoke(credentials)
val actual = isUserLoggedInUseCase.invoke()
Assertions.assertTrue(answer is Answer.Error, "Answer is expected to be an Error")
Assertions.assertFalse(actual, "User is expected to be not logged in")
Assertions.assertEquals(null, fakeUserDataLocalStorage.session)
verifyZeroInteractions(mockSessionExpirationListener)
}
```
### 5. `loginInvalidCredentials`
We have one more expected error type, but this comes from the NetworkResponse. We could add it as parametrized test, but for the sake of readability, let's just keep it separate.
Thi is really similar to the `networkInputError`, the differences are that this is not parametrized, we use `AuthScenario.InvalidCredentials` response and we expect `Answer.Success(LoginStatus.INVALID_CREDENTIALS)`
So together:
```kotlin
@DisplayName("GIVEN no session WHEN user is logging in THEN they get session")
@Test
fun loginInvalidCredentials() = runTest {
mockServerScenarioSetup.setScenario(AuthScenario.InvalidCredentials(username = "usr", password = "sEc"), validateArguments = true)
val answer = loginUseCase.invoke(LoginCredentials(username = "usr", password = "sEc"))
val actual = isUserLoggedInUseCase.invoke()
Assertions.assertEquals(Answer.Success(LoginStatus.INVALID_CREDENTIALS), answer)
Assertions.assertFalse(actual, "User is expected to be not logged in")
Assertions.assertEquals(null, fakeUserDataLocalStorage.session)
verifyZeroInteractions(mockSessionExpirationListener)
}
```
### 6. `logout`
Now let's verify if the user can logout properly.
For this we first need to have the user in a logged in state:
```kotlin
mockServerScenarioSetup.setScenario(AuthScenario.Success(username = "usr", password = "sEc"), validateArguments = true)
loginUseCase.invoke(LoginCredentials(username = "usr", password = "sEc"))
```
The user needs to logout:
```kotlin
logoutUseCase.invoke()
val actual = isUserLoggedInUseCase.invoke()
```
And we verify the user is indeed logged out now:
```kotlin
Assertions.assertFalse(actual, "User is expected to be logged out")
Assertions.assertEquals(null, fakeUserDataLocalStorage.session)
verifyZeroInteractions(mockSessionExpirationListener)
```
### 7. `logoutReleasesContent`
At last, let's verify that when the user logs out, their cache is released and the request is no longer authenticated.
To do this, first we setup our MockServer and login the user:
```kotlin
mockServerScenarioSetup.setScenario(AuthScenario.Success(username = "usr", password = "sEc"), validateArguments = true)
.setScenario(ContentScenario.Success(usingRefreshedToken = false), validateArguments = true)
loginUseCase.invoke(LoginCredentials(username = "usr", password = "sEc"))
```
Now we get the content values before and after logout:
```kotlin
val valuesBeforeLogout = get<GetAllContentUseCase>().get().take(2).last()
logoutUseCase.invoke()
val valuesAfterLogout = get<GetAllContentUseCase>().get().take(2).last()
```
> Note: we are using get() from koin, since we don't want to depend on how the data is cleared and this way we get the UseCase a new user would get.
Now there is a bit of explaining to do. How `mockServerScenarioSetup` is setup is that if `validateArguments` is set, it will verify the path, the body and the authentication token. If it doesn't match, it will return a BAD Request.
We could do the same with MockWebServer and recorded request as well, it's just now hidden behind our TestHelper MockServer.
So what we want to verify, is that `valuesBeforeLogout` is a success, and the `valuesAfterLogout` is a failure.
```kotlin
Assertions.assertTrue(valuesBeforeLogout is Resource.Success, "Before we expect a cached Success")
Assertions.assertTrue(valuesAfterLogout is Resource.Error, "After we expect an error, since our request no longer is authenticated")
```
If it would be cached, the test would be stuck, cause Loading wouldn't be emitted, or if the request would be authenticated success would be returned as we setup Success response.
## Conclusions
With that we wrote our Integration tests.
There is no point of going over other integration test's in the core module, since the idea is captured, and nothing new could be shown.
If you want to give it a go, feel free, however consider using turbine for flow tests, cause it can be a bit tricky.
What we have learned:
- In integration tests, we mock the least amount of classes
- In integration tests we verify multiple classes and how they work together
- We learned we can share test classes between modules
- We learned how to write fakes
- We exercised the Parametrized tests

View file

@ -0,0 +1,237 @@
# 5. Starting of Robolectric testing
So we are finally here, so far we didn't had to touch any kind of context or resources, activities, fragments or anything android. This is where we have to get back to reality and actually deal with Android.
In this testing instruction set you will learn how to write simple tests using Robolectric.
- We will learn why Robolectric is useful
- Learn how to test Room daos
- Learn how to test Room Migrations
- Learn what a Robolectric Shadow is
- And Learn how to write basic UI tests
## FavouriteContentLocalStorage test
Our System Under Test will be `org.fnives.test.showcase.core.storage.content.FavouriteContentLocalStorage` or more precisely it's implementation: `org.fnives.test.showcase.storage.favourite.FavouriteContentLocalStorageImpl`
What it does is:
- it's an abstraction over the Room DAO
- has 3 methods: observe, add and delete
- it gets the data from Room and updates Room
### Setup
So let's start with the setup.
Our test class is `org.fnives.test.showcase.storage.favourite.CodeKataFavouriteContentLocalStorageInstrumentedTest`
Question: Why don't we test the DAO and Storage separately using mocking?
Answer: The same logic applies how we didn't test the RetrofitServices just the RemoteSources. The Service just like the DAO is an implementation detail, our code only accesses them through the RemoteSource / LocalStorage abstraction. With this in mind now we only want to test that we interact with the database properly, we don't really care how many DAOs are used.
We don't add anything Robolectric just yet, let's try to do this without it first.
Let's setup or System Under Test as usual:
```kotlin
private lateinit var sut: FavouriteContentLocalStorage // notice we only care about the interface
@Before
fun setup() {
val room = Room.inMemoryDatabaseBuilder(mock(), LocalDatabase::class.java) // we are using inmemory, cause we don't really want to create files.
.allowMainThreadQueries() // we don't really care about threading for now
.build()
sut = FavouriteContentLocalStorageImpl(room.favouriteDao)
}
@Test
fun atTheStartOurDatabaseIsEmpty() = runBlocking<Unit> {
// we just verify our setup is correct
sut.observeFavourites().first()
}
```
Let's run our test and see:
> Method getWritableDatabase in android.database.sqlite.SQLiteOpenHelper not mocked. See http://g.co/androidstudio/not-mocked for details.
> java.lang.RuntimeException: Method getWritableDatabase in android.database.sqlite.SQLiteOpenHelper not mocked. See http://g.co/androidstudio/not-mocked for details.
> at android.database.sqlite.SQLiteOpenHelper.getWritableDatabase(SQLiteOpenHelper.java)
So we need to mock something inside the `SQLiteOpenHelper` which is used inside the Dao and Room in order to test the Database.
Well, I would rather not do that. So then we need to test on a Real Device or Emulator. Well we could, but then we need to integrate a Testing Farm with our CI. It would be good to do that, but sometimes that's just not possible, here is where [Robolectric](http://robolectric.org/) comes in.
>Robolectric is the industry-standard unit testing framework for Android. With Robolectric, your tests run in a simulated Android environment inside a JVM, without the overhead and flakiness of an emulator. Robolectric tests routinely run 10x faster than those on cold-started emulators.
### Setup with Robolectric
We already have the dependencies in the project.
We need to annotate our class with `@RunWith(AndroidJUnit4::class)`
With this Robolectric actually starts our `TestShowcaseApplication` so instead of creating our SUT, we just inject it. However to easily inject with Koin, we extend `KoinTest`:
```kotlin
@RunWith(AndroidJUnit4::class)
class CodeKataFavouriteContentLocalStorage: KoinTest
```
So additional changes will be:
- remove our previous mocking attempt
- we inject our SUT
- we stop koin in tearDown
- we add a testDispatcher to Room
- we switch to runTest(testDispatcher)
Since Room has their own exercutors, that could make our tests flaky, since we might get out of sync. Luckily we can switch out these executors, so we do that to make sure our tests run just as we would like them to.
```
private val sut by inject<FavouriteContentLocalStorage>()
private lateinit var testDispatcher: TestDispatcher
@Before
fun setUp() {
testDispatcher = StandardTestDispatcher(TestCoroutineScheduler())
DatabaseInitialization.dispatcher = testDispatcher
}
@After
fun tearDown() {
stopKoin()
}
@Test
fun atTheStartOurDatabaseIsEmpty()= runTest(testDispatcher) {
sut.observeFavourites().first()
}
```
The line `DatabaseInitialization.dispatcher = testDispatcher` may look a bit mysterious, but all we do her is overwrite our iriginal DatabaseInitialization in tests, and use the given Dispatcher as an executor for Room setup.
Now if we run our test we see we can indeed access the database. We can get down to actual testing.
### 1. `atTheStartOurDatabaseIsEmpty`
Since we used this test for our setup, we just need to finish it. We just verify the returned list is empty, so:
```kotlin
@Test
fun atTheStartOurDatabaseIsEmpty() = runTest(testDispatcher) {
val actual = sut.observeFavourites().first()
Assert.assertEquals(emptyList<ContentId>(), actual)
// note we are using Assert instead of Assertions, that's because Robolectric and AndroidTest support JUnit4 and not JUnit5 we used previously. The @Test @Before etc annotations are also different.
}
```
### 2. `addingContentIdToFavouriteCanBeLaterReadOut`
Time to test some actual logic. Let's see if we add an element to the Database, we indead can query it back.
First we declare what we expect:
```kotlin
val expected = listOf(ContentId("a"))
```
We do the action:
```kotlin
sut.markAsFavourite(ContentId("a"))
val actual = sut.observeFavourites().first()
```
And at the end verify:
```kotlin
Assert.assertEquals(expected, actual)
```
It is as simple as that.
### 3. `contentIdAddedThenRemovedCanNoLongerBeReadOut`
So we can add to the Database, let's see if we can remove from it.
We expect nothing, and we add an element as a setup:
```kotlin
val expected = listOf<ContentId>()
sut.markAsFavourite(ContentId("b"))
```
We do the action:
```kotlin
sut.deleteAsFavourite(ContentId("b"))
val actual = sut.observeFavourites().first()
```
And just verify our expectation:
```kotlin
Assert.assertEquals(expected, actual)
```
So we can delete as well.
### 4. `addingFavouriteUpdatesExistingObservers`
Until now we just verified that afterwards we get the correct data, but what if we already subscribed? Do we still get the correct updates?
So we setup our expectations and our observer:
```kotlin
val expected = listOf(listOf(), listOf(ContentId("observe")))
val actual = async(coroutineContext) { sut.observeFavourites().take(2).toList() }
advanceUntilIdle() // we sync, so we get the first element that is in the database (which is the emptyList).
```
Now we do the action and synchronize again, so our observer is potentially updated:
```kotlin
sut.markAsFavourite(ContentId("a"))
advanceUntilIdle()
```
And let's assert that indeed we only get these two updates and no more things happening. To do this we won't wait for the async, but just get it's Completed value, aka ensure it is finished.
```kotlin
Assert.assertEquals(expected, actual.getCompleted())
```
##### Note: we can use turbine as well to verify our flows, just like we did previously
### 5. `removingFavouriteUpdatesExistingObservers`
Okay, this should be really similar to `addingFavouriteUpdatesExistingObservers` just with a hint of `contentIdAddedThenRemovedCanNoLongerBeReadOut` so try to write it on your own.
However for completness sake:
```kotlin
val expected = listOf(listOf(ContentId("a")), listOf())
sut.markAsFavourite(ContentId("a"))
val actual = async(coroutineContext) {
sut.observeFavourites().take(2).toList()
}
advanceUntilIdle()
sut.deleteAsFavourite(ContentId("a"))
advanceUntilIdle()
Assert.assertEquals(expected, actual.getCompleted())
```
### 6.`noUnexpectedUpdates`
Until now, just like with Flow tests in core, we assumed the number of updates.
So it's time to verify that we don't get unexpected updates on our flow.
To do this we don't really care about the results, just that the number of updates are correct. So let's observe the database with the Correct Update Count + 1.
```kotlin
val actual = async(coroutineContext) { sut.observeFavourites().take(4).toList() }
advanceUntilIdle() // we expect to get our first result with emptyList()
```
We modify the database:
```kotlin
sut.markAsFavourite(ContentId("a"))
advanceUntilIdle() // we expect to get our second update with added ContentID
sut.deleteAsFavourite(ContentId("a"))
advanceUntilIdle() // we expect to get our third update with emptyList again
```
And now we verify that the observation did not complete, aka no 4th update was received:
```kotlin
Assert.assertFalse(actual.isCompleted)
actual.cancel()
```
With that we know how to verify our Database running on the JVM, without needing an emulator or device.
## Conclusion

View file

@ -1 +0,0 @@
TODO

View file

@ -2,6 +2,7 @@ plugins {
id 'java-library'
id 'kotlin'
id 'kotlin-kapt'
id 'java-test-fixtures'
}
java {
@ -26,4 +27,9 @@ dependencies {
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"
testImplementation project(':mockserver')
testFixturesApi testFixtures(project(':network'))
}

View file

@ -2,6 +2,7 @@ package org.fnives.test.showcase.core.content
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import org.fnives.test.showcase.core.storage.content.FavouriteContentLocalStorage
import org.fnives.test.showcase.model.content.Content
import org.fnives.test.showcase.model.content.ContentId
@ -18,6 +19,7 @@ class GetAllContentUseCase internal constructor(
favouriteContentLocalStorage.observeFavourites(),
::combineContentWithFavourites
)
.distinctUntilChanged()
companion object {
private fun combineContentWithFavourites(

View file

@ -1,4 +1,4 @@
package org.fnives.test.showcase.core.di.koin
package org.fnives.test.showcase.core.di
import org.fnives.test.showcase.core.content.AddContentToFavouriteUseCase
import org.fnives.test.showcase.core.content.ContentRepository

View file

@ -1,6 +1,6 @@
package org.fnives.test.showcase.core.login
import org.fnives.test.showcase.core.di.koin.repositoryModule
import org.fnives.test.showcase.core.di.repositoryModule
import org.fnives.test.showcase.core.storage.UserDataLocalStorage
import org.koin.core.context.loadKoinModules

View file

@ -0,0 +1,192 @@
package org.fnives.test.showcase.core.integration
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.last
import kotlinx.coroutines.flow.take
import kotlinx.coroutines.test.runTest
import org.fnives.test.showcase.core.content.GetAllContentUseCase
import org.fnives.test.showcase.core.di.createCoreModule
import org.fnives.test.showcase.core.integration.fake.FakeFavouriteContentLocalStorage
import org.fnives.test.showcase.core.integration.fake.FakeUserDataLocalStorage
import org.fnives.test.showcase.core.login.IsUserLoggedInUseCase
import org.fnives.test.showcase.core.login.LoginUseCase
import org.fnives.test.showcase.core.login.LogoutUseCase
import org.fnives.test.showcase.core.session.SessionExpirationListener
import org.fnives.test.showcase.core.storage.UserDataLocalStorage
import org.fnives.test.showcase.core.storage.content.FavouriteContentLocalStorage
import org.fnives.test.showcase.model.auth.LoginCredentials
import org.fnives.test.showcase.model.auth.LoginStatus
import org.fnives.test.showcase.model.network.BaseUrl
import org.fnives.test.showcase.model.shared.Answer
import org.fnives.test.showcase.model.shared.Resource
import org.fnives.test.showcase.network.mockserver.ContentData
import org.fnives.test.showcase.network.mockserver.scenario.auth.AuthScenario
import org.fnives.test.showcase.network.mockserver.scenario.content.ContentScenario
import org.fnives.test.showcase.network.testutil.MockServerScenarioSetupExtensions
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.RegisterExtension
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.Arguments
import org.junit.jupiter.params.provider.MethodSource
import org.koin.core.context.startKoin
import org.koin.core.context.stopKoin
import org.koin.test.KoinTest
import org.koin.test.get
import org.koin.test.inject
import org.mockito.kotlin.mock
import org.mockito.kotlin.verifyZeroInteractions
import java.util.stream.Stream
@OptIn(ExperimentalCoroutinesApi::class)
class AuthIntegrationTest : KoinTest {
@RegisterExtension
@JvmField
val mockServerScenarioSetupExtensions = MockServerScenarioSetupExtensions()
private val mockServerScenarioSetup get() = mockServerScenarioSetupExtensions.mockServerScenarioSetup
private lateinit var fakeFavouriteContentLocalStorage: FavouriteContentLocalStorage
private lateinit var mockSessionExpirationListener: SessionExpirationListener
private lateinit var fakeUserDataLocalStorage: UserDataLocalStorage
private val isUserLoggedInUseCase by inject<IsUserLoggedInUseCase>()
private val loginUseCase by inject<LoginUseCase>()
private val logoutUseCase by inject<LogoutUseCase>()
@BeforeEach
fun setup() {
mockSessionExpirationListener = mock()
fakeFavouriteContentLocalStorage = FakeFavouriteContentLocalStorage()
fakeUserDataLocalStorage = FakeUserDataLocalStorage(null)
startKoin {
modules(
createCoreModule(
baseUrl = BaseUrl(mockServerScenarioSetupExtensions.url),
enableNetworkLogging = true,
favouriteContentLocalStorageProvider = { fakeFavouriteContentLocalStorage },
sessionExpirationListenerProvider = { mockSessionExpirationListener },
userDataLocalStorageProvider = { fakeUserDataLocalStorage }
).toList()
)
}
}
@AfterEach
fun tearDown() {
stopKoin()
}
@DisplayName("GIVEN no session saved WHEN checking if user is logged in THEN they are not")
@Test
fun withoutSessionTheUserIsNotLoggedIn() = runTest {
fakeUserDataLocalStorage.session = null
val actual = isUserLoggedInUseCase.invoke()
Assertions.assertFalse(actual, "User is expected to be not logged in")
verifyZeroInteractions(mockSessionExpirationListener)
}
@DisplayName("GIVEN no session WHEN user is logging in THEN they get session")
@Test
fun loginSuccess() = runTest {
mockServerScenarioSetup.setScenario(AuthScenario.Success(username = "usr", password = "sEc"), validateArguments = true)
val expectedSession = ContentData.loginSuccessResponse
val answer = loginUseCase.invoke(LoginCredentials(username = "usr", password = "sEc"))
val actual = isUserLoggedInUseCase.invoke()
Assertions.assertEquals(Answer.Success(LoginStatus.SUCCESS), answer)
Assertions.assertTrue(actual, "User is expected to be logged in")
Assertions.assertEquals(expectedSession, fakeUserDataLocalStorage.session)
verifyZeroInteractions(mockSessionExpirationListener)
}
@MethodSource("localInputErrorArguments")
@ParameterizedTest(name = "GIVEN {0} credentials WHEN login called THEN error {1} is shown")
fun localInputError(credentials: LoginCredentials, loginError: LoginStatus) = runTest {
val answer = loginUseCase.invoke(credentials)
val actual = isUserLoggedInUseCase.invoke()
Assertions.assertEquals(Answer.Success(loginError), answer)
Assertions.assertFalse(actual, "User is expected to be not logged in")
Assertions.assertEquals(null, fakeUserDataLocalStorage.session)
verifyZeroInteractions(mockSessionExpirationListener)
}
@MethodSource("networkErrorArguments")
@ParameterizedTest(name = "GIVEN {0} network response WHEN login called THEN error is shown")
fun networkInputError(authScenario: AuthScenario) = runTest {
mockServerScenarioSetup.setScenario(authScenario, validateArguments = true)
val credentials = LoginCredentials(username = authScenario.username, password = authScenario.password)
val answer = loginUseCase.invoke(credentials)
val actual = isUserLoggedInUseCase.invoke()
Assertions.assertTrue(answer is Answer.Error, "Answer is expected to be an Error")
Assertions.assertFalse(actual, "User is expected to be not logged in")
Assertions.assertEquals(null, fakeUserDataLocalStorage.session)
verifyZeroInteractions(mockSessionExpirationListener)
}
@DisplayName("GIVEN no session WHEN user is logging in THEN they get session")
@Test
fun loginInvalidCredentials() = runTest {
mockServerScenarioSetup.setScenario(AuthScenario.InvalidCredentials(username = "usr", password = "sEc"), validateArguments = true)
val answer = loginUseCase.invoke(LoginCredentials(username = "usr", password = "sEc"))
val actual = isUserLoggedInUseCase.invoke()
Assertions.assertEquals(Answer.Success(LoginStatus.INVALID_CREDENTIALS), answer)
Assertions.assertFalse(actual, "User is expected to be not logged in")
Assertions.assertEquals(null, fakeUserDataLocalStorage.session)
verifyZeroInteractions(mockSessionExpirationListener)
}
@DisplayName("GIVEN logged in user WHEN user is login out THEN they no longer have a session")
@Test
fun logout() = runTest {
mockServerScenarioSetup.setScenario(AuthScenario.Success(username = "usr", password = "sEc"), validateArguments = true)
loginUseCase.invoke(LoginCredentials(username = "usr", password = "sEc"))
logoutUseCase.invoke()
val actual = isUserLoggedInUseCase.invoke()
Assertions.assertFalse(actual, "User is expected to be logged out")
Assertions.assertEquals(null, fakeUserDataLocalStorage.session)
verifyZeroInteractions(mockSessionExpirationListener)
}
@DisplayName("GIVEN logged in user WHEN user is login out THEN content is cleared")
@Test
fun logoutReleasesContent() = runTest {
mockServerScenarioSetup.setScenario(AuthScenario.Success(username = "usr", password = "sEc"), validateArguments = true)
.setScenario(ContentScenario.Success(usingRefreshedToken = false), validateArguments = true)
loginUseCase.invoke(LoginCredentials(username = "usr", password = "sEc"))
val valuesBeforeLogout = get<GetAllContentUseCase>().get().take(2).last()
logoutUseCase.invoke()
val valuesAfterLogout = get<GetAllContentUseCase>().get().take(2).last()
Assertions.assertTrue(valuesBeforeLogout is Resource.Success, "Before we expect a cached Success")
Assertions.assertTrue(valuesAfterLogout is Resource.Error, "After we expect an error, since our request no longer is authenticated")
}
companion object {
@JvmStatic
fun localInputErrorArguments() = Stream.of(
Arguments.of(LoginCredentials("", "password"), LoginStatus.INVALID_USERNAME),
Arguments.of(LoginCredentials("username", ""), LoginStatus.INVALID_PASSWORD)
)
@JvmStatic
fun networkErrorArguments() = Stream.of(
Arguments.of(AuthScenario.GenericError(username = "a", password = "b")),
Arguments.of(AuthScenario.UnexpectedJsonAsSuccessResponse(username = "a", password = "b")),
Arguments.of(AuthScenario.MalformedJsonAsSuccessResponse(username = "a", password = "b")),
Arguments.of(AuthScenario.MissingFieldJson(username = "a", password = "b"))
)
}
}

View file

@ -0,0 +1,101 @@
package org.fnives.test.showcase.core.integration
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.fnives.test.showcase.core.di.createCoreModule
import org.fnives.test.showcase.core.integration.fake.FakeFavouriteContentLocalStorage
import org.fnives.test.showcase.core.integration.fake.FakeUserDataLocalStorage
import org.fnives.test.showcase.core.login.IsUserLoggedInUseCase
import org.fnives.test.showcase.core.login.LoginUseCase
import org.fnives.test.showcase.core.login.LogoutUseCase
import org.fnives.test.showcase.core.session.SessionExpirationListener
import org.fnives.test.showcase.core.storage.UserDataLocalStorage
import org.fnives.test.showcase.core.storage.content.FavouriteContentLocalStorage
import org.fnives.test.showcase.model.auth.LoginCredentials
import org.fnives.test.showcase.model.auth.LoginStatus
import org.fnives.test.showcase.model.network.BaseUrl
import org.fnives.test.showcase.network.mockserver.scenario.auth.AuthScenario
import org.fnives.test.showcase.network.testutil.MockServerScenarioSetupExtensions
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Disabled
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.RegisterExtension
import org.koin.core.context.GlobalContext.startKoin
import org.koin.core.context.GlobalContext.stopKoin
import org.koin.test.KoinTest
import org.koin.test.inject
import org.mockito.kotlin.mock
@OptIn(ExperimentalCoroutinesApi::class)
@Disabled("CodeKata")
class CodeKataAuthIntegrationTest : KoinTest {
@RegisterExtension
@JvmField
val mockServerScenarioSetupExtensions = MockServerScenarioSetupExtensions()
private val isUserLoggedInUseCase by inject<IsUserLoggedInUseCase>()
private val loginUseCase by inject<LoginUseCase>()
private val logoutUseCase by inject<LogoutUseCase>()
private lateinit var fakeFavouriteContentLocalStorage: FavouriteContentLocalStorage
private lateinit var mockSessionExpirationListener: SessionExpirationListener
private lateinit var fakeUserDataLocalStorage: UserDataLocalStorage
@BeforeEach
fun setup() {
mockSessionExpirationListener = mock() // we are using mock, since it only has 1 function so we just want to verify if it's called
fakeFavouriteContentLocalStorage = FakeFavouriteContentLocalStorage()
fakeUserDataLocalStorage = FakeUserDataLocalStorage(null)
startKoin {
modules(
createCoreModule(
baseUrl = BaseUrl(mockServerScenarioSetupExtensions.url),
enableNetworkLogging = true,
favouriteContentLocalStorageProvider = { fakeFavouriteContentLocalStorage },
sessionExpirationListenerProvider = { mockSessionExpirationListener },
userDataLocalStorageProvider = { fakeUserDataLocalStorage }
).toList()
)
}
}
@AfterEach
fun tearDown() {
stopKoin()
}
@DisplayName("GIVEN no session saved WHEN checking if user is logged in THEN they are not")
@Test
fun withoutSessionTheUserIsNotLoggedIn() = runTest {
}
@DisplayName("GIVEN no session WHEN user is logging in THEN they get session")
@Test
fun loginSuccess() = runTest {
}
@DisplayName("GIVEN credentials WHEN login called THEN error is shown")
@Test
fun localInputError(credentials: LoginCredentials, loginError: LoginStatus) = runTest {
}
@DisplayName("GIVEN network response WHEN login called THEN error is shown")
@Test
fun networkInputError(authScenario: AuthScenario) = runTest {
}
@DisplayName("GIVEN no session WHEN user is logging in THEN they get session")
@Test
fun loginInvalidCredentials() = runTest {
}
@DisplayName("GIVEN logged in user WHEN user is login out THEN they no longer have a session and content is cleared")
@Test
fun logout() = runTest {
}
@DisplayName("GIVEN logged in user WHEN user is login out THEN content is cleared")
@Test
fun logoutReleasesContent() = runTest {
}
}

View file

@ -0,0 +1,336 @@
package org.fnives.test.showcase.core.integration
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.take
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.fnives.test.showcase.core.content.AddContentToFavouriteUseCase
import org.fnives.test.showcase.core.content.FetchContentUseCase
import org.fnives.test.showcase.core.content.GetAllContentUseCase
import org.fnives.test.showcase.core.content.RemoveContentFromFavouritesUseCase
import org.fnives.test.showcase.core.di.createCoreModule
import org.fnives.test.showcase.core.integration.fake.FakeFavouriteContentLocalStorage
import org.fnives.test.showcase.core.integration.fake.FakeUserDataLocalStorage
import org.fnives.test.showcase.core.session.SessionExpirationListener
import org.fnives.test.showcase.core.testutil.AwaitElementEmitCount
import org.fnives.test.showcase.model.content.ContentId
import org.fnives.test.showcase.model.content.FavouriteContent
import org.fnives.test.showcase.model.network.BaseUrl
import org.fnives.test.showcase.model.session.Session
import org.fnives.test.showcase.model.shared.Resource
import org.fnives.test.showcase.network.mockserver.ContentData
import org.fnives.test.showcase.network.mockserver.scenario.content.ContentScenario
import org.fnives.test.showcase.network.mockserver.scenario.refresh.RefreshTokenScenario
import org.fnives.test.showcase.network.shared.exceptions.NetworkException
import org.fnives.test.showcase.network.testutil.MockServerScenarioSetupExtensions
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.RegisterExtension
import org.koin.core.context.startKoin
import org.koin.core.context.stopKoin
import org.koin.test.KoinTest
import org.koin.test.inject
import org.mockito.kotlin.mock
import org.mockito.kotlin.verifyZeroInteractions
@OptIn(ExperimentalCoroutinesApi::class)
class ContentIntegrationTest : KoinTest {
@RegisterExtension
@JvmField
val mockServerScenarioSetupExtensions = MockServerScenarioSetupExtensions()
private val mockServerScenarioSetup get() = mockServerScenarioSetupExtensions.mockServerScenarioSetup
private lateinit var fakeFavouriteContentLocalStorage: FakeFavouriteContentLocalStorage
private lateinit var mockSessionExpirationListener: SessionExpirationListener
private lateinit var fakeUserDataLocalStorage: FakeUserDataLocalStorage
private val addContentToFavouriteUseCase by inject<AddContentToFavouriteUseCase>()
private val fetchContentUseCase by inject<FetchContentUseCase>()
private val getAllContentUseCase by inject<GetAllContentUseCase>()
private val removeContentFromFavouritesUseCase by inject<RemoveContentFromFavouritesUseCase>()
private val session = Session(accessToken = "login-access", refreshToken = "login-refresh")
@BeforeEach
fun setup() {
mockSessionExpirationListener = mock()
fakeFavouriteContentLocalStorage = FakeFavouriteContentLocalStorage()
fakeUserDataLocalStorage = FakeUserDataLocalStorage(session)
startKoin {
modules(
createCoreModule(
baseUrl = BaseUrl(mockServerScenarioSetupExtensions.url),
enableNetworkLogging = true,
favouriteContentLocalStorageProvider = { fakeFavouriteContentLocalStorage },
sessionExpirationListenerProvider = { mockSessionExpirationListener },
userDataLocalStorageProvider = { fakeUserDataLocalStorage }
).toList()
)
}
}
@AfterEach
fun tearDown() {
stopKoin()
}
@DisplayName("GIVEN normal response without favourites WHEN observed THEN data is returned")
@Test
fun withoutFavouritesDataIsReturned() = runTest {
mockServerScenarioSetup.setScenario(ContentScenario.Success(usingRefreshedToken = false))
val contentData = ContentData.contentSuccess.map { FavouriteContent(it, false) }
val expected = listOf(
Resource.Loading(),
Resource.Success(contentData)
)
val actual = async {
getAllContentUseCase.get()
.take(2)
.toList()
}
Assertions.assertEquals(expected, actual.await())
verifyZeroInteractions(mockSessionExpirationListener)
Assertions.assertSame(session, fakeUserDataLocalStorage.session)
}
@DisplayName("GIVEN normal response without favourites matching WHEN observed THEN data is returned")
@Test
fun withoutFavouritesMatchingDataIsReturned() = runTest {
mockServerScenarioSetup.setScenario(ContentScenario.Success(usingRefreshedToken = false))
addContentToFavouriteUseCase.invoke(ContentId("non-existent-content-id"))
advanceUntilIdle()
val contentData = ContentData.contentSuccess.map { FavouriteContent(it, false) }
val expected = listOf(
Resource.Loading(),
Resource.Success(contentData)
)
val actual = async {
getAllContentUseCase.get()
.take(2)
.toList()
}
Assertions.assertEquals(expected, actual.await())
verifyZeroInteractions(mockSessionExpirationListener)
Assertions.assertSame(session, fakeUserDataLocalStorage.session)
}
@DisplayName("GIVEN normal response without favourites matching WHEN observed loading and modifying favourites THEN no extra loading is emitted")
@Test
fun modifyingFavouritesWhileLoadingDoesntEmitNewValue() = runTest {
mockServerScenarioSetup.setScenario(ContentScenario.Success(usingRefreshedToken = false))
addContentToFavouriteUseCase.invoke(ContentId("non-existent-content-id"))
advanceUntilIdle()
val contentData = ContentData.contentSuccess.mapIndexed { index, it ->
FavouriteContent(it, index == 0)
}
val expected = listOf(
Resource.Loading(),
Resource.Success(contentData)
)
val actual = async {
getAllContentUseCase.get()
.onEach {
if (it is Resource.Loading) {
addContentToFavouriteUseCase.invoke(contentData.first().content.id)
}
}
.take(2)
.toList()
}
Assertions.assertEquals(expected, actual.await())
verifyZeroInteractions(mockSessionExpirationListener)
Assertions.assertSame(session, fakeUserDataLocalStorage.session)
}
@DisplayName("GIVEN normal response without favourites WHEN adding favourite and removing THEN we get proper updates")
@Test
fun addingRemoving() = runTest {
mockServerScenarioSetup.setScenario(ContentScenario.Success(usingRefreshedToken = false))
val startContentData = ContentData.contentSuccess.map {
FavouriteContent(it, isFavourite = false)
}
val addedFavouriteData = startContentData.mapIndexed { index, it ->
if (index == 0) it.copy(isFavourite = true) else it
}
val added2ndFavouriteData = addedFavouriteData.mapIndexed { index, it ->
if (index == 1) it.copy(isFavourite = true) else it
}
val removedFirstFavouriteData = added2ndFavouriteData.mapIndexed { index, it ->
if (index == 0) it.copy(isFavourite = false) else it
}
val expected = listOf(
Resource.Loading(),
Resource.Success(startContentData),
Resource.Success(addedFavouriteData),
Resource.Success(added2ndFavouriteData),
Resource.Success(removedFirstFavouriteData)
)
val actual = async {
getAllContentUseCase.get()
.take(5)
.toList()
}
getAllContentUseCase.get().take(2).toList() // let's await success request
addContentToFavouriteUseCase.invoke(startContentData.first().content.id)
advanceUntilIdle()
addContentToFavouriteUseCase.invoke(startContentData.drop(1).first().content.id)
advanceUntilIdle()
removeContentFromFavouritesUseCase.invoke(startContentData.first().content.id)
advanceUntilIdle()
val verifyCaching = async {
getAllContentUseCase.get().take(1).first()
}
Assertions.assertIterableEquals(expected, actual.await())
Assertions.assertEquals(expected.last(), verifyCaching.await())
verifyZeroInteractions(mockSessionExpirationListener)
Assertions.assertSame(session, fakeUserDataLocalStorage.session)
}
@DisplayName("GIVEN normal response with favourites WHEN getting the data THEN we get proper updates")
@Test
fun alreadySavedFavourites() = runTest {
mockServerScenarioSetup.setScenario(ContentScenario.Success(usingRefreshedToken = false))
addContentToFavouriteUseCase.invoke(ContentData.contentSuccess.first().id)
addContentToFavouriteUseCase.invoke(ContentData.contentSuccess.takeLast(1).first().id)
val favouritedIndexes = listOf(0, ContentData.contentSuccess.size - 1)
val expectedContents = ContentData.contentSuccess.mapIndexed { index, content ->
FavouriteContent(content, favouritedIndexes.contains(index))
}
val expected = listOf(
Resource.Loading(),
Resource.Success(expectedContents),
)
val actual = async {
getAllContentUseCase.get()
.take(2)
.toList()
}
Assertions.assertIterableEquals(expected, actual.await())
verifyZeroInteractions(mockSessionExpirationListener)
Assertions.assertSame(session, fakeUserDataLocalStorage.session)
}
@DisplayName("GIVEN error response WHEN fetching THEN the data is received")
@Test
fun errorFetch() = runTest {
mockServerScenarioSetup.setScenario(
ContentScenario.Error(usingRefreshedToken = false)
.then(ContentScenario.Success(usingRefreshedToken = false))
)
val expectedContents = ContentData.contentSuccess.map { content ->
FavouriteContent(content, false)
}
val expected = listOf(
Resource.Loading(),
Resource.Error(mock()),
Resource.Loading(),
Resource.Success(expectedContents),
)
val awaitElementEmitionCount = AwaitElementEmitCount(2)
val actual = async {
getAllContentUseCase.get()
.take(4)
.let(awaitElementEmitionCount::attach)
.toList()
}
awaitElementEmitionCount.await() // await 2 emissions, aka the request to finish
fetchContentUseCase.invoke()
val actualValues = actual.await()
Assertions.assertEquals(expected[0], actualValues[0])
Assertions.assertTrue(actualValues[1] is Resource.Error, "Resource is Error")
Assertions.assertTrue((actualValues[1] as Resource.Error).error is NetworkException, "Resource is Network Error")
Assertions.assertEquals(expected[2], actualValues[2])
Assertions.assertEquals(expected[3], actualValues[3])
verifyZeroInteractions(mockSessionExpirationListener)
Assertions.assertSame(session, fakeUserDataLocalStorage.session)
}
@DisplayName("GIVEN proper response WHEN fetching THEN the data is received")
@Test
fun fetchingAgain() = runTest {
mockServerScenarioSetup.setScenario(
ContentScenario.Success(usingRefreshedToken = false)
.then(ContentScenario.SuccessWithMissingFields(usingRefreshedToken = false))
)
val expectedContents = ContentData.contentSuccess.map { content ->
FavouriteContent(content, false)
}
val expectedContents2 = ContentData.contentSuccessWithMissingFields.map { content ->
FavouriteContent(content, false)
}
val expected = listOf(
Resource.Loading(),
Resource.Success(expectedContents),
Resource.Loading(),
Resource.Success(expectedContents2),
)
val awaitElementEmitionCount = AwaitElementEmitCount(2)
val actual = async {
getAllContentUseCase.get()
.take(4)
.let(awaitElementEmitionCount::attach)
.toList()
}
awaitElementEmitionCount.await() // await 2 emissions, aka the request to finish
fetchContentUseCase.invoke()
Assertions.assertIterableEquals(expected, actual.await())
}
@DisplayName("GIVEN session expiration then proper response WHEN observing THEN the data is received")
@Test
fun sessionRefreshing() = runTest {
mockServerScenarioSetup.setScenario(RefreshTokenScenario.Success)
.setScenario(
ContentScenario.Unauthorized(usingRefreshedToken = false)
.then(ContentScenario.Success(usingRefreshedToken = true))
)
val expectedContents = ContentData.contentSuccess.map { content ->
FavouriteContent(content, false)
}
val expected = listOf(
Resource.Loading(),
Resource.Success(expectedContents)
)
val actual = async {
getAllContentUseCase.get()
.take(2)
.toList()
}
Assertions.assertIterableEquals(expected, actual.await())
verifyZeroInteractions(mockSessionExpirationListener)
val expectedSession = Session(accessToken = "refreshed-access", refreshToken = "refreshed-refresh")
Assertions.assertEquals(expectedSession, fakeUserDataLocalStorage.session)
}
}

View file

@ -0,0 +1,118 @@
package org.fnives.test.showcase.core.integration
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.last
import kotlinx.coroutines.flow.take
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.test.runTest
import org.fnives.test.showcase.core.content.FetchContentUseCase
import org.fnives.test.showcase.core.content.GetAllContentUseCase
import org.fnives.test.showcase.core.di.createCoreModule
import org.fnives.test.showcase.core.integration.fake.FakeFavouriteContentLocalStorage
import org.fnives.test.showcase.core.integration.fake.FakeUserDataLocalStorage
import org.fnives.test.showcase.core.login.IsUserLoggedInUseCase
import org.fnives.test.showcase.core.login.LoginUseCase
import org.fnives.test.showcase.core.session.SessionExpirationListener
import org.fnives.test.showcase.model.auth.LoginCredentials
import org.fnives.test.showcase.model.network.BaseUrl
import org.fnives.test.showcase.model.shared.Resource
import org.fnives.test.showcase.network.mockserver.scenario.auth.AuthScenario
import org.fnives.test.showcase.network.mockserver.scenario.content.ContentScenario
import org.fnives.test.showcase.network.mockserver.scenario.refresh.RefreshTokenScenario
import org.fnives.test.showcase.network.shared.exceptions.NetworkException
import org.fnives.test.showcase.network.testutil.MockServerScenarioSetupExtensions
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.RegisterExtension
import org.koin.core.context.startKoin
import org.koin.core.context.stopKoin
import org.koin.test.KoinTest
import org.koin.test.inject
import org.mockito.kotlin.mock
import org.mockito.kotlin.times
import org.mockito.kotlin.verify
import org.mockito.kotlin.verifyNoMoreInteractions
import org.mockito.kotlin.verifyZeroInteractions
@OptIn(ExperimentalCoroutinesApi::class)
class SessionExpirationIntegrationTest : KoinTest {
@RegisterExtension
@JvmField
val mockServerScenarioSetupExtensions = MockServerScenarioSetupExtensions()
private val mockServerScenarioSetup get() = mockServerScenarioSetupExtensions.mockServerScenarioSetup
private lateinit var fakeFavouriteContentLocalStorage: FakeFavouriteContentLocalStorage
private lateinit var mockSessionExpirationListener: SessionExpirationListener
private lateinit var fakeUserDataLocalStorage: FakeUserDataLocalStorage
private val isUserLoggedInUseCase by inject<IsUserLoggedInUseCase>()
private val getAllContentUseCase by inject<GetAllContentUseCase>()
private val loginUseCase by inject<LoginUseCase>()
private val fetchContentUseCase by inject<FetchContentUseCase>()
@BeforeEach
fun setup() {
mockSessionExpirationListener = mock()
fakeFavouriteContentLocalStorage = FakeFavouriteContentLocalStorage()
fakeUserDataLocalStorage = FakeUserDataLocalStorage(null)
startKoin {
modules(
createCoreModule(
baseUrl = BaseUrl(mockServerScenarioSetupExtensions.url),
enableNetworkLogging = true,
favouriteContentLocalStorageProvider = { fakeFavouriteContentLocalStorage },
sessionExpirationListenerProvider = { mockSessionExpirationListener },
userDataLocalStorageProvider = { fakeUserDataLocalStorage }
).toList()
)
}
}
@AfterEach
fun tearDown() {
stopKoin()
}
@DisplayName("GIVEN logged in user WHEN fetching but expired THEN user is logged out")
@Test
fun sessionResultsInErrorAndClearsContent() = runTest {
mockServerScenarioSetup.setScenario(AuthScenario.Success(username = "a", password = "b"), validateArguments = true)
loginUseCase.invoke(LoginCredentials(username = "a", password = "b"))
Assertions.assertTrue(isUserLoggedInUseCase.invoke())
verifyZeroInteractions(mockSessionExpirationListener)
mockServerScenarioSetup.setScenario(ContentScenario.Unauthorized(usingRefreshedToken = false))
.setScenario(RefreshTokenScenario.Error)
getAllContentUseCase.get().take(2).toList() // getting session expiration
verify(mockSessionExpirationListener, times(1)).onSessionExpired()
verifyNoMoreInteractions(mockSessionExpirationListener)
Assertions.assertFalse(isUserLoggedInUseCase.invoke(), "User is expected to be logged out")
}
@DisplayName("GIVEN session expiration and failing token-refresh response WHEN requiring data THEN error is returned and data is cleared")
@Test
fun sessionExpirationResultsInLogout() = runTest {
mockServerScenarioSetup.setScenario(AuthScenario.Success(username = "", password = ""), validateArguments = true)
loginUseCase.invoke(LoginCredentials(username = "", password = ""))
mockServerScenarioSetup.setScenario(RefreshTokenScenario.Error)
.setScenario(
ContentScenario.Success(usingRefreshedToken = true)
.then(ContentScenario.Unauthorized(usingRefreshedToken = false))
.then(ContentScenario.Success(usingRefreshedToken = true))
)
getAllContentUseCase.get().take(2).toList() // cachedData
fetchContentUseCase.invoke()
val unauthorizedData = getAllContentUseCase.get().take(2).last()
Assertions.assertTrue(unauthorizedData is Resource.Error, "Resource is Error")
Assertions.assertTrue((unauthorizedData as Resource.Error).error is NetworkException, "Resource is Network Error")
}
}

View file

@ -0,0 +1,3 @@
package org.fnives.test.showcase.core.integration.fake
class CodeKataFavouriteContentLocalStorage

View file

@ -0,0 +1,3 @@
package org.fnives.test.showcase.core.integration.fake
class CodeKataUserDataLocalStorage

View file

@ -0,0 +1,30 @@
package org.fnives.test.showcase.core.integration.fake
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import org.fnives.test.showcase.core.storage.content.FavouriteContentLocalStorage
import org.fnives.test.showcase.model.content.ContentId
class FakeFavouriteContentLocalStorage : FavouriteContentLocalStorage {
private val dataFlow = MutableSharedFlow<List<ContentId>>(
replay = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
init {
dataFlow.tryEmit(emptyList())
}
override fun observeFavourites(): Flow<List<ContentId>> = dataFlow.asSharedFlow()
override suspend fun markAsFavourite(contentId: ContentId) {
dataFlow.emit(dataFlow.replayCache.first().plus(contentId))
}
override suspend fun deleteAsFavourite(contentId: ContentId) {
dataFlow.emit(dataFlow.replayCache.first().minus(contentId))
}
}

View file

@ -0,0 +1,6 @@
package org.fnives.test.showcase.core.integration.fake
import org.fnives.test.showcase.core.storage.UserDataLocalStorage
import org.fnives.test.showcase.model.session.Session
class FakeUserDataLocalStorage(override var session: Session? = null) : UserDataLocalStorage

View file

@ -10,36 +10,30 @@ class CodeKataSecondLoginUseCaseTest {
@BeforeEach
fun setUp() {
TODO()
}
@DisplayName("GIVEN empty username WHEN trying to login THEN invalid username is returned")
@Test
fun emptyUserNameReturnsLoginStatusError() {
TODO()
}
@DisplayName("GIVEN empty password WHEN trying to login THEN invalid password is returned")
@Test
fun emptyPasswordNameReturnsLoginStatusError() {
TODO()
}
@DisplayName("GIVEN invalid credentials response WHEN trying to login THEN invalid credentials is returned ")
@Test
fun invalidLoginResponseReturnInvalidCredentials() {
TODO()
}
@DisplayName("GIVEN success response WHEN trying to login THEN session is saved and success is returned")
@Test
fun validResponseResultsInSavingSessionAndSuccessReturned() {
TODO()
}
@DisplayName("GIVEN error resposne WHEN trying to login THEN session is not touched and error is returned")
@DisplayName("GIVEN error response WHEN trying to login THEN session is not touched and error is returned")
@Test
fun invalidResponseResultsInErrorReturned() {
TODO()
}
}

View file

@ -3,7 +3,7 @@ package org.fnives.test.showcase.core.login
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.fnives.test.showcase.core.content.ContentRepository
import org.fnives.test.showcase.core.di.koin.createCoreModule
import org.fnives.test.showcase.core.di.createCoreModule
import org.fnives.test.showcase.core.storage.UserDataLocalStorage
import org.fnives.test.showcase.model.network.BaseUrl
import org.junit.jupiter.api.AfterEach

View file

@ -0,0 +1,24 @@
package org.fnives.test.showcase.core.testutil
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.onEach
class AwaitElementEmitCount(private var counter: Int) {
private val completableDeferred = CompletableDeferred<Unit>()
init {
assert(counter > 0)
}
fun <T> attach(flow: Flow<T>): Flow<T> =
flow.onEach {
counter--
if (counter == 0) {
completableDeferred.complete(Unit)
}
}
suspend fun await() = completableDeferred.await()
}

View file

@ -1,6 +1,6 @@
#Sun Apr 11 21:03:49 EEST 2021
#Thu Jan 27 21:44:07 EET 2022
distributionBase=GRADLE_USER_HOME
distributionUrl=https\://services.gradle.org/distributions/gradle-7.2-bin.zip
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-7.0.2-all.zip
zipStoreBase=GRADLE_USER_HOME

View file

@ -62,4 +62,21 @@ 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

@ -20,13 +20,13 @@ class MockServerScenarioSetup internal constructor(
var clientCertificates: HandshakeCertificates? = null
private set
fun start(useHttps: Boolean) {
fun start(useHttps: Boolean): String {
val mockWebServer = MockWebServer().also { this.mockWebServer = it }
if (useHttps) {
clientCertificates = mockWebServer.useHttps()
}
mockWebServer.dispatcher = networkDispatcher
mockWebServer.start(InetAddress.getLocalHost(), PORT)
return mockWebServer.url("/").toString()
}
/**
@ -69,9 +69,6 @@ class MockServerScenarioSetup internal constructor(
}
companion object {
const val PORT: Int = 7335
val HTTP_BASE_URL get() = "http://${InetAddress.getLocalHost().canonicalHostName}"
val HTTPS_BASE_URL get() = "https://localhost"
private fun MockWebServer.useHttps(): HandshakeCertificates {
val localhost = InetAddress.getByName("localhost").canonicalHostName

View file

@ -2,6 +2,7 @@ plugins {
id 'java-library'
id 'kotlin'
id 'kotlin-kapt'
id 'java-test-fixtures'
}
java {
@ -25,9 +26,10 @@ dependencies {
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"
testImplementation project(':mockserver')
testImplementation "io.insert-koin:koin-test-junit5:$koin_version"
testImplementation "org.skyscreamer:jsonassert:$testing_json_assert_version"
testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine:$testing_junit5_version"
testFixturesApi project(':mockserver')
testFixturesApi "org.junit.jupiter:junit-jupiter-engine:$testing_junit5_version"
testFixturesApi "io.insert-koin:koin-test-junit5:$koin_version"
}

View file

@ -29,12 +29,17 @@ fun createNetworkModules(
networkSessionExpirationListenerProvider: Scope.() -> NetworkSessionExpirationListener
): Sequence<Module> =
sequenceOf(
baseUrlModule(baseUrl),
loginModule(),
contentModule(),
sessionlessNetworkingModule(baseUrl, enableLogging),
sessionlessNetworkingModule(enableLogging),
sessionNetworkingModule(networkSessionLocalStorageProvider, networkSessionExpirationListenerProvider)
)
private fun baseUrlModule(baseUrl: BaseUrl) = module {
single { baseUrl }
}
private fun loginModule() = module {
factory { LoginRemoteSourceImpl(get(), get()) }
factory<LoginRemoteSource> { get<LoginRemoteSourceImpl>() }
@ -48,7 +53,7 @@ private fun contentModule() = module {
factory<ContentRemoteSource> { get<ContentRemoteSourceImpl>() }
}
private fun sessionlessNetworkingModule(baseUrl: BaseUrl, enableLogging: Boolean) = module {
private fun sessionlessNetworkingModule(enableLogging: Boolean) = module {
factory { MoshiConverterFactory.create() }
single(qualifier = sessionless) {
OkHttpClient.Builder()
@ -58,7 +63,7 @@ private fun sessionlessNetworkingModule(baseUrl: BaseUrl, enableLogging: Boolean
}
single(qualifier = sessionless) {
Retrofit.Builder()
.baseUrl(baseUrl.baseUrl)
.baseUrl(get<BaseUrl>().baseUrl)
.addConverterFactory(get<MoshiConverterFactory>())
.client(get(sessionless))
.build()
@ -83,5 +88,5 @@ private fun sessionNetworkingModule(
single(qualifier = session) { get<Retrofit>(sessionless).newBuilder().client(get(session)).build() }
}
private val session = StringQualifier("SESSION-NETWORKING")
private val sessionless = StringQualifier("SESSIONLESS-NETWORKING")
internal val session = StringQualifier("SESSION-NETWORKING")
internal val sessionless = StringQualifier("SESSIONLESS-NETWORKING")

View file

@ -6,9 +6,9 @@ import org.fnives.test.showcase.network.di.createNetworkModules
import org.fnives.test.showcase.network.mockserver.ContentData
import org.fnives.test.showcase.network.mockserver.scenario.refresh.RefreshTokenScenario
import org.fnives.test.showcase.network.session.NetworkSessionLocalStorage
import org.fnives.test.showcase.network.shared.MockServerScenarioSetupExtensions
import org.fnives.test.showcase.network.shared.exceptions.NetworkException
import org.fnives.test.showcase.network.shared.exceptions.ParsingException
import org.fnives.test.showcase.network.testutil.MockServerScenarioSetupExtensions
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions
import org.junit.jupiter.api.BeforeEach

View file

@ -11,9 +11,9 @@ import org.fnives.test.showcase.network.mockserver.ContentData
import org.fnives.test.showcase.network.mockserver.ContentData.createExpectedLoginRequestJson
import org.fnives.test.showcase.network.mockserver.scenario.auth.AuthScenario
import org.fnives.test.showcase.network.session.NetworkSessionLocalStorage
import org.fnives.test.showcase.network.shared.MockServerScenarioSetupExtensions
import org.fnives.test.showcase.network.shared.exceptions.NetworkException
import org.fnives.test.showcase.network.shared.exceptions.ParsingException
import org.fnives.test.showcase.network.testutil.MockServerScenarioSetupExtensions
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions
import org.junit.jupiter.api.BeforeEach

View file

@ -6,9 +6,9 @@ import org.fnives.test.showcase.network.di.createNetworkModules
import org.fnives.test.showcase.network.mockserver.ContentData
import org.fnives.test.showcase.network.mockserver.scenario.content.ContentScenario
import org.fnives.test.showcase.network.session.NetworkSessionLocalStorage
import org.fnives.test.showcase.network.shared.MockServerScenarioSetupExtensions
import org.fnives.test.showcase.network.shared.exceptions.NetworkException
import org.fnives.test.showcase.network.shared.exceptions.ParsingException
import org.fnives.test.showcase.network.testutil.MockServerScenarioSetupExtensions
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions
import org.junit.jupiter.api.BeforeEach

View file

@ -9,8 +9,8 @@ import org.fnives.test.showcase.network.mockserver.scenario.content.ContentScena
import org.fnives.test.showcase.network.mockserver.scenario.refresh.RefreshTokenScenario
import org.fnives.test.showcase.network.session.NetworkSessionExpirationListener
import org.fnives.test.showcase.network.session.NetworkSessionLocalStorage
import org.fnives.test.showcase.network.shared.MockServerScenarioSetupExtensions
import org.fnives.test.showcase.network.shared.exceptions.NetworkException
import org.fnives.test.showcase.network.testutil.MockServerScenarioSetupExtensions
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions
import org.junit.jupiter.api.BeforeEach

View file

@ -1,4 +1,4 @@
package org.fnives.test.showcase.network.shared
package org.fnives.test.showcase.network.testutil
import org.fnives.test.showcase.network.mockserver.MockServerScenarioSetup
import org.junit.jupiter.api.extension.AfterEachCallback
@ -7,12 +7,12 @@ import org.junit.jupiter.api.extension.ExtensionContext
class MockServerScenarioSetupExtensions : BeforeEachCallback, AfterEachCallback {
val url: String = "${MockServerScenarioSetup.HTTP_BASE_URL}:${MockServerScenarioSetup.PORT}/"
lateinit var url: String
lateinit var mockServerScenarioSetup: MockServerScenarioSetup
override fun beforeEach(context: ExtensionContext?) {
mockServerScenarioSetup = MockServerScenarioSetup()
mockServerScenarioSetup.start(false)
url = mockServerScenarioSetup.start(false)
}
override fun afterEach(context: ExtensionContext?) {

View file

@ -0,0 +1,68 @@
package org.fnives.test.showcase.network.testutil
import okhttp3.OkHttpClient
import org.fnives.test.showcase.model.network.BaseUrl
import org.fnives.test.showcase.network.mockserver.MockServerScenarioSetup
import org.koin.core.context.loadKoinModules
import org.koin.core.qualifier.StringQualifier
import org.koin.dsl.module
import org.koin.test.KoinTest
import org.koin.test.get
/**
* Gives access to internals of Networking so it can be used in MockWebServer more easily.
*/
object NetworkTestConfigurationHelper : KoinTest {
/**
* For some reason importing these didn't work. Still keeping internal, cause it shouldn't leave the module.
*
* import org.fnives.test.showcase.network.di.session
* import org.fnives.test.showcase.network.di.sessionless
*/
internal val session = StringQualifier("SESSION-NETWORKING")
internal val sessionless = StringQualifier("SESSIONLESS-NETWORKING")
/**
* After koin started, this gives you access for the OkHttpClients, so you can synchronize or keep track of them
*/
fun getOkHttpClients(): List<OkHttpClient> = listOf(
get<OkHttpClient>(sessionless),
get<OkHttpClient>(session)
)
/**
* After koin started, this sets up MockServer to be used with HTTPs.
*
* Url, and injected OkHttpClient is modified for this.
*/
fun startWithHTTPSMockWebServer(): MockServerScenarioSetup {
val mockServerScenarioSetup = MockServerScenarioSetup()
val url = mockServerScenarioSetup.start(true)
val handshakeCertificates = mockServerScenarioSetup.clientCertificates
?: throw IllegalStateException("ClientCertificate should be accessable")
reload(baseUrl = BaseUrl(url)) {
it.newBuilder()
.sslSocketFactory(handshakeCertificates.sslSocketFactory(), handshakeCertificates.trustManager)
.build()
}
return mockServerScenarioSetup
}
private fun reload(baseUrl: BaseUrl, adjustments: (OkHttpClient) -> OkHttpClient) {
val current = get<OkHttpClient>(sessionless)
val adjusted = adjustments(current)
loadKoinModules(
module {
// add https certificate to okhttp
single(qualifier = sessionless) { adjusted }
// replace base url with mockWebServer's
single { baseUrl }
}
)
}
}