diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index efe22c0..40d4072 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -17,7 +17,8 @@ ktlint-gradle = "12.1.0" android-library = "8.3.1" maven-publish = "0.25.2" ui = "1.6.5" - +mockk = "1.13.10" +mvi-kotest = "0.0.2" # its beeing used outside this file ktlint-lib = "1.2.1" @@ -26,9 +27,10 @@ appcompat = { module = "androidx.appcompat:appcompat", version.ref = "appcompat" coroutinesCore = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" } coroutinesTest = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" } kotestRunner = { module = "io.kotest:kotest-runner-junit5", version.ref = "kotest" } -kotlinBom = { group = "org.jetbrains.kotlin", name = "kotlin-bom", version.ref = "kotlin" } -mvi = { group = "com.adidas.mvi", name = "mvi", version.ref = "mvi" } -mviCompose = { group = "com.adidas.mvi", name = "mvi-compose", version.ref = "mvi-compose" } +kotlinBom = { module = "org.jetbrains.kotlin:kotlin-bom", version.ref = "kotlin" } +mvi = { module = "com.adidas.mvi:mvi", version.ref = "mvi" } +mviCompose = { module = "com.adidas.mvi:mvi-compose", version.ref = "mvi-compose" } +mviKotest = { module = "com.adidas.mvi:mvi-kotest", version.ref = "mvi-kotest" } activityCompose = { module = "androidx.activity:activity-compose", version.ref = "activity" } lifecycleRuntimeCompose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycle" } material = { module = "androidx.compose.material:material", version.ref = "material" } @@ -38,6 +40,7 @@ koinAndroid = { module = "io.insert-koin:koin-android", version.ref = "koin-andr koinAnnotations = { module = "io.insert-koin:koin-annotations", version.ref = "koin-annotations" } koinKspCompiler = { module = "io.insert-koin:koin-ksp-compiler", version.ref = "koin-annotations" } koinCompose = { module = "io.insert-koin:koin-androidx-compose", version.ref = "koin-compose" } +mockk = { module = "io.mockk:mockk", version.ref = "mockk" } [plugins] kotlin = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } diff --git a/mvi-sample/build.gradle.kts b/mvi-sample/build.gradle.kts index f1f8c7c..10ae026 100644 --- a/mvi-sample/build.gradle.kts +++ b/mvi-sample/build.gradle.kts @@ -50,6 +50,12 @@ android { applyKSP(this@configure, applicationVariants) } } + + android.testOptions { + unitTests.all { + it.useJUnitPlatform() + } + } } private fun applyKSP( @@ -83,4 +89,8 @@ dependencies { implementation(libs.koinAnnotations) implementation(libs.koinCompose) ksp(libs.koinKspCompiler) + + testImplementation(libs.kotestRunner) + testImplementation(libs.mockk) + testImplementation(libs.mviKotest) } \ No newline at end of file diff --git a/mvi-sample/src/main/kotlin/com/adidas/mvi/sample/login/viewmodel/LoginViewModel.kt b/mvi-sample/src/main/kotlin/com/adidas/mvi/sample/login/viewmodel/LoginViewModel.kt index 36486c5..b97dd29 100644 --- a/mvi-sample/src/main/kotlin/com/adidas/mvi/sample/login/viewmodel/LoginViewModel.kt +++ b/mvi-sample/src/main/kotlin/com/adidas/mvi/sample/login/viewmodel/LoginViewModel.kt @@ -8,6 +8,7 @@ import com.adidas.mvi.State import com.adidas.mvi.reducer.Reducer import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.flow internal class LoginViewModel( @@ -48,7 +49,7 @@ internal class LoginViewModel( private fun executeLogin(intent: LoginIntent.Login) = flow { emit(LoginTransform.SetIsLoggingIn(isLoggingIn = true)) - kotlinx.coroutines.delay(300) + delay(300) emit(LoginTransform.SetIsLoggingIn(isLoggingIn = false)) diff --git a/mvi-sample/src/test/kotlin/com/adidas/mvi/sample/login/viewmodel/CoroutineListener.kt b/mvi-sample/src/test/kotlin/com/adidas/mvi/sample/login/viewmodel/CoroutineListener.kt new file mode 100644 index 0000000..e2d16df --- /dev/null +++ b/mvi-sample/src/test/kotlin/com/adidas/mvi/sample/login/viewmodel/CoroutineListener.kt @@ -0,0 +1,26 @@ +package com.adidas.mvi.sample.login.viewmodel + +import io.kotest.core.listeners.TestListener +import io.kotest.core.test.TestCase +import io.kotest.core.test.TestResult +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.cancel +import kotlinx.coroutines.test.TestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain + +@OptIn(ExperimentalCoroutinesApi::class) +internal class CoroutineListener( + private val testCoroutineDispatcher: TestDispatcher, +) : TestListener { + + override suspend fun beforeContainer(testCase: TestCase) { + Dispatchers.setMain(testCoroutineDispatcher) + } + + override suspend fun afterContainer(testCase: TestCase, result: TestResult) { + Dispatchers.resetMain() + testCoroutineDispatcher.scheduler.cancel() + } +} diff --git a/mvi-sample/src/test/kotlin/com/adidas/mvi/sample/login/viewmodel/LoginViewModelTest.kt b/mvi-sample/src/test/kotlin/com/adidas/mvi/sample/login/viewmodel/LoginViewModelTest.kt new file mode 100644 index 0000000..6a6dc76 --- /dev/null +++ b/mvi-sample/src/test/kotlin/com/adidas/mvi/sample/login/viewmodel/LoginViewModelTest.kt @@ -0,0 +1,59 @@ +package com.adidas.mvi.sample.login.viewmodel + +import com.adidas.mvi.kotest.GivenViewModel +import io.kotest.core.spec.IsolationMode +import io.kotest.core.spec.style.BehaviorSpec +import io.kotest.matchers.shouldBe +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestCoroutineScheduler +import kotlinx.coroutines.test.UnconfinedTestDispatcher + +@OptIn(ExperimentalCoroutinesApi::class) +class LoginViewModelTest : BehaviorSpec({ + + isolationMode = IsolationMode.InstancePerLeaf + + val testCoroutineDispatcher = UnconfinedTestDispatcher(TestCoroutineScheduler()) + listeners(CoroutineListener(testCoroutineDispatcher)) + + fun getViewModel(): LoginViewModel { + return LoginViewModel( + logger = mockk(relaxed = true), + coroutineDispatcher = testCoroutineDispatcher + ) + } + + GivenViewModel(::getViewModel) { + When("no intent is executed") { + ThenState() + } + + WhenIntent(LoginIntent.Close) { + ThenState(LoginSideEffect.Close) + } + + WhenIntent(LoginIntent.Login("username", "password")) { + + ThenState { state -> + state.view.isLoggingIn shouldBe true + } + + testCoroutineDispatcher.scheduler.advanceUntilIdle() + + ThenState() + } + + WhenIntent(LoginIntent.Logout) { + ThenState() + } + + WhenIntent(LoginIntent.Login("", "")) { + testCoroutineDispatcher.scheduler.advanceUntilIdle() + + ThenState(LoginSideEffect.ShowInvalidCredentialsError) + } + + } + +}) \ No newline at end of file