diff --git a/.github/workflows/check-tests.yml b/.github/workflows/check-tests.yml new file mode 100644 index 0000000..5fced3a --- /dev/null +++ b/.github/workflows/check-tests.yml @@ -0,0 +1,44 @@ +name: Check tests + +on: + workflow_dispatch: + push: + branches: + - develop + - main + pull_request: + +jobs: + build: + name: ๐Ÿ“ฒ Build Android Project + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + submodules: recursive + + - 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: + java-version: '17' + distribution: 'temurin' + + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + - name: Run tests + id: run_tests + run: ./gradlew test + continue-on-error: false diff --git a/README.md b/README.md index baecf05..50f2337 100644 --- a/README.md +++ b/README.md @@ -1,62 +1,75 @@ ## ๐Ÿ“ฑ 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 +- Add ui-components to core-presentation module or to a new ds module - Expand test coverage across the application +- 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 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 +79,3 @@ app/ ## ๐Ÿ“ License This project is licensed under the MIT License - see the LICENSE file for details -``` 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 } 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") } } }