From 4a508283432bfe3569c0c3377f8f219d9edca5d0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 12 Apr 2024 08:56:42 +0000 Subject: [PATCH 01/25] build(deps): bump hilt from 2.51 to 2.51.1 Bumps `hilt` from 2.51 to 2.51.1. Updates `com.google.dagger:hilt-android` from 2.51 to 2.51.1 - [Release notes](https://github.com/google/dagger/releases) - [Changelog](https://github.com/google/dagger/blob/master/CHANGELOG.md) - [Commits](https://github.com/google/dagger/compare/dagger-2.51...dagger-2.51.1) Updates `com.google.dagger:hilt-compiler` from 2.51 to 2.51.1 - [Release notes](https://github.com/google/dagger/releases) - [Changelog](https://github.com/google/dagger/blob/master/CHANGELOG.md) - [Commits](https://github.com/google/dagger/compare/dagger-2.51...dagger-2.51.1) Updates `com.google.dagger:hilt-android-gradle-plugin` from 2.51 to 2.51.1 - [Release notes](https://github.com/google/dagger/releases) - [Changelog](https://github.com/google/dagger/blob/master/CHANGELOG.md) - [Commits](https://github.com/google/dagger/compare/dagger-2.51...dagger-2.51.1) Updates `com.google.dagger.hilt.android` from 2.51 to 2.51.1 - [Release notes](https://github.com/google/dagger/releases) - [Changelog](https://github.com/google/dagger/blob/master/CHANGELOG.md) - [Commits](https://github.com/google/dagger/compare/dagger-2.51...dagger-2.51.1) --- updated-dependencies: - dependency-name: com.google.dagger:hilt-android dependency-type: direct:production update-type: version-update:semver-patch - dependency-name: com.google.dagger:hilt-compiler dependency-type: direct:production update-type: version-update:semver-patch - dependency-name: com.google.dagger:hilt-android-gradle-plugin dependency-type: direct:production update-type: version-update:semver-patch - dependency-name: com.google.dagger.hilt.android dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a5f975b..7c2283e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -7,7 +7,7 @@ retrofit = "2.11.0" okhttp = "4.12.0" compose_bom = "2024.04.00" compose_compiler = "1.5.11" -hilt = "2.51" +hilt = "2.51.1" lifecycle = "2.7.0" navigation = "2.7.7" datastore = "1.0.0" From af837deb59cc8204a9763ea178cb32407d0c4829 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 15 Apr 2024 16:40:24 +0000 Subject: [PATCH 02/25] build(deps): bump mockk from 1.13.9 to 1.13.10 Bumps `mockk` from 1.13.9 to 1.13.10. Updates `io.mockk:mockk-android` from 1.13.9 to 1.13.10 - [Release notes](https://github.com/mockk/mockk/releases) - [Commits](https://github.com/mockk/mockk/compare/1.13.9...1.13.10) Updates `io.mockk:mockk-agent` from 1.13.9 to 1.13.10 - [Release notes](https://github.com/mockk/mockk/releases) - [Commits](https://github.com/mockk/mockk/compare/1.13.9...1.13.10) --- updated-dependencies: - dependency-name: io.mockk:mockk-android dependency-type: direct:production update-type: version-update:semver-patch - dependency-name: io.mockk:mockk-agent dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7c2283e..82393e7 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -16,7 +16,7 @@ androidx_appcompat = "1.6.1" androidx_core = "1.9.0" android_gradle = "8.2.2" chucker = "4.0.0" -mockk = "1.13.9" +mockk = "1.13.10" [plugins] spotless = { id = "com.diffplug.spotless", version = "6.25.0" } From 721e7c6a1606552144a9b1611ceb496e262e42b1 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 15 Apr 2024 16:40:44 +0000 Subject: [PATCH 03/25] build(deps): bump io.gitlab.arturbosch.detekt from 1.23.5 to 1.23.6 Bumps [io.gitlab.arturbosch.detekt](https://github.com/detekt/detekt) from 1.23.5 to 1.23.6. - [Release notes](https://github.com/detekt/detekt/releases) - [Commits](https://github.com/detekt/detekt/compare/v1.23.5...v1.23.6) --- updated-dependencies: - dependency-name: io.gitlab.arturbosch.detekt dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7c2283e..4b8aba3 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -20,7 +20,7 @@ mockk = "1.13.9" [plugins] spotless = { id = "com.diffplug.spotless", version = "6.25.0" } -detekt = { id = "io.gitlab.arturbosch.detekt", version = "1.23.5" } +detekt = { id = "io.gitlab.arturbosch.detekt", version = "1.23.6" } kotlin_serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } android-application = { id = "com.android.application", version.ref = "android_gradle" } android_library = { id = "com.android.library", version.ref = "android_gradle" } From 9c8f0989d9126f7dc8b7e1029396b20b149dfeb7 Mon Sep 17 00:00:00 2001 From: Khairullo Date: Thu, 9 May 2024 11:33:10 +0200 Subject: [PATCH 04/25] build: replaced jakewharton's retrofit converter to square's as a first-party --- .../main/java/com/monstarlab/core/network/OkHttpModule.kt | 6 +++--- gradle/libs.versions.toml | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/core/src/main/java/com/monstarlab/core/network/OkHttpModule.kt b/core/src/main/java/com/monstarlab/core/network/OkHttpModule.kt index bd918bc..56fac5a 100644 --- a/core/src/main/java/com/monstarlab/core/network/OkHttpModule.kt +++ b/core/src/main/java/com/monstarlab/core/network/OkHttpModule.kt @@ -2,7 +2,6 @@ package com.monstarlab.core.network import android.content.Context import com.chuckerteam.chucker.api.ChuckerInterceptor -import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory import com.monstarlab.core.BuildConfig import com.monstarlab.core.config.BuildConfiguration import com.monstarlab.core.network.errorhandling.ApiErrorInterceptor @@ -11,14 +10,15 @@ import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.json.Json import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient import okhttp3.logging.HttpLoggingInterceptor import retrofit2.Retrofit +import retrofit2.converter.kotlinx.serialization.asConverterFactory import java.util.concurrent.TimeUnit import javax.inject.Singleton -import kotlinx.serialization.ExperimentalSerializationApi -import kotlinx.serialization.json.Json @InstallIn(SingletonComponent::class) @Module diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7c2283e..4bb8eef 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -99,8 +99,8 @@ android_datastore_preferences = { module = "androidx.datastore:datastore-prefere #------------------------- # Networking #------------------------- -retrofit_converter = "com.jakewharton.retrofit:retrofit2-kotlinx-serialization-converter:1.0.0" retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" } +retrofit_converter = { module = "com.retrofit_converte.retrofit2:converter-kotlinx-serialization", version.ref = "retrofit" } okhttp_logger = { module = "com.squareup.okhttp3:logging-interceptor", version.ref = "okhttp" } okhttp_mockwebserver = { module = "com.squareup.okhttp3:mockwebserver", version.ref = "okhttp" } #------------------------- From fd2d7eb532fa40a5c0b40b711721dbaded4b2c42 Mon Sep 17 00:00:00 2001 From: Khairullo Date: Thu, 9 May 2024 11:33:37 +0200 Subject: [PATCH 05/25] style: spotless applied --- .../src/main/java/com/monstarlab/core/network/OkHttpModule.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/core/src/main/java/com/monstarlab/core/network/OkHttpModule.kt b/core/src/main/java/com/monstarlab/core/network/OkHttpModule.kt index 56fac5a..f5ef6d8 100644 --- a/core/src/main/java/com/monstarlab/core/network/OkHttpModule.kt +++ b/core/src/main/java/com/monstarlab/core/network/OkHttpModule.kt @@ -10,8 +10,6 @@ import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent -import kotlinx.serialization.ExperimentalSerializationApi -import kotlinx.serialization.json.Json import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient import okhttp3.logging.HttpLoggingInterceptor @@ -19,6 +17,8 @@ import retrofit2.Retrofit import retrofit2.converter.kotlinx.serialization.asConverterFactory import java.util.concurrent.TimeUnit import javax.inject.Singleton +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.json.Json @InstallIn(SingletonComponent::class) @Module From e80f2ee0846b472ea0ca9cf6372bf34ed259af9f Mon Sep 17 00:00:00 2001 From: Khairullo Date: Thu, 9 May 2024 11:45:14 +0200 Subject: [PATCH 06/25] build: fixed typo in retrofit dependency --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 4bb8eef..0d49bde 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -100,7 +100,7 @@ android_datastore_preferences = { module = "androidx.datastore:datastore-prefere # Networking #------------------------- retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" } -retrofit_converter = { module = "com.retrofit_converte.retrofit2:converter-kotlinx-serialization", version.ref = "retrofit" } +retrofit_converter = { module = "com.squareup.retrofit2:converter-kotlinx-serialization", version.ref = "retrofit" } okhttp_logger = { module = "com.squareup.okhttp3:logging-interceptor", version.ref = "okhttp" } okhttp_mockwebserver = { module = "com.squareup.okhttp3:mockwebserver", version.ref = "okhttp" } #------------------------- From dbf259128e5c4f2d7e0b8ebb510985c470904497 Mon Sep 17 00:00:00 2001 From: Khairullo Date: Thu, 9 May 2024 14:12:25 +0200 Subject: [PATCH 07/25] test: covered LoginUseCase with mockk tests --- .../login/domain/usecase/LoginUseCaseTest.kt | 109 ++++++++++++++++++ 1 file changed, 109 insertions(+) create mode 100644 app/src/test/java/com/monstarlab/features/login/domain/usecase/LoginUseCaseTest.kt diff --git a/app/src/test/java/com/monstarlab/features/login/domain/usecase/LoginUseCaseTest.kt b/app/src/test/java/com/monstarlab/features/login/domain/usecase/LoginUseCaseTest.kt new file mode 100644 index 0000000..6db5c87 --- /dev/null +++ b/app/src/test/java/com/monstarlab/features/login/domain/usecase/LoginUseCaseTest.kt @@ -0,0 +1,109 @@ +package com.monstarlab.features.login.domain.usecase + +import com.monstarlab.features.auth.domain.models.AuthToken +import com.monstarlab.features.auth.domain.repository.AuthRepository +import com.monstarlab.features.user.domain.repository.UserRepository +import io.mockk.clearAllMocks +import io.mockk.coEvery +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +class LoginUseCaseTest { + + private lateinit var authRepository: AuthRepository + private lateinit var userRepository: UserRepository + private lateinit var loginUseCase: LoginUseCase + + @Before + fun setUp() { + authRepository = mockk() + userRepository = mockk() + loginUseCase = LoginUseCase(authRepository, userRepository) + } + + @After + fun tearDown() { + clearAllMocks() + } + + /** + * GIVEN + * - Auth token is returned + * - Retrieving user is returned + * WHEN + * - User logs in + * THEN + * - Result is successful + */ + @Test + fun testLoginSuccess() = runTest { + // GIVEN + val email = "test@test.com" + val password = "password" + + coEvery { authRepository.login(email, password) } returns AuthToken("token") + coEvery { userRepository.get() } returns mockk() + + // WHEN + val result = loginUseCase.invoke(email, password) + + // THEN + assertEquals("Result is successful", true, result.isSuccess) + assertTrue(result.isSuccess) + } + + /** + * GIVEN + * - Auth token throws an exception + * - Retrieving user is returned + * WHEN + * - User logs in + * THEN + * - Result is unsuccessful + */ + @Test + fun testLoginFailure() = runTest { + // GIVEN + val email = "test@test.com" + val password = "password" + + coEvery { authRepository.login(email, password) } throws Exception() + coEvery { userRepository.get() } returns mockk() + + // WHEN + val result = loginUseCase.invoke(email, password) + + // THEN + assertEquals("Result is unsuccessful", false, result.isSuccess) + } + + /** + * GIVEN + * - Auth token is returned + * - Retrieving user throws an exception + * WHEN + * - User logs in + * THEN + * - Result is unsuccessful + */ + @Test + fun testLoginFailureWhenUserRetrievalFails() = runTest { + // GIVEN + val email = "test@test.com" + val password = "password" + + coEvery { authRepository.login(email, password) } returns AuthToken("token") + coEvery { userRepository.get() } throws Exception() + + // WHEN + val result = loginUseCase.invoke(email, password) + + // THEN + assertEquals("Result is unsuccessful", false, result.isSuccess) + } +} From cd14513c7876a0448fc6160d8c29c92d44a75014 Mon Sep 17 00:00:00 2001 From: Khairullo Date: Thu, 9 May 2024 14:33:47 +0200 Subject: [PATCH 08/25] test: removed unnecessary assert from the testLoginSuccess in LoginUseCaseTest --- .../features/login/domain/usecase/LoginUseCaseTest.kt | 2 -- 1 file changed, 2 deletions(-) diff --git a/app/src/test/java/com/monstarlab/features/login/domain/usecase/LoginUseCaseTest.kt b/app/src/test/java/com/monstarlab/features/login/domain/usecase/LoginUseCaseTest.kt index 6db5c87..4664b9c 100644 --- a/app/src/test/java/com/monstarlab/features/login/domain/usecase/LoginUseCaseTest.kt +++ b/app/src/test/java/com/monstarlab/features/login/domain/usecase/LoginUseCaseTest.kt @@ -9,7 +9,6 @@ import io.mockk.mockk import kotlinx.coroutines.test.runTest import org.junit.After import org.junit.Assert.assertEquals -import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test @@ -54,7 +53,6 @@ class LoginUseCaseTest { // THEN assertEquals("Result is successful", true, result.isSuccess) - assertTrue(result.isSuccess) } /** From f6b92c2c17813c21e9d9c238d7af94f7dbe13330 Mon Sep 17 00:00:00 2001 From: Khairullo Date: Fri, 10 May 2024 11:12:41 +0200 Subject: [PATCH 09/25] test: covered LoginViewModel with tests --- .../features/login/ui/LoginViewModelTest.kt | 257 ++++++++++++++++++ 1 file changed, 257 insertions(+) create mode 100644 app/src/test/java/com/monstarlab/features/login/ui/LoginViewModelTest.kt diff --git a/app/src/test/java/com/monstarlab/features/login/ui/LoginViewModelTest.kt b/app/src/test/java/com/monstarlab/features/login/ui/LoginViewModelTest.kt new file mode 100644 index 0000000..140c061 --- /dev/null +++ b/app/src/test/java/com/monstarlab/features/login/ui/LoginViewModelTest.kt @@ -0,0 +1,257 @@ +package com.monstarlab.features.login.ui + +import app.cash.turbine.test +import com.monstarlab.core.error.ErrorModel +import com.monstarlab.core.network.errorhandling.ApiException +import com.monstarlab.features.login.domain.usecase.LoginUseCase +import io.mockk.clearAllMocks +import io.mockk.coEvery +import io.mockk.mockk +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.delay +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +class LoginViewModelTest { + + private val testDispatcher = StandardTestDispatcher() + private lateinit var loginUseCase: LoginUseCase + private lateinit var viewModel: LoginViewModel + + @OptIn(ExperimentalCoroutinesApi::class) + @Before + fun setUp() { + Dispatchers.setMain(testDispatcher) + + loginUseCase = mockk() + viewModel = LoginViewModel(loginUseCase) + } + + @OptIn(ExperimentalCoroutinesApi::class) + @After + fun tearDown() { + Dispatchers.resetMain() + clearAllMocks() + } + + @Test + fun testInitialState() = runTest { + // GIVEN + val initialState = LoginState() + + // WHEN + val state = viewModel.stateFlow.value + + // THEN + assertEquals(initialState, state) + } + + /** + * GIVEN + * - initialEmail is "eve.holt@reqres.in" + * - testEmail is "test@test.com" + * WHEN + * - User changes email + * THEN + * - State email is updated + * - State email equals test email + */ + @Test + fun testOnEmailChange() = runTest { + // GIVEN + val initialEmail = "eve.holt@reqres.in" + val testEmail = "test@test.com" + val initialState = viewModel.stateFlow.value + assertEquals(initialEmail, initialState.email) + + // WHEN + viewModel.onEmailChange(testEmail) + + // THEN + val state = viewModel.stateFlow.value + assertEquals(testEmail, state.email) + } + + + /** + * GIVEN + * - initialPassword is "cityslicka" + * - testPassword is "password" + * WHEN + * - User changes password + * THEN + * - State password is updated + * - State password equals test password + */ + @Test + fun testOnPasswordChange() = runTest { + // GIVEN + val initialPassword = "cityslicka" + val testPassword = "password" + val initialState = viewModel.stateFlow.value + assertEquals(initialPassword, initialState.password) + + // WHEN + viewModel.onPasswordChange(testPassword) + + // THEN + val state = viewModel.stateFlow.value + assertEquals(testPassword, state.password) + } + + /** + * GIVEN + * - User is not logged in + * - Loading is false + * - Error is null + * WHEN + * - User logs in + * THEN + * - User is logged in + * - Loading is false + * - Error is null + */ + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun testLoginSuccess() = runTest { + // GIVEN + val testEmail = "test@test.com" + val testPassword = "password" + + assertEquals(false, viewModel.stateFlow.value.isLoggedIn) + assertEquals(false, viewModel.stateFlow.value.isLoading) + assertEquals(null, viewModel.stateFlow.value.error) + coEvery { loginUseCase(testEmail, testPassword) } returns Result.success(mockk()) + + // WHEN + viewModel.onEmailChange(testEmail) + viewModel.onPasswordChange(testPassword) + viewModel.login() + advanceUntilIdle() + + // THEN + val state = viewModel.stateFlow.value + assertTrue(state.isLoggedIn) + assertFalse(state.isLoading) + assertEquals(null, state.error) + } + + /** + * GIVEN + * - User is not logged in + * - Loading is false + * - exception is ApiException with code 400 and message "user not found" + * - expectedError is ErrorModel.ApiError(exception) + * WHEN + * - User logs in + * THEN + * - User is not logged in + * - Loading is false + * - Error is expectedError + */ + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun testLoginFailure() = runTest { + // GIVEN + val testEmail = "test@test.com" + val testPassword = "password" + val exception = ApiException(400, "user not found") + val expectedError = ErrorModel.ApiError(exception) + + assertEquals(false, viewModel.stateFlow.value.isLoggedIn) + assertEquals(false, viewModel.stateFlow.value.isLoading) + assertEquals(null, viewModel.stateFlow.value.error) + coEvery { + loginUseCase(testEmail, testPassword) + } returns Result.failure(exception) + + // WHEN + viewModel.onEmailChange(testEmail) + viewModel.onPasswordChange(testPassword) + viewModel.login() + advanceUntilIdle() + + // THEN + val state = viewModel.stateFlow.value + assertFalse(state.isLoggedIn) + assertFalse(state.isLoading) + assertEquals(expectedError, state.error) + } + + /** + * GIVEN + * - User is not logged in + * - Loading is false + * - exception is ApiException with code 400 and message "user not found" + * - expectedError is ErrorModel.ApiError(exception) + * WHEN + * - User logs in + * THEN + * - User is not logged in + * - Loading is false + * - Error is expectedError + */ + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun testLoginLoadingState() = runTest { + // GIVEN + val initialState = viewModel.stateFlow.value + + assertEquals(false, initialState.isLoading) + coEvery { loginUseCase(any(), any()) } coAnswers { + delay(1000) + Result.success(mockk()) + } + + // WHEN + viewModel.login() + runCurrent() + val intermediateState = viewModel.stateFlow.value + assertTrue(intermediateState.isLoading) + advanceTimeBy(2000) + + // THEN + val finalState = viewModel.stateFlow.value + assertFalse(finalState.isLoading) + } + + /** + * GIVEN + * - The initial state is: not loading & not logged in + * - The login use case always returns a successful result + * WHEN + * - User logs in + * THEN + * - The initial state is: not loading & not logged in + * - The state changes to: loading & not logged in + * - The state changes to: not loading & logged in + */ + @Test + fun testLoginStateWithTurbine() = runTest { + // GIVEN + coEvery { loginUseCase(any(), any()) } returns Result.success(mockk()) + + // WHEN + viewModel.login() + + // THEN + viewModel.stateFlow.test { + assertEquals(LoginState(isLoading = false, isLoggedIn = false), awaitItem()) + assertEquals(LoginState(isLoading = true, isLoggedIn = false), awaitItem()) + assertEquals(LoginState(isLoading = false, isLoggedIn = true), awaitItem()) + } + } + +} \ No newline at end of file From a417ab4c8caae61b86c8560f0f6c8c2ea888c10a Mon Sep 17 00:00:00 2001 From: Khairullo Date: Fri, 10 May 2024 11:14:24 +0200 Subject: [PATCH 10/25] refactor: converted ErrorModel from objects to data objects --- core/src/main/java/com/monstarlab/core/error/ErrorModel.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/core/src/main/java/com/monstarlab/core/error/ErrorModel.kt b/core/src/main/java/com/monstarlab/core/error/ErrorModel.kt index 6ddef76..249c6bc 100644 --- a/core/src/main/java/com/monstarlab/core/error/ErrorModel.kt +++ b/core/src/main/java/com/monstarlab/core/error/ErrorModel.kt @@ -10,9 +10,9 @@ sealed interface ErrorModel { data class ApiError(val exception: ApiException) : ErrorModel sealed class Connection : ErrorModel { - object Timeout : Connection() - object IOError : Connection() - object UnknownHost : Connection() + data object Timeout : Connection() + data object IOError : Connection() + data object UnknownHost : Connection() } data class Unknown(val throwable: Throwable) : ErrorModel From ed9feec8de55eaf4947324eb7668d28e724728e8 Mon Sep 17 00:00:00 2001 From: Khairullo Date: Sun, 12 May 2024 13:29:24 +0200 Subject: [PATCH 11/25] build: added turbine dependency --- gradle/libs.versions.toml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d628bb5..5ce3527 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -17,6 +17,7 @@ androidx_core = "1.9.0" android_gradle = "8.2.2" chucker = "4.0.0" mockk = "1.13.10" +turbine = "1.1.0" [plugins] spotless = { id = "com.diffplug.spotless", version = "6.25.0" } @@ -122,6 +123,7 @@ junit_android = { group = "androidx.test.ext", name = "junit", version = "1.1.5" espresso_core = { group = "androidx.test.espresso", name = "espresso-core", version = "3.5.1" } mock_android = { module = "io.mockk:mockk-android", version.ref = "mockk"} mockk_agent = { module = "io.mockk:mockk-agent", version.ref = "mockk"} +turbine = { module = "app.cash.turbine:turbine", version.ref = "turbine" } #------------------------- # Others #------------------------- @@ -173,7 +175,8 @@ test = [ "okhttp_mockwebserver", "kotlin_coroutines_test", "mock_android", - "mockk_agent" + "mockk_agent", + "turbine" ] android_test = [ From 0d05441bf0db5daa8746a334176e4d9d3e7e7ac3 Mon Sep 17 00:00:00 2001 From: Khairullo Date: Tue, 14 May 2024 15:01:08 +0200 Subject: [PATCH 12/25] chore: removed unnecessary ExampleInstrumentedTest --- .../com/monstarlab/ExampleInstrumentedTest.kt | 22 ------------------- 1 file changed, 22 deletions(-) delete mode 100644 app/src/androidTest/java/com/monstarlab/ExampleInstrumentedTest.kt diff --git a/app/src/androidTest/java/com/monstarlab/ExampleInstrumentedTest.kt b/app/src/androidTest/java/com/monstarlab/ExampleInstrumentedTest.kt deleted file mode 100644 index 6cced5d..0000000 --- a/app/src/androidTest/java/com/monstarlab/ExampleInstrumentedTest.kt +++ /dev/null @@ -1,22 +0,0 @@ -package com.monstarlab - -import androidx.test.ext.junit.runners.AndroidJUnit4 -import androidx.test.platform.app.InstrumentationRegistry -import org.junit.Assert.assertEquals -import org.junit.Test -import org.junit.runner.RunWith - -/** - * Instrumented test, which will execute on an Android device. - * - * See [testing documentation](http://d.android.com/tools/testing). - */ -@RunWith(AndroidJUnit4::class) -class ExampleInstrumentedTest { - @Test - fun useAppContext() { - // Context of the app under test. - val appContext = InstrumentationRegistry.getInstrumentation().targetContext - assertEquals("com.monstarlab", appContext.packageName) - } -} From 7aa31819b71dbb3f32dc650450b9026adb599904 Mon Sep 17 00:00:00 2001 From: Khairullo Date: Tue, 14 May 2024 15:01:13 +0200 Subject: [PATCH 13/25] chore: removed unnecessary ExampleUnitTest --- .../test/java/com/monstarlab/ExampleUnitTest.kt | 16 ---------------- 1 file changed, 16 deletions(-) delete mode 100644 app/src/test/java/com/monstarlab/ExampleUnitTest.kt diff --git a/app/src/test/java/com/monstarlab/ExampleUnitTest.kt b/app/src/test/java/com/monstarlab/ExampleUnitTest.kt deleted file mode 100644 index dbf3254..0000000 --- a/app/src/test/java/com/monstarlab/ExampleUnitTest.kt +++ /dev/null @@ -1,16 +0,0 @@ -package com.monstarlab - -import org.junit.Assert.assertEquals -import org.junit.Test - -/** - * Example local unit test, which will execute on the development machine (host). - * - * See [testing documentation](http://d.android.com/tools/testing). - */ -class ExampleUnitTest { - @Test - fun addition_isCorrect() { - assertEquals(4, 2 + 2) - } -} From 1a9a8e068f180f8946be31a2b6054d11c3560f86 Mon Sep 17 00:00:00 2001 From: Khairullo Date: Tue, 14 May 2024 15:01:53 +0200 Subject: [PATCH 14/25] build: added ui-test-manifest to app's build gradle --- app/build.gradle.kts | 1 + 1 file changed, 1 insertion(+) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 7c1e4fe..10ec40c 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -114,4 +114,5 @@ dependencies { releaseImplementation(libs.chucker.noop) testImplementation(libs.bundles.test) androidTestImplementation(libs.bundles.android.test) + debugImplementation(libs.android.compose.ui.test.manifest) } From 81d34d0fca03116a12f1abc303c657f0ab0fc09e Mon Sep 17 00:00:00 2001 From: Khairullo Date: Tue, 14 May 2024 15:03:21 +0200 Subject: [PATCH 15/25] build: added mockk dependency to the versions catalogue --- gradle/libs.versions.toml | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5ce3527..ca758fe 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -5,7 +5,7 @@ ksp = "1.9.23-1.0.19" kotlin_coroutines = "1.8.0" retrofit = "2.11.0" okhttp = "4.12.0" -compose_bom = "2024.04.00" +compose_bom = "2024.05.00" compose_compiler = "1.5.11" hilt = "2.51.1" lifecycle = "2.7.0" @@ -83,8 +83,6 @@ android_compose_ui_tooling_preview = { module = "androidx.compose.ui:ui-tooling- android_compose_foundation = { module = "androidx.compose.animation:animation" } android_compose_animation = { module = "androidx.compose.foundation:foundation" } android_compose_runtime = { module = "androidx.compose.runtime:runtime-livedata" } -android_compose_ui_test = { module = "androidx.compose.ui:ui-test-junit4" } -android_compose_ui_test_manifest = { module = "androidx.compose.ui:ui-test-manifest" } android_compose_material_windowsize = "androidx.compose.material3:material3-window-size-class:1.2.1" #------------------------- # Android - Navigation @@ -120,8 +118,10 @@ google_accompanist_permissions = { module = "com.google.accompanist:accompanist- #------------------------- junit = { group = "junit", name = "junit", version = "4.13.2" } junit_android = { group = "androidx.test.ext", name = "junit", version = "1.1.5" } +android_compose_ui_test_manifest = { group = "androidx.compose.ui", name = "ui-test-manifest", version = "1.6.7" } +android_compose_ui_test = { group = "androidx.compose.ui", name = "ui-test-junit4", version = "1.6.7" } espresso_core = { group = "androidx.test.espresso", name = "espresso-core", version = "3.5.1" } -mock_android = { module = "io.mockk:mockk-android", version.ref = "mockk"} +mockk_android = { module = "io.mockk:mockk-android", version.ref = "mockk"} mockk_agent = { module = "io.mockk:mockk-agent", version.ref = "mockk"} turbine = { module = "app.cash.turbine:turbine", version.ref = "turbine" } #------------------------- @@ -174,12 +174,13 @@ test = [ "junit", "okhttp_mockwebserver", "kotlin_coroutines_test", - "mock_android", + "mockk_android", "mockk_agent", - "turbine" + "turbine", ] android_test = [ "junit_android", - "espresso_core" + "espresso_core", + "android_compose_ui_test", ] \ No newline at end of file From 478a61a6a411489506f48f6c5e8cab7047a49afa Mon Sep 17 00:00:00 2001 From: Khairullo Date: Tue, 14 May 2024 15:04:09 +0200 Subject: [PATCH 16/25] test: implemented LoginScreenTest to test LoginScreen compose content --- .../features/login/ui/LoginScreenTest.kt | 170 ++++++++++++++++++ .../features/login/ui/LoginScreen.kt | 13 +- 2 files changed, 180 insertions(+), 3 deletions(-) create mode 100644 app/src/androidTest/java/com/monstarlab/features/login/ui/LoginScreenTest.kt diff --git a/app/src/androidTest/java/com/monstarlab/features/login/ui/LoginScreenTest.kt b/app/src/androidTest/java/com/monstarlab/features/login/ui/LoginScreenTest.kt new file mode 100644 index 0000000..f0c963e --- /dev/null +++ b/app/src/androidTest/java/com/monstarlab/features/login/ui/LoginScreenTest.kt @@ -0,0 +1,170 @@ +package com.monstarlab.features.login.ui + +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.test.assert +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsNotDisplayed +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextReplacement +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.monstarlab.core.error.ErrorModel +import com.monstarlab.core.network.errorhandling.ApiException +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class LoginScreenTest { + + @get:Rule + val composeTestRule = createComposeRule() + + private lateinit var state: MutableState + + @Before + fun setUp() { + state = mutableStateOf(LoginState()) + composeTestRule.setContent { + LoginScreen(state = state.value, actions = LoginActions( + onEmailChange = { state.value = state.value.copy(email = it) }, + onPasswordChange = { state.value = state.value.copy(password = it) }, + onLoginClick = { state.value = state.value.copy(isLoading = true) } + )) + } + } + + @Test + fun testInitialState() { + composeTestRule + .onNodeWithTag("email") + .assertIsEnabled() + .assertIsDisplayed() + .assert(hasText("eve.holt@reqres.in")) + + composeTestRule + .onNodeWithTag("password") + .assertIsEnabled() + .assertIsDisplayed() + .assert(hasText("")) + + composeTestRule + .onNodeWithTag("login") + .assertIsEnabled() + .assertIsDisplayed() + } + + /** + * GIVEN: + * - Initial email is "eve.holt@reqres.in" + * - Test email is "test@test.com" + * WHEN: + * - User types "test@test.com" into the email field + * THEN: + * - Email field should contain "test@test.com" + */ + @Test + fun testEmailInput() { + // GIVEN + composeTestRule + .onNodeWithTag("email") + .assert(hasText("eve.holt@reqres.in")) + + // WHEN + composeTestRule + .onNodeWithTag("email") + .performTextReplacement("test@test.com") + + // THEN + composeTestRule + .onNodeWithTag("email") + .assert(hasText("test@test.com")) + } + + /** + * GIVEN: + * - Initial password is empty + * - Test password is "cityslicka", but due to visual transformation, it should not be visible + * WHEN: + * - User types "secret" into the password field + * THEN: + * - Password field should remain empty (due to visual transformation) + */ + @Test + fun testPasswordInput() { + // GIVEN + composeTestRule + .onNodeWithTag("password") + .assert(hasText("")) + + // WHEN + composeTestRule + .onNodeWithTag("password") + .performTextReplacement("secret") + + // THEN + composeTestRule + .onNodeWithTag("password") + .assert(hasText("")) + } + + /** + * GIVEN: + * - The loading state is false + * WHEN: + * - User clicks the login button + * - The loading state is true (simulating an ongoing login attempt) + * THEN: + * - Login button should be disabled + */ + @Test + fun testLoginButton_disabledWhenLoading() { + // GIVEN + composeTestRule + .onNodeWithTag("login") + .assertIsEnabled() + + // WHEN + composeTestRule + .onNodeWithTag("login") + .performClick() + + // THEN + composeTestRule + .onNodeWithTag("login") + .assertIsNotEnabled() + } + + /** + * GIVEN: + * - Initially, no error message is displayed + * - An error state with "Invalid credentials" message is set + * WHEN: + * - The UI recomposes + * THEN: + * - An error message with the text "Invalid credentials" should be displayed + */ + @Test + fun testErrorState() { + // GIVEN + composeTestRule + .onNodeWithText("Invalid credentials") + .assertIsNotDisplayed() + + // WHEN + state.value = + state.value.copy(error = ErrorModel.ApiError(ApiException(401, "Invalid credentials"))) + + // THEN + composeTestRule + .onNodeWithText("Invalid credentials") + .assertIsDisplayed() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/monstarlab/features/login/ui/LoginScreen.kt b/app/src/main/java/com/monstarlab/features/login/ui/LoginScreen.kt index cefb7fc..467d91d 100644 --- a/app/src/main/java/com/monstarlab/features/login/ui/LoginScreen.kt +++ b/app/src/main/java/com/monstarlab/features/login/ui/LoginScreen.kt @@ -13,6 +13,7 @@ import androidx.compose.material.Scaffold import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.input.PasswordVisualTransformation import com.monstarlab.core.error.displayableMessage import com.monstarlab.core.ui.previews.LightDarkPreview @@ -44,8 +45,10 @@ fun LoginScreen(state: LoginState = LoginState(), actions: LoginActions = LoginA AppTextField( value = state.email, onValueChange = actions.onEmailChange, - modifier = Modifier.fillMaxWidth(), placeholder = "E-Mail", + modifier = Modifier + .fillMaxWidth() + .testTag("email"), ) Spacer(modifier = Modifier.size(Theme.dimensions.medium3)) @@ -53,18 +56,22 @@ fun LoginScreen(state: LoginState = LoginState(), actions: LoginActions = LoginA AppTextField( value = state.password, onValueChange = actions.onPasswordChange, - modifier = Modifier.fillMaxWidth(), error = state.error?.displayableMessage, visualTransformation = PasswordVisualTransformation(), placeholder = "Password", + modifier = Modifier + .fillMaxWidth() + .testTag("password"), ) Spacer(modifier = Modifier.size(Theme.dimensions.medium3)) AppButton( text = "Login", onClick = actions.onLoginClick, - modifier = Modifier.fillMaxWidth(), isLoading = state.isLoading, + modifier = Modifier + .fillMaxWidth() + .testTag("login"), ) } } From 1c6cc2765de0ae9159265f01fd9e900a50eb4d67 Mon Sep 17 00:00:00 2001 From: Khairullo Date: Tue, 14 May 2024 15:04:38 +0200 Subject: [PATCH 17/25] style: spotless applied --- .../features/login/ui/LoginScreenTest.kt | 15 +++++++++------ .../login/domain/usecase/LoginUseCaseTest.kt | 2 +- .../features/login/ui/LoginViewModelTest.kt | 16 +++++++--------- 3 files changed, 17 insertions(+), 16 deletions(-) diff --git a/app/src/androidTest/java/com/monstarlab/features/login/ui/LoginScreenTest.kt b/app/src/androidTest/java/com/monstarlab/features/login/ui/LoginScreenTest.kt index f0c963e..c978b8b 100644 --- a/app/src/androidTest/java/com/monstarlab/features/login/ui/LoginScreenTest.kt +++ b/app/src/androidTest/java/com/monstarlab/features/login/ui/LoginScreenTest.kt @@ -33,11 +33,14 @@ class LoginScreenTest { fun setUp() { state = mutableStateOf(LoginState()) composeTestRule.setContent { - LoginScreen(state = state.value, actions = LoginActions( - onEmailChange = { state.value = state.value.copy(email = it) }, - onPasswordChange = { state.value = state.value.copy(password = it) }, - onLoginClick = { state.value = state.value.copy(isLoading = true) } - )) + LoginScreen( + state = state.value, + actions = LoginActions( + onEmailChange = { state.value = state.value.copy(email = it) }, + onPasswordChange = { state.value = state.value.copy(password = it) }, + onLoginClick = { state.value = state.value.copy(isLoading = true) }, + ), + ) } } @@ -167,4 +170,4 @@ class LoginScreenTest { .onNodeWithText("Invalid credentials") .assertIsDisplayed() } -} \ No newline at end of file +} diff --git a/app/src/test/java/com/monstarlab/features/login/domain/usecase/LoginUseCaseTest.kt b/app/src/test/java/com/monstarlab/features/login/domain/usecase/LoginUseCaseTest.kt index 4664b9c..b615605 100644 --- a/app/src/test/java/com/monstarlab/features/login/domain/usecase/LoginUseCaseTest.kt +++ b/app/src/test/java/com/monstarlab/features/login/domain/usecase/LoginUseCaseTest.kt @@ -6,11 +6,11 @@ import com.monstarlab.features.user.domain.repository.UserRepository import io.mockk.clearAllMocks import io.mockk.coEvery import io.mockk.mockk -import kotlinx.coroutines.test.runTest import org.junit.After import org.junit.Assert.assertEquals import org.junit.Before import org.junit.Test +import kotlinx.coroutines.test.runTest class LoginUseCaseTest { diff --git a/app/src/test/java/com/monstarlab/features/login/ui/LoginViewModelTest.kt b/app/src/test/java/com/monstarlab/features/login/ui/LoginViewModelTest.kt index 140c061..42b631f 100644 --- a/app/src/test/java/com/monstarlab/features/login/ui/LoginViewModelTest.kt +++ b/app/src/test/java/com/monstarlab/features/login/ui/LoginViewModelTest.kt @@ -7,6 +7,12 @@ import com.monstarlab.features.login.domain.usecase.LoginUseCase import io.mockk.clearAllMocks import io.mockk.coEvery import io.mockk.mockk +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.delay @@ -17,12 +23,6 @@ import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain -import org.junit.After -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertTrue -import org.junit.Before -import org.junit.Test class LoginViewModelTest { @@ -84,7 +84,6 @@ class LoginViewModelTest { assertEquals(testEmail, state.email) } - /** * GIVEN * - initialPassword is "cityslicka" @@ -253,5 +252,4 @@ class LoginViewModelTest { assertEquals(LoginState(isLoading = false, isLoggedIn = true), awaitItem()) } } - -} \ No newline at end of file +} From 1f9cd319ef819aff421b9d66517d776bd0ad1a1e Mon Sep 17 00:00:00 2001 From: Khairullo Date: Tue, 14 May 2024 16:58:21 +0200 Subject: [PATCH 18/25] build: added robolectric dependency --- app/build.gradle.kts | 12 ++++++++++-- gradle/libs.versions.toml | 8 +++++++- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 10ec40c..7d48d6a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,4 +1,5 @@ @file:Suppress("UnstableApiUsage") + // TODO: Remove once https://youtrack.jetbrains.com/issue/KTIJ-19369 is fixed @Suppress("DSL_SCOPE_VIOLATION") plugins { @@ -52,9 +53,16 @@ android { } } + testOptions.unitTests { + isIncludeAndroidResources = true + } packaging { - resources.excludes.add("META-INF/versions/9/previous-compilation-data.bin") + resources { + excludes.add("META-INF/versions/9/previous-compilation-data.bin") + excludes.add("META-INF/LICENSE.md") + excludes.add("META-INF/LICENSE-notice.md") + } } } @@ -95,7 +103,7 @@ dependencies { implementation(libs.android.activity.compose) implementation(libs.android.lifecycle.viewmodel.compose) implementation(libs.bundles.google.accompanist) - implementation (libs.android.compose.ui.tooling.preview) + implementation(libs.android.compose.ui.tooling.preview) debugImplementation(libs.android.compose.ui.tooling) // Injection diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ca758fe..4f75d5c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -17,6 +17,7 @@ androidx_core = "1.9.0" android_gradle = "8.2.2" chucker = "4.0.0" mockk = "1.13.10" +robolectric = "4.12.1" turbine = "1.1.0" [plugins] @@ -121,8 +122,10 @@ junit_android = { group = "androidx.test.ext", name = "junit", version = "1.1.5" android_compose_ui_test_manifest = { group = "androidx.compose.ui", name = "ui-test-manifest", version = "1.6.7" } android_compose_ui_test = { group = "androidx.compose.ui", name = "ui-test-junit4", version = "1.6.7" } espresso_core = { group = "androidx.test.espresso", name = "espresso-core", version = "3.5.1" } +mockk = { module = "io.mockk:mockk", version.ref = "mockk" } mockk_android = { module = "io.mockk:mockk-android", version.ref = "mockk"} mockk_agent = { module = "io.mockk:mockk-agent", version.ref = "mockk"} +robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" } turbine = { module = "app.cash.turbine:turbine", version.ref = "turbine" } #------------------------- # Others @@ -174,13 +177,16 @@ test = [ "junit", "okhttp_mockwebserver", "kotlin_coroutines_test", - "mockk_android", + "mockk", "mockk_agent", "turbine", + "robolectric", + "android_compose_ui_test" ] android_test = [ "junit_android", "espresso_core", "android_compose_ui_test", + "mockk_android", ] \ No newline at end of file From e02b9d91e2b4ca3aab136ac29e4ca1d43fa5124b Mon Sep 17 00:00:00 2001 From: Khairullo Date: Tue, 14 May 2024 16:58:25 +0200 Subject: [PATCH 19/25] test: replaced LoginScreenTest ui tests with mockk & robolectric --- .../features/login/ui/LoginScreenTest.kt | 173 ------------------ .../features/login/ui/LoginScreenTest.kt | 114 ++++++++++++ 2 files changed, 114 insertions(+), 173 deletions(-) delete mode 100644 app/src/androidTest/java/com/monstarlab/features/login/ui/LoginScreenTest.kt create mode 100644 app/src/test/java/com/monstarlab/features/login/ui/LoginScreenTest.kt diff --git a/app/src/androidTest/java/com/monstarlab/features/login/ui/LoginScreenTest.kt b/app/src/androidTest/java/com/monstarlab/features/login/ui/LoginScreenTest.kt deleted file mode 100644 index c978b8b..0000000 --- a/app/src/androidTest/java/com/monstarlab/features/login/ui/LoginScreenTest.kt +++ /dev/null @@ -1,173 +0,0 @@ -package com.monstarlab.features.login.ui - -import androidx.compose.runtime.MutableState -import androidx.compose.runtime.mutableStateOf -import androidx.compose.ui.test.assert -import androidx.compose.ui.test.assertIsDisplayed -import androidx.compose.ui.test.assertIsEnabled -import androidx.compose.ui.test.assertIsNotDisplayed -import androidx.compose.ui.test.assertIsNotEnabled -import androidx.compose.ui.test.hasText -import androidx.compose.ui.test.junit4.createComposeRule -import androidx.compose.ui.test.onNodeWithTag -import androidx.compose.ui.test.onNodeWithText -import androidx.compose.ui.test.performClick -import androidx.compose.ui.test.performTextReplacement -import androidx.test.ext.junit.runners.AndroidJUnit4 -import com.monstarlab.core.error.ErrorModel -import com.monstarlab.core.network.errorhandling.ApiException -import org.junit.Before -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith - -@RunWith(AndroidJUnit4::class) -class LoginScreenTest { - - @get:Rule - val composeTestRule = createComposeRule() - - private lateinit var state: MutableState - - @Before - fun setUp() { - state = mutableStateOf(LoginState()) - composeTestRule.setContent { - LoginScreen( - state = state.value, - actions = LoginActions( - onEmailChange = { state.value = state.value.copy(email = it) }, - onPasswordChange = { state.value = state.value.copy(password = it) }, - onLoginClick = { state.value = state.value.copy(isLoading = true) }, - ), - ) - } - } - - @Test - fun testInitialState() { - composeTestRule - .onNodeWithTag("email") - .assertIsEnabled() - .assertIsDisplayed() - .assert(hasText("eve.holt@reqres.in")) - - composeTestRule - .onNodeWithTag("password") - .assertIsEnabled() - .assertIsDisplayed() - .assert(hasText("")) - - composeTestRule - .onNodeWithTag("login") - .assertIsEnabled() - .assertIsDisplayed() - } - - /** - * GIVEN: - * - Initial email is "eve.holt@reqres.in" - * - Test email is "test@test.com" - * WHEN: - * - User types "test@test.com" into the email field - * THEN: - * - Email field should contain "test@test.com" - */ - @Test - fun testEmailInput() { - // GIVEN - composeTestRule - .onNodeWithTag("email") - .assert(hasText("eve.holt@reqres.in")) - - // WHEN - composeTestRule - .onNodeWithTag("email") - .performTextReplacement("test@test.com") - - // THEN - composeTestRule - .onNodeWithTag("email") - .assert(hasText("test@test.com")) - } - - /** - * GIVEN: - * - Initial password is empty - * - Test password is "cityslicka", but due to visual transformation, it should not be visible - * WHEN: - * - User types "secret" into the password field - * THEN: - * - Password field should remain empty (due to visual transformation) - */ - @Test - fun testPasswordInput() { - // GIVEN - composeTestRule - .onNodeWithTag("password") - .assert(hasText("")) - - // WHEN - composeTestRule - .onNodeWithTag("password") - .performTextReplacement("secret") - - // THEN - composeTestRule - .onNodeWithTag("password") - .assert(hasText("")) - } - - /** - * GIVEN: - * - The loading state is false - * WHEN: - * - User clicks the login button - * - The loading state is true (simulating an ongoing login attempt) - * THEN: - * - Login button should be disabled - */ - @Test - fun testLoginButton_disabledWhenLoading() { - // GIVEN - composeTestRule - .onNodeWithTag("login") - .assertIsEnabled() - - // WHEN - composeTestRule - .onNodeWithTag("login") - .performClick() - - // THEN - composeTestRule - .onNodeWithTag("login") - .assertIsNotEnabled() - } - - /** - * GIVEN: - * - Initially, no error message is displayed - * - An error state with "Invalid credentials" message is set - * WHEN: - * - The UI recomposes - * THEN: - * - An error message with the text "Invalid credentials" should be displayed - */ - @Test - fun testErrorState() { - // GIVEN - composeTestRule - .onNodeWithText("Invalid credentials") - .assertIsNotDisplayed() - - // WHEN - state.value = - state.value.copy(error = ErrorModel.ApiError(ApiException(401, "Invalid credentials"))) - - // THEN - composeTestRule - .onNodeWithText("Invalid credentials") - .assertIsDisplayed() - } -} diff --git a/app/src/test/java/com/monstarlab/features/login/ui/LoginScreenTest.kt b/app/src/test/java/com/monstarlab/features/login/ui/LoginScreenTest.kt new file mode 100644 index 0000000..eab592f --- /dev/null +++ b/app/src/test/java/com/monstarlab/features/login/ui/LoginScreenTest.kt @@ -0,0 +1,114 @@ +package com.monstarlab.features.login.ui + +import androidx.compose.ui.test.assert +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextReplacement +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.mockk.mockk +import io.mockk.verify +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class LoginScreenTest { + + @get:Rule + val composeTestRule = createComposeRule() + + + private val mockActions: LoginActions = mockk(relaxed = true) + + @Before + fun setUp() { + composeTestRule.setContent { + LoginScreen(state = LoginState(), actions = mockActions) + } + } + + /** + * GIVEN: + * - Initial state of the login screen + * WHEN: + * - The screen is displayed + * THEN: + * - Email field should be enabled, displayed, and contain "eve.holt@reqres.in" + * - Password field should be enabled and displayed + * - Login button should be enabled and displayed + */ + @Test + fun testInitialState() { + composeTestRule + .onNodeWithTag("email") + .assertIsEnabled() + .assertIsDisplayed() + .assert(hasText("eve.holt@reqres.in")) + + composeTestRule + .onNodeWithTag("password") + .assertIsEnabled() + .assertIsDisplayed() + + composeTestRule + .onNodeWithTag("login") + .assertIsEnabled() + .assertIsDisplayed() + } + + /** + * GIVEN: + * - Initial email is "eve.holt@reqres.in" + * - Test email is "test@test.com" + * WHEN: + * - User types "test@test.com" into the email field + * THEN: + * - onEmailChange action should be called with "test@test.com" + */ + @Test + fun testEmailInput() { + composeTestRule + .onNodeWithTag("email") + .performTextReplacement("test@test.com") + + verify { mockActions.onEmailChange("test@test.com") } + } + + /** + * GIVEN: + * - Initial password is empty + * - Test password is "secret" + * WHEN: + * - User types "secret" into the password field + * THEN: + * - onPasswordChange action should be called with "secret" + */ + @Test + fun testPasswordInput() { + composeTestRule + .onNodeWithTag("password") + .performTextReplacement("secret") + + verify { mockActions.onPasswordChange("secret") } + } + + /** + * GIVEN: + * - User is on the login screen + * WHEN: + * - User clicks the login button + * THEN: + * - onLoginClick action should be called + */ + @Test + fun testLoginButtonClick() { + composeTestRule.onNodeWithTag("login").performClick() + + verify { mockActions.onLoginClick() } + } +} From 93c2627e73e4b0577d5021a85d872b07cc221d89 Mon Sep 17 00:00:00 2001 From: Khairullo Date: Tue, 14 May 2024 16:58:51 +0200 Subject: [PATCH 20/25] style: spotless applied --- .../java/com/monstarlab/features/login/ui/LoginScreenTest.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/app/src/test/java/com/monstarlab/features/login/ui/LoginScreenTest.kt b/app/src/test/java/com/monstarlab/features/login/ui/LoginScreenTest.kt index eab592f..8a5ca61 100644 --- a/app/src/test/java/com/monstarlab/features/login/ui/LoginScreenTest.kt +++ b/app/src/test/java/com/monstarlab/features/login/ui/LoginScreenTest.kt @@ -22,7 +22,6 @@ class LoginScreenTest { @get:Rule val composeTestRule = createComposeRule() - private val mockActions: LoginActions = mockk(relaxed = true) @Before From 5726b32e0304f5b5319eb8621aaa608440f79bd3 Mon Sep 17 00:00:00 2001 From: C706049 Date: Thu, 16 May 2024 13:07:36 +0200 Subject: [PATCH 21/25] build: updated github-actions to test only debug --- .github/workflows/github-actions.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/github-actions.yml b/.github/workflows/github-actions.yml index 4004eba..6a2ccd3 100644 --- a/.github/workflows/github-actions.yml +++ b/.github/workflows/github-actions.yml @@ -29,4 +29,4 @@ jobs: run: ./gradlew detekt - name: Run Tests - run: ./gradlew test + run: ./gradlew testDevDebugUnitTest From 71cf3f90cdbb5c018ab26df60ec9d9b5302532bb Mon Sep 17 00:00:00 2001 From: C706049 Date: Thu, 16 May 2024 13:18:37 +0200 Subject: [PATCH 22/25] test: updated LoginScreenTest to use semantics instead of tags --- .../monstarlab/features/login/ui/LoginScreen.kt | 16 ++++++---------- .../features/login/ui/LoginScreenTest.kt | 17 ++++++++++------- 2 files changed, 16 insertions(+), 17 deletions(-) diff --git a/app/src/main/java/com/monstarlab/features/login/ui/LoginScreen.kt b/app/src/main/java/com/monstarlab/features/login/ui/LoginScreen.kt index 467d91d..dc13f94 100644 --- a/app/src/main/java/com/monstarlab/features/login/ui/LoginScreen.kt +++ b/app/src/main/java/com/monstarlab/features/login/ui/LoginScreen.kt @@ -13,7 +13,6 @@ import androidx.compose.material.Scaffold import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.input.PasswordVisualTransformation import com.monstarlab.core.error.displayableMessage import com.monstarlab.core.ui.previews.LightDarkPreview @@ -46,9 +45,8 @@ fun LoginScreen(state: LoginState = LoginState(), actions: LoginActions = LoginA value = state.email, onValueChange = actions.onEmailChange, placeholder = "E-Mail", - modifier = Modifier - .fillMaxWidth() - .testTag("email"), + label = "E-Mail", + modifier = Modifier.fillMaxWidth() ) Spacer(modifier = Modifier.size(Theme.dimensions.medium3)) @@ -59,19 +57,17 @@ fun LoginScreen(state: LoginState = LoginState(), actions: LoginActions = LoginA error = state.error?.displayableMessage, visualTransformation = PasswordVisualTransformation(), placeholder = "Password", - modifier = Modifier - .fillMaxWidth() - .testTag("password"), + label = "Password", + modifier = Modifier.fillMaxWidth(), ) + Spacer(modifier = Modifier.size(Theme.dimensions.medium3)) AppButton( text = "Login", onClick = actions.onLoginClick, isLoading = state.isLoading, - modifier = Modifier - .fillMaxWidth() - .testTag("login"), + modifier = Modifier.fillMaxWidth(), ) } } diff --git a/app/src/test/java/com/monstarlab/features/login/ui/LoginScreenTest.kt b/app/src/test/java/com/monstarlab/features/login/ui/LoginScreenTest.kt index 8a5ca61..51dc09d 100644 --- a/app/src/test/java/com/monstarlab/features/login/ui/LoginScreenTest.kt +++ b/app/src/test/java/com/monstarlab/features/login/ui/LoginScreenTest.kt @@ -3,9 +3,10 @@ package com.monstarlab.features.login.ui import androidx.compose.ui.test.assert import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.hasClickAction import androidx.compose.ui.test.hasText import androidx.compose.ui.test.junit4.createComposeRule -import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performTextReplacement import androidx.test.ext.junit.runners.AndroidJUnit4 @@ -44,18 +45,18 @@ class LoginScreenTest { @Test fun testInitialState() { composeTestRule - .onNodeWithTag("email") + .onNodeWithText("E-Mail") .assertIsEnabled() .assertIsDisplayed() .assert(hasText("eve.holt@reqres.in")) composeTestRule - .onNodeWithTag("password") + .onNodeWithText("Password") .assertIsEnabled() .assertIsDisplayed() composeTestRule - .onNodeWithTag("login") + .onNode(hasText("Login") and hasClickAction()) .assertIsEnabled() .assertIsDisplayed() } @@ -72,7 +73,7 @@ class LoginScreenTest { @Test fun testEmailInput() { composeTestRule - .onNodeWithTag("email") + .onNodeWithText("E-Mail") .performTextReplacement("test@test.com") verify { mockActions.onEmailChange("test@test.com") } @@ -90,7 +91,7 @@ class LoginScreenTest { @Test fun testPasswordInput() { composeTestRule - .onNodeWithTag("password") + .onNodeWithText("Password") .performTextReplacement("secret") verify { mockActions.onPasswordChange("secret") } @@ -106,7 +107,9 @@ class LoginScreenTest { */ @Test fun testLoginButtonClick() { - composeTestRule.onNodeWithTag("login").performClick() + composeTestRule + .onNode(hasText("Login") and hasClickAction()) + .performClick() verify { mockActions.onLoginClick() } } From 781a708724d3b8d59fe3bf7fe65a5c2f289596e0 Mon Sep 17 00:00:00 2001 From: C706049 Date: Thu, 16 May 2024 13:18:57 +0200 Subject: [PATCH 23/25] style: spotless applied --- .../main/java/com/monstarlab/features/login/ui/LoginScreen.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/monstarlab/features/login/ui/LoginScreen.kt b/app/src/main/java/com/monstarlab/features/login/ui/LoginScreen.kt index dc13f94..d2cd848 100644 --- a/app/src/main/java/com/monstarlab/features/login/ui/LoginScreen.kt +++ b/app/src/main/java/com/monstarlab/features/login/ui/LoginScreen.kt @@ -46,7 +46,7 @@ fun LoginScreen(state: LoginState = LoginState(), actions: LoginActions = LoginA onValueChange = actions.onEmailChange, placeholder = "E-Mail", label = "E-Mail", - modifier = Modifier.fillMaxWidth() + modifier = Modifier.fillMaxWidth(), ) Spacer(modifier = Modifier.size(Theme.dimensions.medium3)) From 3b87c1f82095d44eb39e40a6629ecb318695fe12 Mon Sep 17 00:00:00 2001 From: C706049 Date: Thu, 16 May 2024 15:40:51 +0200 Subject: [PATCH 24/25] test: updated testLoginStateWithTurbine for better readability --- .../features/login/ui/LoginViewModelTest.kt | 23 ++++++++++--------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/app/src/test/java/com/monstarlab/features/login/ui/LoginViewModelTest.kt b/app/src/test/java/com/monstarlab/features/login/ui/LoginViewModelTest.kt index 42b631f..6f5e866 100644 --- a/app/src/test/java/com/monstarlab/features/login/ui/LoginViewModelTest.kt +++ b/app/src/test/java/com/monstarlab/features/login/ui/LoginViewModelTest.kt @@ -7,12 +7,6 @@ import com.monstarlab.features.login.domain.usecase.LoginUseCase import io.mockk.clearAllMocks import io.mockk.coEvery import io.mockk.mockk -import org.junit.After -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertTrue -import org.junit.Before -import org.junit.Test import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.delay @@ -23,6 +17,12 @@ import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test class LoginViewModelTest { @@ -230,10 +230,10 @@ class LoginViewModelTest { * GIVEN * - The initial state is: not loading & not logged in * - The login use case always returns a successful result + * - The initial state is: not loading & not logged in * WHEN * - User logs in * THEN - * - The initial state is: not loading & not logged in * - The state changes to: loading & not logged in * - The state changes to: not loading & logged in */ @@ -242,12 +242,13 @@ class LoginViewModelTest { // GIVEN coEvery { loginUseCase(any(), any()) } returns Result.success(mockk()) - // WHEN - viewModel.login() - - // THEN viewModel.stateFlow.test { assertEquals(LoginState(isLoading = false, isLoggedIn = false), awaitItem()) + + // WHEN + viewModel.login() + + // THEN assertEquals(LoginState(isLoading = true, isLoggedIn = false), awaitItem()) assertEquals(LoginState(isLoading = false, isLoggedIn = true), awaitItem()) } From 2452fc3d6cf9f72dd44c38a6050869cd9f2990e9 Mon Sep 17 00:00:00 2001 From: C706049 Date: Thu, 16 May 2024 15:41:05 +0200 Subject: [PATCH 25/25] style: spotless applied --- .../features/login/ui/LoginViewModelTest.kt | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/app/src/test/java/com/monstarlab/features/login/ui/LoginViewModelTest.kt b/app/src/test/java/com/monstarlab/features/login/ui/LoginViewModelTest.kt index 6f5e866..0335905 100644 --- a/app/src/test/java/com/monstarlab/features/login/ui/LoginViewModelTest.kt +++ b/app/src/test/java/com/monstarlab/features/login/ui/LoginViewModelTest.kt @@ -7,6 +7,12 @@ import com.monstarlab.features.login.domain.usecase.LoginUseCase import io.mockk.clearAllMocks import io.mockk.coEvery import io.mockk.mockk +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.delay @@ -17,12 +23,6 @@ import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain -import org.junit.After -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertTrue -import org.junit.Before -import org.junit.Test class LoginViewModelTest {