Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions .github/workflows/check-tests.yml
Original file line number Diff line number Diff line change
@@ -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
68 changes: 40 additions & 28 deletions README.md
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -66,4 +79,3 @@ app/
## 📝 License

This project is licensed under the MIT License - see the LICENSE file for details
```
4 changes: 2 additions & 2 deletions buildSrc/src/main/kotlin/AppVersions.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Original file line number Diff line number Diff line change
Expand Up @@ -33,43 +33,39 @@ 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,
info = ResponseInfoDtoMother.createModel(seed = newSeed),
).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()) }
}

Expand Down Expand Up @@ -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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -46,7 +46,7 @@ class GetUserListUseCaseUnitTest {
).right(),
result,
)
coVerify { usersRepository.getUsers(1, any()) }
coVerify { usersRepository.getUsers(PAGE, RESULTS) }
coVerify { usersRepository.getDeletedUsers() }
}

Expand All @@ -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<String>().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() }
}

Expand All @@ -86,26 +79,26 @@ 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<User>().right(), result)
coVerify { usersRepository.getUsers(1, any()) }
coVerify { usersRepository.getUsers(PAGE, RESULTS) }
coVerify { usersRepository.getDeletedUsers() }
}

@Test
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() }
}

Expand All @@ -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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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")
}
}
}