From cf0e0b748ae40189f96571abfea16b799d9b664b Mon Sep 17 00:00:00 2001 From: Pell Date: Tue, 22 Apr 2025 21:53:00 +0200 Subject: [PATCH 1/8] Improved tests --- .../repository/UsersRepositoryUnitTest.kt | 21 +++++---- .../usecase/GetUserListUseCaseUnitTest.kt | 47 ++++++++----------- .../users/viewmodel/UsersViewModelUnitTest.kt | 16 ++++++- 3 files changed, 44 insertions(+), 40 deletions(-) diff --git a/data/src/test/kotlin/com/random/users/data/repository/UsersRepositoryUnitTest.kt b/data/src/test/kotlin/com/random/users/data/repository/UsersRepositoryUnitTest.kt index 28f6560..2101987 100644 --- a/data/src/test/kotlin/com/random/users/data/repository/UsersRepositoryUnitTest.kt +++ b/data/src/test/kotlin/com/random/users/data/repository/UsersRepositoryUnitTest.kt @@ -33,14 +33,12 @@ class UsersRepositoryUnitTest { @Test fun `GIVEN users from remote data source WHEN getUsers THEN return users successfully`() = runBlocking { - val page = 1 - val results = 10 val mockSeed = "mock-seed" val newSeed = "new-seed" val users = listOf(UserDtoMother.createModel()) coEvery { seedLocalDataSource.getSeed() } returns mockSeed.right() - coEvery { usersRemoteDataSource.getUsers(page, results, mockSeed) } returns + coEvery { usersRemoteDataSource.getUsers(PAGE, RESULTS, mockSeed) } returns RandomUsersResponseMother .createModel( results = users, @@ -48,28 +46,26 @@ class UsersRepositoryUnitTest { ).right() coEvery { seedLocalDataSource.saveSeed(newSeed) } returns Unit.right() - val result = usersRepository.getUsers(page, results) + val result = usersRepository.getUsers(PAGE, RESULTS) Assert.assertEquals(users.toDomain().right(), result) coVerify { seedLocalDataSource.getSeed() } - coVerify { usersRemoteDataSource.getUsers(page, results, mockSeed) } + coVerify { usersRemoteDataSource.getUsers(PAGE, RESULTS, mockSeed) } coVerify { seedLocalDataSource.saveSeed(newSeed) } } @Test fun `GIVEN error from remote data source WHEN getUsers THEN return error`() = runBlocking { - val page = 1 - val results = 10 val error = UsersErrors.NetworkError coEvery { seedLocalDataSource.getSeed() } returns null.right() - coEvery { usersRemoteDataSource.getUsers(page, results, null) } returns error.left() + coEvery { usersRemoteDataSource.getUsers(PAGE, RESULTS, null) } returns error.left() - val result = usersRepository.getUsers(page, results) + val result = usersRepository.getUsers(PAGE, RESULTS) Assert.assertEquals(error.left(), result) coVerify { seedLocalDataSource.getSeed() } - coVerify { usersRemoteDataSource.getUsers(page, results, null) } + coVerify { usersRemoteDataSource.getUsers(PAGE, RESULTS, null) } coVerify(exactly = 0) { seedLocalDataSource.saveSeed(any()) } } @@ -109,4 +105,9 @@ class UsersRepositoryUnitTest { Assert.assertEquals(Unit.right(), result) coVerify { usersLocalDataSource.deleteUser(uuid) } } + + companion object { + private const val PAGE = 1 + private const val RESULTS = 15 + } } diff --git a/domain/src/test/kotlin/com/random/users/domain/usecase/GetUserListUseCaseUnitTest.kt b/domain/src/test/kotlin/com/random/users/domain/usecase/GetUserListUseCaseUnitTest.kt index 962ed5e..32a1d6a 100644 --- a/domain/src/test/kotlin/com/random/users/domain/usecase/GetUserListUseCaseUnitTest.kt +++ b/domain/src/test/kotlin/com/random/users/domain/usecase/GetUserListUseCaseUnitTest.kt @@ -34,7 +34,7 @@ class GetUserListUseCaseUnitTest { UserMother.createModel(uuid = "3"), ) val deletedUsers = listOf("2") - coEvery { usersRepository.getUsers(1, any()) } returns users.right() + coEvery { usersRepository.getUsers(PAGE, RESULTS) } returns users.right() coEvery { usersRepository.getDeletedUsers() } returns deletedUsers.right() val result = getUserListUseCase(1) @@ -46,7 +46,7 @@ class GetUserListUseCaseUnitTest { ).right(), result, ) - coVerify { usersRepository.getUsers(1, any()) } + coVerify { usersRepository.getUsers(PAGE, RESULTS) } coVerify { usersRepository.getDeletedUsers() } } @@ -59,20 +59,13 @@ class GetUserListUseCaseUnitTest { UserMother.createModel(uuid = "2"), UserMother.createModel(uuid = "3"), ) - coEvery { usersRepository.getUsers(1, any()) } returns users.right() + coEvery { usersRepository.getUsers(PAGE, RESULTS) } returns users.right() coEvery { usersRepository.getDeletedUsers() } returns emptyList().right() val result = getUserListUseCase(1) - Assert.assertEquals( - listOf( - UserMother.createModel(uuid = "1"), - UserMother.createModel(uuid = "2"), - UserMother.createModel(uuid = "3"), - ).right(), - result, - ) - coVerify { usersRepository.getUsers(1, any()) } + Assert.assertEquals(users.right(), result) + coVerify { usersRepository.getUsers(PAGE, RESULTS) } coVerify { usersRepository.getDeletedUsers() } } @@ -86,13 +79,13 @@ class GetUserListUseCaseUnitTest { UserMother.createModel(uuid = "3"), ) val deletedUsers = listOf("1", "2", "3") - coEvery { usersRepository.getUsers(1, any()) } returns users.right() + coEvery { usersRepository.getUsers(PAGE, RESULTS) } returns users.right() coEvery { usersRepository.getDeletedUsers() } returns deletedUsers.right() val result = getUserListUseCase(1) Assert.assertEquals(emptyList().right(), result) - coVerify { usersRepository.getUsers(1, any()) } + coVerify { usersRepository.getUsers(PAGE, RESULTS) } coVerify { usersRepository.getDeletedUsers() } } @@ -100,12 +93,12 @@ class GetUserListUseCaseUnitTest { fun `GIVEN left in getUsers WHEN getUserListUseCase THEN returned left`() = runBlocking { val error = UsersErrors.NetworkError - coEvery { usersRepository.getUsers(1, any()) } returns error.left() + coEvery { usersRepository.getUsers(PAGE, RESULTS) } returns error.left() - val result = getUserListUseCase(1) + val result = getUserListUseCase(PAGE) Assert.assertEquals(error.left(), result) - coVerify { usersRepository.getUsers(any(), any()) } + coVerify { usersRepository.getUsers(PAGE, RESULTS) } coVerify(exactly = 0) { usersRepository.getDeletedUsers() } } @@ -118,20 +111,18 @@ class GetUserListUseCaseUnitTest { UserMother.createModel(uuid = "2"), UserMother.createModel(uuid = "3"), ) - coEvery { usersRepository.getUsers(1, any()) } returns users.right() + coEvery { usersRepository.getUsers(PAGE, RESULTS) } returns users.right() coEvery { usersRepository.getDeletedUsers() } returns UsersErrors.UserError.left() - val result = getUserListUseCase(1) + val result = getUserListUseCase(PAGE) - Assert.assertEquals( - listOf( - UserMother.createModel(uuid = "1"), - UserMother.createModel(uuid = "2"), - UserMother.createModel(uuid = "3"), - ).right(), - result, - ) - coVerify { usersRepository.getUsers(any(), any()) } + Assert.assertEquals(users.right(), result) + coVerify { usersRepository.getUsers(PAGE, RESULTS) } coVerify { usersRepository.getDeletedUsers() } } + + companion object { + private const val PAGE = 1 + private const val RESULTS = 15 + } } diff --git a/presentation/users/src/test/kotlin/com/random/users/users/viewmodel/UsersViewModelUnitTest.kt b/presentation/users/src/test/kotlin/com/random/users/users/viewmodel/UsersViewModelUnitTest.kt index 6e9cbec..8953630 100644 --- a/presentation/users/src/test/kotlin/com/random/users/users/viewmodel/UsersViewModelUnitTest.kt +++ b/presentation/users/src/test/kotlin/com/random/users/users/viewmodel/UsersViewModelUnitTest.kt @@ -8,15 +8,20 @@ import com.random.users.domain.usecase.GetUserListUseCase import com.random.users.test.rules.MainDispatcherRule import com.random.users.users.contract.UsersErrorUiEventsState import com.random.users.users.contract.UsersEvent +import com.random.users.users.contract.UsersScreenUiState import io.mockk.coEvery import io.mockk.coVerify import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import org.junit.Rule import org.junit.Test import kotlin.getValue import kotlin.test.assertEquals +import kotlin.test.assertTrue +@OptIn(ExperimentalCoroutinesApi::class) internal class UsersViewModelUnitTest { @get:Rule val mainDispatcherRule = MainDispatcherRule() @@ -32,14 +37,21 @@ internal class UsersViewModelUnitTest { runTest { coEvery { deleteUserUseCase("1") } returns UsersErrors.UserError.left() + viewModel.handleEvent(UsersEvent.OnDeleteUser("1")) + runCurrent() + + viewModel.uiState.test { + assertTrue(awaitItem().contentState is UsersScreenUiState.ContentState.Idle) + expectNoEvents() + } + viewModel.uiEventsState.test { - viewModel.handleEvent(UsersEvent.OnDeleteUser("1")) assertEquals(UsersErrorUiEventsState.DeleteError, awaitItem()) expectNoEvents() } coVerify { - deleteUserUseCase(any()) + deleteUserUseCase("1") } } } From 4b9b89cc5bf9098138f6acc6450eb9e82dac72df Mon Sep 17 00:00:00 2001 From: Pell Date: Tue, 22 Apr 2025 22:03:53 +0200 Subject: [PATCH 2/8] Changed README.md --- README.md | 58 ++++++++++++++++++++++++++++--------------------------- 1 file changed, 30 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index baecf05..745d0af 100644 --- a/README.md +++ b/README.md @@ -1,62 +1,65 @@ ## ๐Ÿ“ฑ Overview -An Android application that displays a list of random users fetched from an external API. Features a modern and intuitive UI built with Jetpack Compose, allowing users to filter, delete, and manage profiles. +An Android application that displays a list of random users fetched from an external API. Features a modern and intuitive UI built with Jetpack Compose, allowing users to filter and delete profiles. ## โœจ Features -- **User List**: Browse through random user profiles with detailed information -- **Filtering**: Filter users by various criteria -- **Local Storage**: Access previously loaded users while offline -- **Reactive UI**: Real-time updates when data changes +- **User List**: Browse through random user profiles with detailed information. Infinite scroll implemented +- **USer details**: View detailed information about each user +- **Filtering**: Filter users by name, surname and email +- **Delete**: Remove users from the list ## ๐Ÿ› ๏ธ Tech Stack -| Category | Technologies | -|----------|--------------| -| **Core** | Kotlin, Coroutines, Flow | -| **UI** | Jetpack Compose | -| **Architecture** | Clean Architecture, MVI Pattern | -| **Dependency Injection** | Hilt | -| **Networking** | Retrofit | -| **Local Storage** | Room | -| **Functional Programming** | Arrow | -| **Testing** | Turbine, Mockk, Roborazzi, Robolectric | -| **Planned** | MockWebServer | +| Category | Technologies | +|----------|-------------------------------------------------------| +| **Core** | Kotlin, Coroutines, Flow | +| **UI** | Jetpack Compose | +| **Architecture** | Clean Architecture, MVI Pattern | +| **Dependency Injection** | Hilt | +| **Networking** | Retrofit | +| **Local Storage** | Room, SharedPreferences | +| **Functional Programming** | Arrow | +| **Testing** | Turbine, Mockk, Roborazzi, Robolectric, MockWebServer | ## ๐Ÿ—๏ธ Architecture The project follows **Clean Architecture** principles with an MVI (Model-View-Intent) pattern: -- **Presentation Layer**: Compose UI components and ViewModels -- **Domain Layer**: Business logic encapsulated in UseCases +- **Presentation Layer**: Compose UI components and ViewModels modularized by feature +- **Domain Layer**: Business logic encapsulated in UseCases and repositories - **Data Layer**: Repository implementations and data sources -- **Core Module**: Shared utilities and reusable components +- **Core Module**: Shared utilities and reusable components like api, database and preferences ``` app/ -โ”œโ”€โ”€ core/ # Core utilities and extensions -โ”œโ”€โ”€ data/ # Data layer implementations -โ”œโ”€โ”€ domain/ # Business logic and use cases -โ””โ”€โ”€ presentation/ # UI components and ViewModels +โ”œโ”€โ”€ core/ # Core modules +โ”œโ”€โ”€ data/ # Data layer +โ”œโ”€โ”€ domain/ # Domain layer +โ””โ”€โ”€ presentation/ # Feature modules + โ””โ”€โ”€ users/ # User list feature ``` ## ๐Ÿงช Testing -- **Unit Tests**: Cover ViewModels, data and parts of the core layers +- **Unit Tests**: Cover ViewModels, usecases and data layer - **UI Tests**: Screenshot testing with Roborazzi implemented -- **Integration Tests**: Planned for UseCase to DataSource layers using MockWebServer and Room +- **Integration Tests**: VM integration tests with mockwebserver ## ๐Ÿš€ Future Improvements -- Add integration tests for UseCase to DataSource layers using MockWebServer +- Manage strings in core-presentation module - Expand test coverage across the application +- Integration tests from compose to data layer +- Compose navigation tests +- Github Actions for CI/CD ## ๐Ÿ”ง Getting Started 1. Clone the repository ```bash - git clone https://github.com/yourusername/random-users.git + git clone https://github.com/xavierpellvidal/random-users.git ``` 2. Open the project in Android Studio 3. Sync the project with Gradle @@ -66,4 +69,3 @@ app/ ## ๐Ÿ“ License This project is licensed under the MIT License - see the LICENSE file for details -``` From 3c8c1c68d7dc8e6cc0e018ba4094f74232ea2ddd Mon Sep 17 00:00:00 2001 From: Pell Date: Tue, 22 Apr 2025 22:05:35 +0200 Subject: [PATCH 3/8] Bump project version --- README.md | 6 ++++++ buildSrc/src/main/kotlin/AppVersions.kt | 4 ++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 745d0af..1de44f0 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,7 @@ An Android application that displays a list of random users fetched from an external API. Features a modern and intuitive UI built with Jetpack Compose, allowing users to filter and delete profiles. + ## โœจ Features - **User List**: Browse through random user profiles with detailed information. Infinite scroll implemented @@ -10,6 +11,7 @@ An Android application that displays a list of random users fetched from an exte - **Filtering**: Filter users by name, surname and email - **Delete**: Remove users from the list + ## ๐Ÿ› ๏ธ Tech Stack | Category | Technologies | @@ -23,6 +25,7 @@ An Android application that displays a list of random users fetched from an exte | **Functional Programming** | Arrow | | **Testing** | Turbine, Mockk, Roborazzi, Robolectric, MockWebServer | + ## ๐Ÿ—๏ธ Architecture The project follows **Clean Architecture** principles with an MVI (Model-View-Intent) pattern: @@ -41,12 +44,14 @@ app/ โ””โ”€โ”€ users/ # User list feature ``` + ## ๐Ÿงช Testing - **Unit Tests**: Cover ViewModels, usecases and data layer - **UI Tests**: Screenshot testing with Roborazzi implemented - **Integration Tests**: VM integration tests with mockwebserver + ## ๐Ÿš€ Future Improvements - Manage strings in core-presentation module @@ -55,6 +60,7 @@ app/ - Compose navigation tests - Github Actions for CI/CD + ## ๐Ÿ”ง Getting Started 1. Clone the repository diff --git a/buildSrc/src/main/kotlin/AppVersions.kt b/buildSrc/src/main/kotlin/AppVersions.kt index 7c9259e..e961c65 100644 --- a/buildSrc/src/main/kotlin/AppVersions.kt +++ b/buildSrc/src/main/kotlin/AppVersions.kt @@ -4,8 +4,8 @@ object AppVersions { const val APPLICATION_ID = "com.random.user" const val COMPILE_SDK = 35 const val MIN_SDK = 26 - const val APP_VERSION_CODE = 2 - const val APP_VERSION_NAME = "1.1.1" + const val APP_VERSION_CODE = 3 + const val APP_VERSION_NAME = "1.2.0" const val JVM_TARGET = "17" val javaVersion = JavaVersion.VERSION_17 } From 48eae122f9c2601247fb62d95dd440fcba30a6c5 Mon Sep 17 00:00:00 2001 From: Pell Date: Tue, 22 Apr 2025 22:11:58 +0200 Subject: [PATCH 4/8] First action version --- .github/workflows/check-tests.yml | 33 +++++++++++++++++++++++++++++++ README.md | 4 +++- 2 files changed, 36 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/check-tests.yml diff --git a/.github/workflows/check-tests.yml b/.github/workflows/check-tests.yml new file mode 100644 index 0000000..14c1a84 --- /dev/null +++ b/.github/workflows/check-tests.yml @@ -0,0 +1,33 @@ +name: Check Code Coverage + +on: + pull_request: + branches: + - main + - develop + +permissions: + pull-requests: write + contents: read + +jobs: + build-and-test: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + - name: Run tests + id: run_tests + run: ./gradlew :app:testDebugUnitTest + continue-on-error: false diff --git a/README.md b/README.md index 1de44f0..5418336 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,9 @@ app/ - Expand test coverage across the application - Integration tests from compose to data layer - Compose navigation tests -- Github Actions for CI/CD +- Kover for coverage reports +- Konsists for code quality +- Ktlint for code style ## ๐Ÿ”ง Getting Started From ad394c71f841f7505b300070de873cd44f829eb1 Mon Sep 17 00:00:00 2001 From: Pell Date: Tue, 22 Apr 2025 22:16:36 +0200 Subject: [PATCH 5/8] Added cache to action --- .github/workflows/check-tests.yml | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/.github/workflows/check-tests.yml b/.github/workflows/check-tests.yml index 14c1a84..19a4d75 100644 --- a/.github/workflows/check-tests.yml +++ b/.github/workflows/check-tests.yml @@ -1,23 +1,31 @@ -name: Check Code Coverage +name: Check tests on: - pull_request: + workflow_dispatch: + push: branches: - - main - develop - -permissions: - pull-requests: write - contents: read + - main + pull_request: jobs: - build-and-test: + build: + name: ๐Ÿ“ฒ Build Android Project runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v4 + - name: Setup build cache + uses: actions/cache@v4 + with: + key: $gradle-build-cache + path: | + ~/.gradle/caches + ~/.gradle/wrapper + ~/.gradle/gradle-build-cache + - name: Set up JDK 17 uses: actions/setup-java@v4 with: From 1c42d3cf3933bfc834d5114d23d83afb061e20f4 Mon Sep 17 00:00:00 2001 From: Pell Date: Tue, 22 Apr 2025 22:21:08 +0200 Subject: [PATCH 6/8] Test screenshot tests in CI --- .github/workflows/check-tests.yml | 2 +- .../main/kotlin/com/random/users/users/composable/UserCard.kt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/check-tests.yml b/.github/workflows/check-tests.yml index 19a4d75..5036abb 100644 --- a/.github/workflows/check-tests.yml +++ b/.github/workflows/check-tests.yml @@ -37,5 +37,5 @@ jobs: - name: Run tests id: run_tests - run: ./gradlew :app:testDebugUnitTest + run: ./gradlew test continue-on-error: false diff --git a/presentation/users/src/main/kotlin/com/random/users/users/composable/UserCard.kt b/presentation/users/src/main/kotlin/com/random/users/users/composable/UserCard.kt index 25ef00f..7d53c20 100644 --- a/presentation/users/src/main/kotlin/com/random/users/users/composable/UserCard.kt +++ b/presentation/users/src/main/kotlin/com/random/users/users/composable/UserCard.kt @@ -52,7 +52,7 @@ internal fun UserCard( modifier = Modifier .fillMaxWidth() - .padding(16.dp), + .padding(46.dp), verticalAlignment = Alignment.CenterVertically, ) { AsyncImage( From 073e924ee3dbcc6e1d43b26b8c15379418b56a68 Mon Sep 17 00:00:00 2001 From: Pell Date: Tue, 22 Apr 2025 22:29:53 +0200 Subject: [PATCH 7/8] Final workflow version --- .github/workflows/check-tests.yml | 3 +++ README.md | 4 +++- .../main/kotlin/com/random/users/users/composable/UserCard.kt | 2 +- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/.github/workflows/check-tests.yml b/.github/workflows/check-tests.yml index 5036abb..5fced3a 100644 --- a/.github/workflows/check-tests.yml +++ b/.github/workflows/check-tests.yml @@ -16,6 +16,9 @@ jobs: steps: - name: Checkout code uses: actions/checkout@v4 + with: + fetch-depth: 0 + submodules: recursive - name: Setup build cache uses: actions/cache@v4 diff --git a/README.md b/README.md index 5418336..25a5d61 100644 --- a/README.md +++ b/README.md @@ -55,12 +55,14 @@ app/ ## ๐Ÿš€ Future Improvements - Manage strings in core-presentation module +- Add ui-components to core-presentation module or to a new ds module - Expand test coverage across the application -- Integration tests from compose to data layer +- Integration tests from roborazzi to data layer - Compose navigation tests - Kover for coverage reports - Konsists for code quality - Ktlint for code style +- Add screenshot results in PR to see the differences ## ๐Ÿ”ง Getting Started diff --git a/presentation/users/src/main/kotlin/com/random/users/users/composable/UserCard.kt b/presentation/users/src/main/kotlin/com/random/users/users/composable/UserCard.kt index 7d53c20..25ef00f 100644 --- a/presentation/users/src/main/kotlin/com/random/users/users/composable/UserCard.kt +++ b/presentation/users/src/main/kotlin/com/random/users/users/composable/UserCard.kt @@ -52,7 +52,7 @@ internal fun UserCard( modifier = Modifier .fillMaxWidth() - .padding(46.dp), + .padding(16.dp), verticalAlignment = Alignment.CenterVertically, ) { AsyncImage( From 6cf9124270da46dcddce532151d5ee24254d4dd8 Mon Sep 17 00:00:00 2001 From: Pell Date: Tue, 22 Apr 2025 22:37:28 +0200 Subject: [PATCH 8/8] Copilot typo comment --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 25a5d61..50f2337 100644 --- a/README.md +++ b/README.md @@ -6,8 +6,8 @@ An Android application that displays a list of random users fetched from an exte ## โœจ Features -- **User List**: Browse through random user profiles with detailed information. Infinite scroll implemented -- **USer details**: View detailed information about each user +- **User list**: Browse through random user profiles with detailed information. Infinite scroll implemented +- **User details**: View detailed information about each user - **Filtering**: Filter users by name, surname and email - **Delete**: Remove users from the list