Merge pull request #4 from AdrianKuta/feat/quality-docs

Quality & Docs (REDI-94…98): UseCase, tests, error demo, architecture README
This commit is contained in:
2026-06-10 15:13:44 +02:00
committed by GitHub
29 changed files with 1616 additions and 71 deletions

View File

@@ -32,5 +32,18 @@ jobs:
- name: Set up Gradle
uses: gradle/actions/setup-gradle@v4
- name: Assemble (debug)
run: ./gradlew assembleDebug --no-daemon --stacktrace
- name: Unit tests (JUnit 5)
run: ./gradlew test --no-daemon --stacktrace
- name: Assemble (debug) + compile instrumented tests
# assembleDebugAndroidTest compiles the Compose UI test; it runs on a device via
# connectedDebugAndroidTest (locally / on an emulator runner), not in this build job.
run: ./gradlew assembleDebug assembleDebugAndroidTest --no-daemon --stacktrace
- name: Upload test reports
if: always()
uses: actions/upload-artifact@v4
with:
name: test-reports
path: '**/build/reports/tests/'
if-no-files-found: ignore

400
README.md
View File

@@ -1,84 +1,388 @@
# Android Architecture Showcase
A single runnable **Android-only (Jetpack Compose)** reference app that demonstrates good
architecture conventions — each in its own module/example. Teaching repo: every module is meant to
be minimal but complete and idiomatic.
A single, runnable **Android-only (Jetpack Compose)** reference app that demonstrates a complete,
idiomatic multi-module architecture — each convention shown in its own minimal-but-complete module.
It is a teaching repo: the goal is not features but *how the pieces fit together*.
Data comes from the no-key [Rick & Morty API](https://rickandmortyapi.com/). The app lists
characters, opens a detail screen, renders that same list **twice** (Compose and classic Views), has
a small MVVM *About* screen for contrast, and a dedicated **error-handling demo**.
> **Status:** built milestone-by-milestone from the
> [Linear backlog](https://linear.app/adrian-kuta/project/android-architecture-showcase-b5ecdeddda6c).
> **Foundation**, **Core Infrastructure**, the **Flagship MVI** characters feature, and
> **Breadth & Contrast** (character detail, the MVVM About screen, the Views renderer, and
> Compose↔View interop) are complete and the project assembles green. Full architecture docs land
> with the *Quality & Docs* milestone.
> Foundation, Core Infrastructure, the flagship MVI feature, Breadth & Contrast, and Quality & Docs
> are complete; the project assembles green and ships unit + UI tests. The only optional item left is
> the Room offline-cache stretch (see [Optional: Room stretch](#optional-room-stretch)).
---
## Table of contents
- [Stack](#stack)
- [Module structure & dependency rules](#module-structure--dependency-rules)
- [The data → UI flow](#the-data--ui-flow)
- [Presentation patterns: MVI vs MVVM](#presentation-patterns-mvi-vs-mvvm)
- [One ViewModel, two renderers (Compose vs Views)](#one-viewmodel-two-renderers-compose-vs-views)
- [Errors: `Result`, `DataError`, `UiText`](#errors-result-dataerror-uitext)
- [Navigation](#navigation)
- [Dependency injection (Koin)](#dependency-injection-koin)
- [Testing](#testing)
- [Build & run (`android` CLI)](#build--run-android-cli)
- [Optional: Room stretch](#optional-room-stretch)
- [Convention skills index](#convention-skills-index)
---
## Stack
Multi-module Gradle + `build-logic` convention plugins · Koin (constructor DSL) · Ktor ·
KotlinX Serialization · Coil · Timber · type-safe Compose Navigation. Data comes from the no-key
[Rick & Morty API](https://rickandmortyapi.com/).
| Concern | Choice |
|---|---|
| Build | Multi-module Gradle + `:build-logic` **convention plugins**; a single **version catalog** (`gradle/libs.versions.toml`) is the only place versions live |
| Toolchain | AGP 9.0.1, Kotlin 2.3.20, Gradle 9.1, `compileSdk`/`targetSdk` 36, `minSdk` 24, Java 17 |
| UI | Jetpack Compose (Material 3) + one classic **Views/XML** renderer |
| DI | Koin 4.1 (constructor DSL) |
| Networking | Ktor (OkHttp engine) + KotlinX Serialization |
| Images | Coil 3 |
| Navigation | type-safe Compose Navigation (`@Serializable` routes) |
| Logging | Timber |
| Async | Coroutines + Flow |
| Testing | JUnit 5, Turbine, AssertK, `kotlinx-coroutines-test`, Ktor `MockEngine`, Compose UI test |
What it showcases: **MVI** as the primary presentation pattern (flagship *characters* feature),
an **MVVM** contrast screen (*about*), and the same MVI `ViewModel` driven by **two renderers**
Jetpack Compose and classic **XML + ViewBinding + RecyclerView** — proving the presentation logic is
UI-toolkit-agnostic. See [Presentation patterns](#presentation-patterns-mvi-vs-mvvm) below.
> **AGP 9 gotcha:** AGP 9.0 has **built-in Kotlin**. Applying `com.android.application`/`library`
> auto-applies the Kotlin Android plugin, so the convention plugins must **not** apply
> `org.jetbrains.kotlin.android` themselves. Source lives in `src/main/kotlin`.
## Module structure
---
## Module structure & dependency rules
Modularized **by feature first, then by layer** (Clean Architecture: `presentation → domain ← data`).
Features never depend on each other; anything shared moves to a `core` module; `:app` wires the graph.
```
:app → wires everything; single Activity, Compose host
:build-logic → Gradle convention plugins (the only place versions/config live)
:core:domain → Result/error types, shared domain models (pure Kotlin)
:core:dataKtor HttpClient factory, safe-call helpers
:core:presentationUiText, ObserveAsEvents, DataError → UiText
:core:design-system → AppTheme + reusable composables
:feature:characters:domain → models + repository interface (pure Kotlin)
:feature:characters:data → DTOs, mappers, data source, repository impl
:feature:characters:presentation → MVI ViewModel/State/Action/Event (UI-agnostic: no Compose, no Views)
:feature:characters:presentation-compose → Compose renderer
:feature:characters:presentation-views → Views/XML renderer (same ViewModel)
:feature:about:presentation → MVVM contrast screen
:app → wires everything; single Activity, Compose host, Koin start
:build-logic → Gradle convention plugins (the only place build config lives)
:core:domain Result / Error / DataError, shared contracts (pure Kotlin)
:core:data Ktor HttpClient factory + safe-call helpers (BuildConfig.BASE_URL)
:core:presentation → UiText, ObserveAsEvents, DataError → UiText
:core:design-system → AppTheme + reusable composables (AppScaffold, ErrorState, …)
:feature:characters:domain → models, CharacterRepository, GetCharactersPageUseCase (pure Kotlin)
:feature:characters:data → DTOs, mappers, KtorCharacterDataSource, NetworkCharacterRepository
:feature:characters:presentation → MVI ViewModels/State/Action/Event (UI-agnostic: no Compose, no Views)
:feature:characters:presentation-compose → Compose renderer (list, detail, error demo, nav graph)
:feature:characters:presentation-views → Views/XML renderer of the list (same ViewModel)
:feature:about:presentation → MVVM contrast screen
```
**Dependency rules:** `presentation → domain ← data`; `domain` depends only on `:core:domain`;
features never depend on other features; `:app` wires the graph.
**Dependency rules** (enforced by what each convention plugin exposes):
## Presentation patterns (MVI vs MVVM)
| Layer | May depend on |
|---|---|
| `presentation` | own `domain`, `core:domain`, `core:presentation`, `core:design-system` |
| `data` | own `domain`, `core:domain`, `core:data` |
| `domain` | `core:domain` only — never `data` or `presentation` |
| `:app` | everything |
Both patterns live side by side so the trade-off is concrete, not theoretical.
A key consequence: `:core:presentation`'s `UiText` is **Compose-free**, and the `compose` convention
uses `implementation` (not `api`), so the UI-agnostic `:feature:characters:presentation` never gets
Compose on its classpath — which is what lets two different renderers share one ViewModel.
See **android-module-structure**.
---
## The data → UI flow
One request flows through every layer, each with one job:
```
Rick & Morty API
│ JSON
CharacterDto / CharactersResponseDto (:data/dto) serialization shape
│ CharacterMapper.toDomain() (:data/mappers) DTO → domain, never the reverse leaks up
Character / CharactersPage (:domain/model) pure Kotlin domain model
│ CharacterRepository.getCharacters() (:domain contract, :data impl)
│ GetCharactersPageUseCase(page) (:domain/usecase) domain operation (see note)
CharacterListViewModel (:presentation) holds State, processes Action, emits Event
│ Character.toCharacterUi() (:presentation/model) domain → UI model (display shaping)
CharacterUi in CharacterListState (:presentation) immutable UI state
CharacterListScreen / CharacterListFragment (:presentation-compose / -views) dumb renderers
```
- **DTOs** (`*Dto`) live in `data`; **domain models** are separate and never become DTOs/entities.
Mappers are pure extension functions in a `mappers/` package (`toDomain()`). See
**android-data-layer**, **android-data-layer-mappers**.
- **UI models** (`*Ui`) live in `presentation` and carry display-ready data (e.g. blank detail fields
pre-formatted to an em dash). See **android-presentation-mvi**.
### Note — when to add a UseCase
`GetCharactersPageUseCase` is intentionally a **thin pass-through** included to show the convention. The
rule it illustrates:
> Add a UseCase when a screen needs **business logic that doesn't belong in the ViewModel** — real
> rules, or **composition of several repositories/sources** into one operation. When the ViewModel
> would merely forward a single repository call, injecting the repository directly is fine.
Here the list VM uses the UseCase; the detail VM calls `CharacterRepository` directly — both are
correct, and the contrast is the point. See **android-module-structure**, **android-di-koin**.
---
## Presentation patterns: MVI vs MVVM
Both patterns live side by side so the trade-off is concrete.
| | **MVI** (`:feature:characters:*`) | **MVVM** (`:feature:about:presentation`) |
|---|---|---|
| State | one immutable `State` data class | one immutable `State` data class |
| User input | a single `onAction(Action)` funnel + sealed `Action` | plain public methods on the `ViewModel` |
| Side effects | one-time `Event`s via a `Channel` (nav, snackbar) | none — the screen calls a method / uses `LocalUriHandler` directly |
| Side effects | one-time `Event`s via a `Channel` (nav, snackbar) | none — the screen calls a method / uses `LocalUriHandler` |
| Best when | state is complex and interacting; effects matter | the screen is small and mostly static |
The flagship characters list is MVI because its state is genuinely complex — pagination, loading
vs. next-page loading, error surfacing, and `SavedStateHandle` restore after process death — and it
emits navigation/snackbar effects. The About screen is deliberately MVVM: a `StateFlow` plus a couple
of public methods, with **no `Action` and no `Event` types at all**, because that ceremony would be
pure overhead for static content. Rule of thumb: **reach for MVI when state is complex and side
effects matter; reach for MVVM when the screen is simple.**
The flagship list is **MVI** because its state is genuinely complex — pagination, loading vs.
next-page loading, error surfacing, `SavedStateHandle` restore after process death — and it emits
navigation/snackbar effects. *About* is deliberately **MVVM**: a `StateFlow` plus a couple of public
methods, with **no `Action` and no `Event` types at all**, because that ceremony would be pure
overhead for static content.
### One ViewModel, two renderers
### Note — Events vs State
`:feature:characters:presentation` is **UI-toolkit-agnostic** — it has no Compose *and* no Views
dependency (state stays Compose-stable via `kotlinx-collections-immutable` rather than `@Stable`).
The exact same `CharacterListViewModel` (State/Action/Event/UI-model) is rendered twice:
State is what the screen **is** (re-rendered on every change, survives recomposition/rotation).
Events are things that happen **once** — navigate, show a snackbar. Modeling a one-time effect as
state causes it to re-fire on rotation; modeling durable data as an event drops it. MVI keeps them
separate (`StateFlow` vs `Channel`); the Compose side consumes events with `ObserveAsEvents`, the
Views side with `repeatOnLifecycle`.
### Note — when MVVM is acceptable
Reach for MVI when state is complex **and** side effects matter. Reach for plain MVVM when the screen
is small, mostly static, and has no real side effects — the *About* screen is the canonical case.
See **android-presentation-mvi**, **android-compose-ui**.
---
## One ViewModel, two renderers (Compose vs Views)
`:feature:characters:presentation` is **UI-toolkit-agnostic** — no Compose *and* no Views dependency.
State stays Compose-stable via `kotlinx-collections-immutable` (`ImmutableList`) rather than the
`@Stable` annotation (which would pull in compose-runtime). The exact same `CharacterListViewModel`
(State/Action/Event/UI-model) is rendered twice:
- `:feature:characters:presentation-compose` — Jetpack Compose (`LazyColumn`).
- `:feature:characters:presentation-views``Fragment` + ViewBinding + `RecyclerView`/`DiffUtil`.
- `:feature:characters:presentation-views``Fragment` + ViewBinding + `RecyclerView`/`DiffUtil`,
resolving the **same** Koin `CharacterListViewModel` via `by viewModel()`.
`:app` hosts the Views renderer inside the Compose `NavHost` via `AndroidFragment` (Compose↔View
interop) and injects all navigation as callbacks, so the renderers stay decoupled from each other.
interop) and injects all navigation as callbacks, so the renderers stay decoupled from each other and
from navigation.
## Build & run
> **Material3-XML-theme gotcha:** the host Activity (`MainActivity`) extends **`FragmentActivity`**
> (so `AndroidFragment` has a `FragmentManager`) and uses a **Material Components XML theme**, which
> the classic Views (e.g. `MaterialToolbar`, `?attr/colorOnSurfaceVariant`) require. A plain
> `ComponentActivity` or a non-Material theme breaks the Fragment renderer.
See **android-compose-ui**, **android-module-structure**.
---
## Errors: `Result`, `DataError`, `UiText`
Expected failures are **values, not exceptions**. The whole app speaks one typed result:
```kotlin
sealed interface Result<out D, out E : Error> { Success(data) ; Error(error) } // :core:domain
sealed interface DataError : Error { enum Network { NO_INTERNET, NOT_FOUND, SERVER_ERROR, SERIALIZATION, } ; enum Local { } }
```
- The **data layer** catches transport/parse exceptions at the boundary (`safeCall` in `:core:data`)
and converts them to `Result.Error(DataError.Network.*)` — HTTP status → typed error, and a
malformed body → `SERIALIZATION` (the cause chain is unwrapped because Ktor wraps the kotlinx
`SerializationException`). Upper layers never see raw exceptions.
- The **presentation layer** maps a `DataError` to user-facing **`UiText`** via `DataError.toUiText()`
(`:core:presentation`). `UiText` is itself Compose-free (a `StringResource`/`DynamicString`), so a
UI-agnostic ViewModel can hold `UiText?` in state; the renderer resolves it (`asString()` in
Compose, `asString(context)` in Views).
### The error-handling demo (overflow menu → "Error handling demo")
A runnable walk-through of the whole pipeline. Pick a failure to force; the ViewModel produces the
real `DataError.Network`, routes it through the **same** steps a genuine call uses, and the shared
design-system `ErrorState` renders it:
```
[Force: No internet] → Result.Error(DataError.Network.NO_INTERNET)
→ onFailure { … }
→ DataError.toUiText() = UiText.StringResource(R.string.error_no_internet)
→ ErrorState(message = uiText.asString(), onRetry = { onAction(OnRetry) })
```
Three distinct cases (`NO_INTERNET`, `NOT_FOUND`, `SERVER_ERROR`) each render their mapped message;
**Retry** re-issues the last request as an Action; a successful load **clears** the error. The same
`ErrorState` + retry Action is what the real list and detail screens use.
See **android-error-handling**, **android-presentation-mvi**.
---
## Navigation
Type-safe Compose Navigation with `@Serializable` route objects, one nav graph per feature, assembled
in `:app`.
```kotlin
@Serializable data object CharacterListRoute
@Serializable data class CharacterDetailRoute(val characterId: Int)
@Serializable data object ErrorDemoRoute
```
- **Intra-feature** navigation (list → detail, list → error demo) is driven by the `NavController`
passed into `charactersGraph(navController, …)`.
- **Cross-feature / cross-toolkit** destinations (About, the Views renderer) are exposed as **lambda
callbacks** supplied by `:app` — a feature never imports another feature's route.
- **Nav args without a nav dependency:** type-safe nav serializes `CharacterDetailRoute.characterId`
into the destination's arguments, which Navigation copies into the ViewModel's `SavedStateHandle`.
`CharacterDetailViewModel` reads `savedStateHandle.get<Int>("characterId")` by field name — so the
UI-agnostic `presentation` module needs **no** navigation dependency. The same `SavedStateHandle`
also persists the list's loaded page across process death.
See **android-navigation**.
---
## Dependency injection (Koin)
One Koin module per feature layer (only if it has something to provide), all assembled in
`ArchitectureApp` — never inside feature modules. Prefer the **constructor DSL**:
```kotlin
// :feature:characters:data
val charactersDataModule = module {
singleOf(::KtorCharacterDataSource)
singleOf(::NetworkCharacterRepository) { bind<CharacterRepository>() }
}
// :feature:characters:presentation
val charactersPresentationModule = module {
factoryOf(::GetCharactersPageUseCase) // stateless UseCase
viewModelOf(::CharacterListViewModel) // same VM used by both renderers
viewModelOf(::CharacterDetailViewModel)
viewModelOf(::ErrorDemoViewModel)
}
```
The lambda form (`single { … }`) appears only where a constructor reference can't express the binding
— e.g. `single { HttpClientFactory.create(OkHttp.create()) }` in `coreDataModule` (a factory call,
not a constructor). Compose roots inject with `koinViewModel()`; the Fragment uses `by viewModel()`
both resolve the **same** `CharacterListViewModel` class and supply its `SavedStateHandle`.
See **android-di-koin**, **koin-constructor-dsl**.
---
## Testing
Tests prove the architecture, not just the code. Stack: **JUnit 5**, **Turbine** (Flow), **AssertK**,
`kotlinx-coroutines-test`, Ktor **`MockEngine`**, and Compose UI test.
| What | Where | Kind |
|---|---|---|
| `GetCharactersPageUseCase` | `:feature:characters:domain` `src/test` | pure JVM, JUnit 5 |
| `CharacterListViewModel`, `CharacterDetailViewModel` | `:feature:characters:presentation` `src/test` | JVM unit, fakes + Turbine + `SavedStateHandle` |
| `NetworkCharacterRepository` | `:feature:characters:data` `src/test` | JVM unit, Ktor `MockEngine` |
| `CharacterListScreen` (robot) | `:feature:characters:presentation-compose` `src/androidTest` | instrumented Compose UI |
Conventions demonstrated:
- **Fakes, not mocks.** `FakeCharacterRepository` is a real in-memory implementation with a
`failWith` toggle and call counts — tests assert against working behaviour, not recorded calls.
- **VM tested through its public MVI surface** (State/Action/Event) with a directly-constructed
`SavedStateHandle`, so the same tests hold for either renderer. Coverage includes happy path,
error → `UiText` + snackbar `Event`, pagination end-reached, **process-death restore**, and the
rapid-duplicate-paging guard (which is why these use `StandardTestDispatcher`).
- **Repository tested over a real Ktor client** with a swapped `MockEngine`
(`HttpClientFactory.create(engine)`): success mapping, `404 → NOT_FOUND`, `500 → SERVER_ERROR`,
malformed body `→ SERIALIZATION`.
- **Robot pattern** for the Compose UI test: `CharacterListRobot` methods `return this` so a test
reads as a scenario; it asserts a rendered item, the empty/error states, and that a tap fires the
right `Action`.
> **JUnit 5 on AGP 9:** the `de.mannodermaus.android-junit5` Gradle plugin targets AGP 8.x, so this
> repo doesn't use it. `AndroidUnitTest` extends Gradle's `Test`, so the `architecture.android.unit.test`
> convention plugin just calls `useJUnitPlatform()` and adds the `unit-test` bundle — including the
> `junit-platform-launcher`, which Gradle 9 no longer bundles.
> **Espresso + API 34+:** Compose's test rule drives Espresso's `onIdle`, and transitive Espresso
> 3.5.0 calls the removed `InputManager.getInstance()`. The catalog pins espresso/runner to current
> versions in the `compose-ui-test` bundle to fix that.
What runs where: `./gradlew test` (all JVM unit tests) runs in **CI**; the instrumented Compose test
runs on a device/emulator via `./gradlew :feature:characters:presentation-compose:connectedDebugAndroidTest`
(CI compiles it via `assembleDebugAndroidTest`). An Espresso test for the Fragment renderer is
possible but intentionally omitted (the VM logic is already covered by the shared unit tests).
See **android-testing**.
---
## Build & run (`android` CLI)
```bash
./gradlew assembleDebug # build the APK
./gradlew projects # print the module tree
./gradlew check # tests + lint (added in the Quality & Docs milestone)
# Build
./gradlew assembleDebug # build the debug APK
./gradlew projects # print the module tree
./gradlew test # all JVM unit tests (JUnit 5)
./gradlew :feature:characters:presentation-compose:connectedDebugAndroidTest # Compose UI test (needs a device)
```
Using the `android` CLI for an emulator + run:
```bash
android emulator list # list AVDs
android emulator start <avd-name> # boot an emulator (returns when ready)
android run # build & deploy the app
android screenshot -o screen.png # capture the current screen
android layout --pretty # dump the UI tree (faster than a screenshot for debugging)
android docs search "<topic>" # search authoritative Android docs
```
Requires JDK 17+ (the Gradle build pins a Java 17 toolchain) and the Android SDK
(`compileSdk 36`, `minSdk 24`).
---
## Optional: Room stretch
Out of core scope and **not implemented** (tracked as the optional REDI-99). It would add a `room`
convention plugin and a `:core:database` (or feature Room set) with `CharacterEntity` + DAO +
`@Database` (prefer `autoMigrations`), then convert the repository to **offline-first**
(`OfflineFirstCharacterRepository`: network → persist → expose a DB `Flow`; the ViewModel observes the
DB, never the network response). The current `CharacterRepository` returning the `DataError`
supertype already anticipates a multi-source implementation. See **android-data-layer**.
---
## Convention skills index
This repo is a narrative index of these conventions:
| Skill | Where it shows up |
|---|---|
| android-module-structure | module graph, dependency rules, convention plugins |
| android-presentation-mvi | characters list/detail/error-demo (State/Action/Event/VM) |
| android-compose-ui | Compose renderers, design-system, previews, stability |
| android-navigation | type-safe routes, per-feature graphs, callback decoupling |
| android-di-koin / koin-constructor-dsl | feature Koin modules, `*Of` constructor DSL |
| android-data-layer / android-data-layer-mappers | data sources, repository, DTOs, mappers |
| android-error-handling | `Result`/`DataError`/`UiText`, `safeCall`, the error demo |
| android-testing | unit tests, fakes, `MockEngine`, the robot UI test |
| android-cli | build/run/emulator steps above |

View File

@@ -47,6 +47,10 @@ gradlePlugin {
id = "architecture.domain.module"
implementationClass = "com.example.architecture.convention.DomainModuleConventionPlugin"
}
register("androidUnitTest") {
id = "architecture.android.unit.test"
implementationClass = "com.example.architecture.convention.AndroidUnitTestConventionPlugin"
}
register("compose") {
id = "architecture.compose"
implementationClass = "com.example.architecture.convention.ComposeConventionPlugin"

View File

@@ -19,6 +19,9 @@ class AndroidLibraryConventionPlugin : Plugin<Project> {
defaultConfig {
minSdk = MIN_SDK
// Used by instrumented (androidTest) tests, e.g. the Compose UI test in
// :feature:characters:presentation-compose. Harmless for modules without androidTest.
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
compileOptions {

View File

@@ -0,0 +1,32 @@
package com.example.architecture.convention
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.api.tasks.testing.Test
import org.gradle.kotlin.dsl.dependencies
import org.gradle.kotlin.dsl.withType
/**
* Runs an Android library module's local unit tests (`src/test`) on the **JUnit 5 platform** with the
* shared `unit-test` toolset (JUnit Jupiter, kotlinx-coroutines-test, Turbine, AssertK).
*
* Deliberately does NOT use the `de.mannodermaus.android-junit5` Gradle plugin: its 1.11.x line
* targets AGP 8.x and we build on AGP 9.0. It isn't needed for *local* unit tests anyway —
* `com.android.build.gradle.tasks.factory.AndroidUnitTest` extends Gradle's [Test] task, so calling
* `useJUnitPlatform()` on it is enough (this mirrors `DomainModuleConventionPlugin`, which does the
* same for pure-JVM modules).
*/
class AndroidUnitTestConventionPlugin : Plugin<Project> {
override fun apply(target: Project) = with(target) {
dependencies {
add("testImplementation", libs.findBundle("unit-test").get())
add("testRuntimeOnly", libs.findLibrary("junit-jupiter-engine").get())
// Gradle 9 dropped the bundled launcher; JUnit 5 won't start without it.
add("testRuntimeOnly", libs.findLibrary("junit-platform-launcher").get())
}
tasks.withType<Test>().configureEach {
useJUnitPlatform()
}
}
}

View File

@@ -21,6 +21,8 @@ class DomainModuleConventionPlugin : Plugin<Project> {
add("testImplementation", libs.findLibrary("junit-jupiter-api").get())
add("testImplementation", libs.findLibrary("assertk").get())
add("testRuntimeOnly", libs.findLibrary("junit-jupiter-engine").get())
// Gradle 9 dropped the bundled launcher; JUnit 5 won't start without it.
add("testRuntimeOnly", libs.findLibrary("junit-platform-launcher").get())
}
tasks.withType<Test>().configureEach {

View File

@@ -75,8 +75,16 @@ suspend inline fun <reified T> safeCall(
Result.Error(DataError.Network.SERIALIZATION)
} catch (e: Exception) {
if (e is CancellationException) throw e
logNetworkError(e, "Unknown network failure")
Result.Error(DataError.Network.UNKNOWN)
// Ktor's ContentNegotiation wraps a kotlinx SerializationException (malformed/garbage body)
// in its own ContentConvertException, so the catch above misses it. Scan the cause chain so a
// bad payload still maps to SERIALIZATION instead of the generic UNKNOWN.
if (generateSequence(e as Throwable) { it.cause }.any { it is SerializationException }) {
logNetworkError(e, "Serialization failure (wrapped)")
Result.Error(DataError.Network.SERIALIZATION)
} else {
logNetworkError(e, "Unknown network failure")
Result.Error(DataError.Network.UNKNOWN)
}
}
}

View File

@@ -2,6 +2,7 @@ plugins {
alias(libs.plugins.architecture.android.library)
alias(libs.plugins.architecture.koin)
alias(libs.plugins.architecture.kotlinx.serialization)
alias(libs.plugins.architecture.android.unit.test)
}
android {
@@ -12,4 +13,9 @@ dependencies {
implementation(project(":core:domain"))
implementation(project(":core:data"))
implementation(project(":feature:characters:domain"))
// Swap a Ktor MockEngine into HttpClientFactory.create(...) for the repository test.
testImplementation(libs.ktor.client.mock)
testImplementation(libs.ktor.client.content.negotiation)
testImplementation(libs.ktor.serialization.kotlinx.json)
}

View File

@@ -0,0 +1,162 @@
package com.example.architecture.feature.characters.data
import assertk.assertThat
import assertk.assertions.endsWith
import assertk.assertions.isEqualTo
import assertk.assertions.isInstanceOf
import assertk.assertions.isNotNull
import com.example.architecture.core.data.network.HttpClientFactory
import com.example.architecture.core.domain.DataError
import com.example.architecture.core.domain.Result
import com.example.architecture.feature.characters.data.datasource.KtorCharacterDataSource
import io.ktor.client.engine.mock.MockEngine
import io.ktor.client.engine.mock.MockRequestHandleScope
import io.ktor.client.engine.mock.respond
import io.ktor.client.request.HttpRequestData
import io.ktor.client.request.HttpResponseData
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpStatusCode
import io.ktor.http.headersOf
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
/**
* Data-layer test for [NetworkCharacterRepository]. A Ktor [MockEngine] is swapped into the real
* [HttpClientFactory] (`create(engine)` takes the engine precisely so tests can do this) — so the
* full path under test is genuine: Ktor request → status/JSON handling in `safeCall` → DTO mapping →
* domain model. Covers success mapping, a 404 and a 5xx mapped to typed [DataError.Network], and a
* malformed-body → SERIALIZATION case.
*/
class NetworkCharacterRepositoryTest {
private fun repository(
handler: MockRequestHandleScope.(HttpRequestData) -> HttpResponseData,
): NetworkCharacterRepository {
val engine = MockEngine { request -> handler(request) }
val httpClient = HttpClientFactory.create(engine)
return NetworkCharacterRepository(KtorCharacterDataSource(httpClient))
}
private fun jsonHeaders() = headersOf(HttpHeaders.ContentType, "application/json")
@Test
fun `getCharacters maps a successful response to a domain page`() = runTest {
var requestedPath: String? = null
var requestedPage: String? = null
val repository = repository { request ->
requestedPath = request.url.encodedPath
requestedPage = request.url.parameters["page"]
respond(content = CHARACTERS_PAGE_JSON, status = HttpStatusCode.OK, headers = jsonHeaders())
}
val result = repository.getCharacters(page = 3)
// Request construction: correct endpoint and the page forwarded as a query param.
assertThat(requestedPath).isNotNull().endsWith("/character")
assertThat(requestedPage).isEqualTo("3")
assertThat(result).isInstanceOf(Result.Success::class)
val page = (result as Result.Success).data
assertThat(page.characters.size).isEqualTo(2)
assertThat(page.characters.first().name).isEqualTo("Rick Sanchez")
// `next` URL ".../character?page=2" is parsed to a page number.
assertThat(page.nextPage).isEqualTo(2)
}
@Test
fun `getCharacters maps 404 to NOT_FOUND`() = runTest {
val repository = repository {
respond(content = "", status = HttpStatusCode.NotFound)
}
val result = repository.getCharacters(page = 1)
assertThat(result).isEqualTo(Result.Error(DataError.Network.NOT_FOUND))
}
@Test
fun `getCharacters maps 500 to SERVER_ERROR`() = runTest {
val repository = repository {
respond(content = "", status = HttpStatusCode.InternalServerError)
}
val result = repository.getCharacters(page = 1)
assertThat(result).isEqualTo(Result.Error(DataError.Network.SERVER_ERROR))
}
@Test
fun `getCharacters maps a malformed body to SERIALIZATION`() = runTest {
val repository = repository {
respond(content = "{ this is not valid json", status = HttpStatusCode.OK, headers = jsonHeaders())
}
val result = repository.getCharacters(page = 1)
assertThat(result).isEqualTo(Result.Error(DataError.Network.SERIALIZATION))
}
@Test
fun `getCharacterDetails maps a successful response to domain details`() = runTest {
var requestedPath: String? = null
val repository = repository { request ->
requestedPath = request.url.encodedPath
respond(content = CHARACTER_JSON, status = HttpStatusCode.OK, headers = jsonHeaders())
}
val result = repository.getCharacterDetails(id = 1)
// Request construction: the id is placed in the path.
assertThat(requestedPath).isNotNull().endsWith("/character/1")
assertThat(result).isInstanceOf(Result.Success::class)
val details = (result as Result.Success).data
assertThat(details.name).isEqualTo("Rick Sanchez")
assertThat(details.origin).isEqualTo("Earth (C-137)")
assertThat(details.episodeCount).isEqualTo(3)
}
private companion object {
val CHARACTER_JSON = """
{
"id": 1,
"name": "Rick Sanchez",
"status": "Alive",
"species": "Human",
"type": "",
"gender": "Male",
"origin": { "name": "Earth (C-137)", "url": "" },
"location": { "name": "Citadel of Ricks", "url": "" },
"image": "https://example.com/1.png",
"episode": ["e1", "e2", "e3"]
}
""".trimIndent()
val CHARACTERS_PAGE_JSON = """
{
"info": {
"count": 2,
"pages": 1,
"next": "https://rickandmortyapi.com/api/character?page=2",
"prev": null
},
"results": [
{
"id": 1, "name": "Rick Sanchez", "status": "Alive", "species": "Human",
"type": "", "gender": "Male",
"origin": { "name": "Earth (C-137)", "url": "" },
"location": { "name": "Citadel of Ricks", "url": "" },
"image": "https://example.com/1.png", "episode": ["e1", "e2"]
},
{
"id": 2, "name": "Morty Smith", "status": "Alive", "species": "Human",
"type": "", "gender": "Male",
"origin": { "name": "Earth (C-137)", "url": "" },
"location": { "name": "Citadel of Ricks", "url": "" },
"image": "https://example.com/2.png", "episode": ["e1"]
}
]
}
""".trimIndent()
}
}

View File

@@ -0,0 +1,27 @@
package com.example.architecture.feature.characters.domain.usecase
import com.example.architecture.core.domain.DataError
import com.example.architecture.core.domain.Result
import com.example.architecture.feature.characters.domain.CharacterRepository
import com.example.architecture.feature.characters.domain.model.CharactersPage
/**
* Loads one page of characters.
*
* **When to add a UseCase (convention note):** introduce a UseCase when a screen needs business
* logic that does NOT belong in the ViewModel — non-trivial rules, or *composition* of several
* repositories/sources into one domain operation. When the ViewModel would merely forward a single
* repository call, skipping the UseCase and injecting the repository directly is perfectly fine.
*
* This particular UseCase is a **thin pass-through, included for illustration**: it adds no logic
* beyond delegating to [CharacterRepository]. It earns its place only as a showcase of the
* convention (domain-owned, `operator fun invoke`, constructor-injected). In a real app you would
* grow it the moment list loading gained real behaviour (filtering, merging a local cache, …) — or
* delete it and let the ViewModel call the repository.
*/
class GetCharactersPageUseCase(
private val characterRepository: CharacterRepository,
) {
suspend operator fun invoke(page: Int): Result<CharactersPage, DataError> =
characterRepository.getCharacters(page)
}

View File

@@ -0,0 +1,79 @@
package com.example.architecture.feature.characters.domain.usecase
import assertk.assertThat
import assertk.assertions.isEqualTo
import assertk.assertions.isInstanceOf
import com.example.architecture.core.domain.DataError
import com.example.architecture.core.domain.Result
import com.example.architecture.feature.characters.domain.CharacterRepository
import com.example.architecture.feature.characters.domain.model.Character
import com.example.architecture.feature.characters.domain.model.CharacterDetails
import com.example.architecture.feature.characters.domain.model.CharacterStatus
import com.example.architecture.feature.characters.domain.model.CharactersPage
import kotlinx.coroutines.runBlocking
import org.junit.jupiter.api.Test
/**
* Tests for the (thin pass-through) [GetCharactersPageUseCase]: it must forward the requested page to
* the repository and return its result verbatim — success and error alike. Pure JVM test on the
* JUnit 5 platform (see DomainModuleConventionPlugin); collaborator is a hand-written fake.
*/
class GetCharactersPageUseCaseTest {
@Test
fun `returns the repository page on success`() = runBlocking {
val page = CharactersPage(characters = listOf(domainCharacter(1)), nextPage = 2)
val useCase = GetCharactersPageUseCase(FakeCharacterRepository(pageResult = Result.Success(page)))
val result = useCase(page = 1)
assertThat(result).isEqualTo(Result.Success(page))
}
@Test
fun `propagates the repository error`() = runBlocking {
val useCase = GetCharactersPageUseCase(
FakeCharacterRepository(pageResult = Result.Error(DataError.Network.SERVER_ERROR)),
)
val result = useCase(page = 1)
assertThat(result).isInstanceOf(Result.Error::class)
assertThat((result as Result.Error).error).isEqualTo(DataError.Network.SERVER_ERROR)
}
@Test
fun `forwards the requested page number`() = runBlocking {
val fake = FakeCharacterRepository(
pageResult = Result.Success(CharactersPage(characters = emptyList(), nextPage = null)),
)
val useCase = GetCharactersPageUseCase(fake)
useCase(page = 7)
assertThat(fake.lastRequestedPage).isEqualTo(7)
}
private class FakeCharacterRepository(
private val pageResult: Result<CharactersPage, DataError>,
) : CharacterRepository {
var lastRequestedPage: Int? = null
private set
override suspend fun getCharacters(page: Int): Result<CharactersPage, DataError> {
lastRequestedPage = page
return pageResult
}
override suspend fun getCharacterDetails(id: Int): Result<CharacterDetails, DataError> =
Result.Error(DataError.Network.NOT_FOUND)
}
private fun domainCharacter(id: Int) = Character(
id = id,
name = "Character $id",
status = CharacterStatus.ALIVE,
species = "Human",
imageUrl = "https://example.com/$id.png",
)
}

View File

@@ -13,4 +13,9 @@ dependencies {
implementation(project(":core:design-system"))
implementation(project(":feature:characters:domain"))
implementation(project(":feature:characters:presentation"))
// Instrumented Compose UI test (robot pattern). The Compose convention already adds the BOM to
// androidTestImplementation; ui-test-manifest provides the empty Activity ComposeTestRule hosts in.
androidTestImplementation(libs.bundles.compose.ui.test)
debugImplementation(libs.androidx.compose.ui.test.manifest)
}

View File

@@ -0,0 +1,81 @@
package com.example.architecture.feature.characters.presentation.compose
import android.content.Context
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.ComposeContentTestRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import com.example.architecture.core.design.system.theme.AppTheme
import com.example.architecture.feature.characters.presentation.CharacterListAction
import com.example.architecture.feature.characters.presentation.CharacterListState
import org.junit.Assert.assertTrue
/**
* Robot for [CharacterListScreen] UI tests. Each method returns `this` so calls read as a fluent
* scenario (`robot.setContent(state).assertCharacterShown(...).clickCharacter(...)`). The robot owns
* the interaction vocabulary; the test owns the assertions' intent — keeping tests readable and
* resilient to UI structure changes. See android-testing.
*/
class CharacterListRobot(
private val composeRule: ComposeContentTestRule,
private val context: Context,
) {
private val recordedActions = mutableListOf<CharacterListAction>()
fun setContent(state: CharacterListState): CharacterListRobot {
composeRule.setContent {
AppTheme {
CharacterListScreen(
state = state,
onAction = { recordedActions += it },
onOpenAbout = {},
onOpenViewsList = {},
onOpenErrorDemo = {},
)
}
}
return this
}
fun assertCharacterShown(name: String): CharacterListRobot {
composeRule.onNodeWithText(name).assertIsDisplayed()
return this
}
fun assertEmptyStateShown(): CharacterListRobot {
composeRule.onNodeWithText(context.getString(R.string.characters_empty)).assertIsDisplayed()
return this
}
fun assertErrorShown(message: String): CharacterListRobot {
composeRule.onNodeWithText(message).assertIsDisplayed()
return this
}
fun assertRetryShown(): CharacterListRobot {
composeRule.onNodeWithText(retryLabel).assertIsDisplayed()
return this
}
fun clickCharacter(name: String): CharacterListRobot {
composeRule.onNodeWithText(name).performClick()
return this
}
fun clickRetry(): CharacterListRobot {
composeRule.onNodeWithText(retryLabel).performClick()
return this
}
fun assertActionRecorded(action: CharacterListAction): CharacterListRobot {
assertTrue(
"Expected $action to be recorded, but got $recordedActions",
recordedActions.contains(action),
)
return this
}
// The retry label lives in the design-system module; reference its R directly (non-transitive R).
private val retryLabel: String
get() = context.getString(com.example.architecture.core.design.system.R.string.designsystem_retry)
}

View File

@@ -0,0 +1,77 @@
package com.example.architecture.feature.characters.presentation.compose
import android.content.Context
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.example.architecture.core.presentation.UiText
import com.example.architecture.feature.characters.domain.model.CharacterStatus
import com.example.architecture.feature.characters.presentation.CharacterListAction
import com.example.architecture.feature.characters.presentation.CharacterListState
import com.example.architecture.feature.characters.presentation.model.CharacterUi
import kotlinx.collections.immutable.persistentListOf
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
/**
* Instrumented Compose UI test for [CharacterListScreen] using [CharacterListRobot]. Runs on a
* device/emulator (`connectedDebugAndroidTest`); CI assembles it. Asserts rendered items, the
* empty + error states, and that user gestures fire the right MVI [CharacterListAction]s.
*/
@RunWith(AndroidJUnit4::class)
class CharacterListScreenTest {
@get:Rule
val composeRule = createComposeRule()
private val context: Context = ApplicationProvider.getApplicationContext()
private fun robot() = CharacterListRobot(composeRule, context)
private val loadedState = CharacterListState(
characters = persistentListOf(
CharacterUi(1, "Rick Sanchez", "Human", "", CharacterStatus.ALIVE),
CharacterUi(2, "Morty Smith", "Human", "", CharacterStatus.ALIVE),
),
)
@Test
fun rendersCharacterItems() {
robot()
.setContent(loadedState)
.assertCharacterShown("Rick Sanchez")
.assertCharacterShown("Morty Smith")
}
@Test
fun showsEmptyState() {
robot()
.setContent(CharacterListState())
.assertEmptyStateShown()
}
@Test
fun showsErrorStateWithRetry() {
robot()
.setContent(CharacterListState(error = UiText.DynamicString("Boom")))
.assertErrorShown("Boom")
.assertRetryShown()
}
@Test
fun tappingAnItemFiresOnCharacterClick() {
robot()
.setContent(loadedState)
.clickCharacter("Rick Sanchez")
.assertActionRecorded(CharacterListAction.OnCharacterClick(1))
}
@Test
fun tappingRetryFiresOnRetry() {
robot()
.setContent(CharacterListState(error = UiText.DynamicString("Boom")))
.clickRetry()
.assertActionRecorded(CharacterListAction.OnRetry)
}
}

View File

@@ -66,14 +66,15 @@ import org.koin.androidx.compose.koinViewModel
* Root: owns the ViewModel (via Koin), observes one-time Events, and forwards navigation up.
* The snackbar is resolved with the Context-based [asString] because it runs outside composition.
*
* [onOpenAbout] and [onOpenViewsList] are renderer-only chrome (a Compose overflow menu), so they
* are plain callbacks rather than going through the shared, UI-agnostic ViewModel.
* [onOpenAbout], [onOpenViewsList] and [onOpenErrorDemo] are renderer-only chrome (a Compose overflow
* menu), so they are plain callbacks rather than going through the shared, UI-agnostic ViewModel.
*/
@Composable
fun CharacterListRoot(
onCharacterClick: (Int) -> Unit,
onOpenAbout: () -> Unit,
onOpenViewsList: () -> Unit,
onOpenErrorDemo: () -> Unit,
viewModel: CharacterListViewModel = koinViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
@@ -95,6 +96,7 @@ fun CharacterListRoot(
onAction = viewModel::onAction,
onOpenAbout = onOpenAbout,
onOpenViewsList = onOpenViewsList,
onOpenErrorDemo = onOpenErrorDemo,
snackbarHostState = snackbarHostState,
)
}
@@ -107,13 +109,20 @@ fun CharacterListScreen(
onAction: (CharacterListAction) -> Unit,
onOpenAbout: () -> Unit,
onOpenViewsList: () -> Unit,
onOpenErrorDemo: () -> Unit,
snackbarHostState: SnackbarHostState = remember { SnackbarHostState() },
) {
AppScaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.characters_title)) },
actions = { CharacterListOverflowMenu(onOpenAbout = onOpenAbout, onOpenViewsList = onOpenViewsList) },
actions = {
CharacterListOverflowMenu(
onOpenAbout = onOpenAbout,
onOpenViewsList = onOpenViewsList,
onOpenErrorDemo = onOpenErrorDemo,
)
},
)
},
) { innerPadding ->
@@ -149,6 +158,7 @@ fun CharacterListScreen(
private fun CharacterListOverflowMenu(
onOpenAbout: () -> Unit,
onOpenViewsList: () -> Unit,
onOpenErrorDemo: () -> Unit,
) {
var expanded by remember { mutableStateOf(false) }
IconButton(onClick = { expanded = true }) {
@@ -165,6 +175,13 @@ private fun CharacterListOverflowMenu(
onOpenViewsList()
},
)
DropdownMenuItem(
text = { Text(stringResource(R.string.menu_error_demo)) },
onClick = {
expanded = false
onOpenErrorDemo()
},
)
DropdownMenuItem(
text = { Text(stringResource(R.string.menu_about)) },
onClick = {
@@ -284,6 +301,7 @@ private fun CharacterListScreenLoadedPreview() {
onAction = {},
onOpenAbout = {},
onOpenViewsList = {},
onOpenErrorDemo = {},
)
}
}
@@ -301,6 +319,7 @@ private fun CharacterListScreenErrorPreview() {
onAction = {},
onOpenAbout = {},
onOpenViewsList = {},
onOpenErrorDemo = {},
)
}
}

View File

@@ -13,10 +13,14 @@ data object CharacterListRoute
@Serializable
data class CharacterDetailRoute(val characterId: Int)
/** Type-safe route for the error-handling demo screen. */
@Serializable
data object ErrorDemoRoute
/**
* The characters feature nav graph. List→detail is intra-feature navigation, so it is driven by the
* [navController] passed in. Cross-boundary destinations (the About screen, the Views renderer hosted
* by `:app`) stay decoupled as callbacks supplied by `:app`.
* The characters feature nav graph. List→detail and list→error-demo are intra-feature navigation, so
* they are driven by the [navController] passed in. Cross-boundary destinations (the About screen,
* the Views renderer hosted by `:app`) stay decoupled as callbacks supplied by `:app`.
*/
fun NavGraphBuilder.charactersGraph(
navController: NavController,
@@ -30,6 +34,7 @@ fun NavGraphBuilder.charactersGraph(
},
onOpenAbout = onOpenAbout,
onOpenViewsList = onOpenViewsList,
onOpenErrorDemo = { navController.navigate(ErrorDemoRoute) },
)
}
composable<CharacterDetailRoute> {
@@ -38,4 +43,7 @@ fun NavGraphBuilder.charactersGraph(
// CharacterDetailViewModel reads it (keeping that module free of any navigation dependency).
CharacterDetailRoot(onNavigateBack = { navController.popBackStack() })
}
composable<ErrorDemoRoute> {
ErrorDemoRoot(onNavigateBack = { navController.popBackStack() })
}
}

View File

@@ -0,0 +1,156 @@
package com.example.architecture.feature.characters.presentation.compose
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.example.architecture.core.design.system.component.AppScaffold
import com.example.architecture.core.design.system.component.ErrorState
import com.example.architecture.core.design.system.component.LoadingIndicator
import com.example.architecture.core.design.system.theme.AppTheme
import com.example.architecture.core.presentation.ObserveAsEvents
import com.example.architecture.core.presentation.asString
import com.example.architecture.feature.characters.presentation.ErrorDemoAction
import com.example.architecture.feature.characters.presentation.ErrorDemoEvent
import com.example.architecture.feature.characters.presentation.ErrorDemoState
import com.example.architecture.feature.characters.presentation.ErrorDemoViewModel
import com.example.architecture.feature.characters.presentation.ErrorScenario
import org.koin.androidx.compose.koinViewModel
/**
* Root: owns the demo ViewModel (Koin) and forwards the one-time NavigateBack event up the stack.
*/
@Composable
fun ErrorDemoRoot(
onNavigateBack: () -> Unit,
viewModel: ErrorDemoViewModel = koinViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
ObserveAsEvents(viewModel.events) { event ->
when (event) {
ErrorDemoEvent.NavigateBack -> onNavigateBack()
}
}
ErrorDemoScreen(state = state, onAction = viewModel::onAction)
}
/** Pure, stateless screen — previewable without a ViewModel. */
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ErrorDemoScreen(
state: ErrorDemoState,
onAction: (ErrorDemoAction) -> Unit,
) {
AppScaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.error_demo_title)) },
navigationIcon = {
IconButton(onClick = { onAction(ErrorDemoAction.OnBackClick) }) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.cd_back),
)
}
},
)
},
) { innerPadding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(
text = stringResource(R.string.error_demo_intro),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
OutlinedButton(
onClick = { onAction(ErrorDemoAction.OnForceError(ErrorScenario.NO_INTERNET)) },
modifier = Modifier.fillMaxWidth(),
) { Text(stringResource(R.string.error_demo_force_no_internet)) }
OutlinedButton(
onClick = { onAction(ErrorDemoAction.OnForceError(ErrorScenario.NOT_FOUND)) },
modifier = Modifier.fillMaxWidth(),
) { Text(stringResource(R.string.error_demo_force_not_found)) }
OutlinedButton(
onClick = { onAction(ErrorDemoAction.OnForceError(ErrorScenario.SERVER_ERROR)) },
modifier = Modifier.fillMaxWidth(),
) { Text(stringResource(R.string.error_demo_force_server)) }
Button(
onClick = { onAction(ErrorDemoAction.OnLoadSuccess) },
modifier = Modifier.fillMaxWidth(),
) { Text(stringResource(R.string.error_demo_load_success)) }
// Result area: loading → mapped error (with retry) → success → idle hint.
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
val error = state.error
when {
state.isLoading -> LoadingIndicator()
error != null -> ErrorState(
message = error.asString(),
onRetry = { onAction(ErrorDemoAction.OnRetry) },
)
state.loaded -> Text(
text = stringResource(R.string.error_demo_success),
style = MaterialTheme.typography.titleMedium,
)
else -> Text(
text = stringResource(R.string.error_demo_hint),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
}
}
}
}
}
@Preview
@Composable
private fun ErrorDemoScreenIdlePreview() {
AppTheme { ErrorDemoScreen(state = ErrorDemoState(), onAction = {}) }
}
@Preview
@Composable
private fun ErrorDemoScreenErrorPreview() {
AppTheme {
ErrorDemoScreen(
state = ErrorDemoState(
error = com.example.architecture.core.presentation.UiText.DynamicString(
"No internet connection. Check your network and try again.",
),
),
onAction = {},
)
}
}

View File

@@ -10,6 +10,17 @@
<string name="cd_more_options">More options</string>
<string name="menu_about">About</string>
<string name="menu_open_as_views">Open as Views</string>
<string name="menu_error_demo">Error handling demo</string>
<!-- Error-handling demo screen -->
<string name="error_demo_title">Error handling demo</string>
<string name="error_demo_intro">Force a network failure to watch it flow through the pipeline: DataError.Network → toUiText() → the shared ErrorState. Retry re-issues the same request; a successful load clears the error.</string>
<string name="error_demo_force_no_internet">Force: No internet</string>
<string name="error_demo_force_not_found">Force: Not found</string>
<string name="error_demo_force_server">Force: Server error</string>
<string name="error_demo_load_success">Load (success)</string>
<string name="error_demo_success">Loaded successfully ✓</string>
<string name="error_demo_hint">Pick an action above to see the result here.</string>
<!-- Detail screen -->
<string name="character_detail_title">Character</string>

View File

@@ -1,6 +1,7 @@
plugins {
alias(libs.plugins.architecture.android.library)
alias(libs.plugins.architecture.koin)
alias(libs.plugins.architecture.android.unit.test)
}
// UI-agnostic presentation: the MVI ViewModel + State/Action/Event live here and are shared by

View File

@@ -8,7 +8,7 @@ import com.example.architecture.core.domain.onFailure
import com.example.architecture.core.domain.onSuccess
import com.example.architecture.core.presentation.UiText
import com.example.architecture.core.presentation.toUiText
import com.example.architecture.feature.characters.domain.CharacterRepository
import com.example.architecture.feature.characters.domain.usecase.GetCharactersPageUseCase
import com.example.architecture.feature.characters.presentation.model.CharacterUi
import com.example.architecture.feature.characters.presentation.model.toCharacterUi
import kotlinx.collections.immutable.toImmutableList
@@ -25,7 +25,7 @@ import kotlinx.coroutines.launch
* via a [Channel], maps failures to [UiText], and persists the loaded page in [SavedStateHandle].
*/
class CharacterListViewModel(
private val characterRepository: CharacterRepository,
private val getCharactersPage: GetCharactersPageUseCase,
private val savedStateHandle: SavedStateHandle,
) : ViewModel() {
@@ -62,7 +62,7 @@ class CharacterListViewModel(
var page = 1
while (page <= targetPage) {
when (val result = characterRepository.getCharacters(page)) {
when (val result = getCharactersPage(page)) {
is Result.Success -> {
accumulated += result.data.characters.map { it.toCharacterUi() }
lastLoadedPage = page
@@ -123,7 +123,7 @@ class CharacterListViewModel(
// get appended twice.
_state.update { it.copy(isLoadingNextPage = true, error = null) }
viewModelScope.launch {
characterRepository.getCharacters(page)
getCharactersPage(page)
.onSuccess { pageData ->
_state.update { state ->
state.copy(

View File

@@ -0,0 +1,14 @@
package com.example.architecture.feature.characters.presentation
sealed interface ErrorDemoAction {
/** Force a load that fails with the given [ErrorScenario]. */
data class OnForceError(val scenario: ErrorScenario) : ErrorDemoAction
/** Force a load that succeeds — clears any current error. */
data object OnLoadSuccess : ErrorDemoAction
/** Re-issue the most recent load (the design-system retry button). */
data object OnRetry : ErrorDemoAction
data object OnBackClick : ErrorDemoAction
}

View File

@@ -0,0 +1,5 @@
package com.example.architecture.feature.characters.presentation
sealed interface ErrorDemoEvent {
data object NavigateBack : ErrorDemoEvent
}

View File

@@ -0,0 +1,25 @@
package com.example.architecture.feature.characters.presentation
import com.example.architecture.core.presentation.UiText
/**
* State for the error-handling demo. All fields are primitive/stable, so no `@Stable` is needed.
* [error] is the *mapped* [UiText] produced by `DataError.toUiText()` — exactly what the real
* screens hold — so the renderer resolves and shows it the same way.
*/
data class ErrorDemoState(
val isLoading: Boolean = false,
val loaded: Boolean = false,
val error: UiText? = null,
)
/**
* The failure the user asks the demo to reproduce. A presentation-local choice (not a `DataError`)
* so the renderer stays free of domain error types; the ViewModel maps it to the real
* `DataError.Network` case.
*/
enum class ErrorScenario {
NO_INTERNET,
NOT_FOUND,
SERVER_ERROR,
}

View File

@@ -0,0 +1,87 @@
package com.example.architecture.feature.characters.presentation
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.example.architecture.core.domain.DataError
import com.example.architecture.core.domain.Result
import com.example.architecture.core.domain.onFailure
import com.example.architecture.core.domain.onSuccess
import com.example.architecture.core.presentation.toUiText
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
/**
* UI-agnostic MVI ViewModel for the **error-handling demo** — a runnable walk-through of the whole
* error pipeline. A "force error" affordance produces a real [DataError.Network], which is routed
* through the *same* steps a genuine network call uses:
*
* ```
* Result<…, DataError.Network> → onSuccess / onFailure → DataError.toUiText() → ErrorState
* ```
*
* The outcome is *simulated* (no real request) only so every case — including NO_INTERNET, which you
* can't reliably trigger on demand — is reachable deterministically. [OnRetry] re-issues the last
* attempt (proving retry is an Action); [OnLoadSuccess] clears the error (proving it clears on
* success). See android-error-handling.
*/
class ErrorDemoViewModel : ViewModel() {
private val _state = MutableStateFlow(ErrorDemoState())
val state = _state.asStateFlow()
private val _events = Channel<ErrorDemoEvent>()
val events = _events.receiveAsFlow()
// Remembered so OnRetry re-issues exactly what was last attempted.
private var lastAttempt: Attempt = Attempt.Success
fun onAction(action: ErrorDemoAction) {
when (action) {
is ErrorDemoAction.OnForceError -> load(Attempt.Fail(action.scenario))
ErrorDemoAction.OnLoadSuccess -> load(Attempt.Success)
ErrorDemoAction.OnRetry -> load(lastAttempt)
ErrorDemoAction.OnBackClick -> viewModelScope.launch {
_events.send(ErrorDemoEvent.NavigateBack)
}
}
}
private fun load(attempt: Attempt) {
lastAttempt = attempt
_state.update { it.copy(isLoading = true, error = null, loaded = false) }
viewModelScope.launch {
delay(LOAD_DELAY_MS) // pretend a request is in flight, so the loading state is visible
simulate(attempt)
.onSuccess { _state.update { it.copy(isLoading = false, loaded = true, error = null) } }
.onFailure { dataError ->
// The crux of the demo: a DataError becomes user-facing UiText right here.
_state.update { it.copy(isLoading = false, error = dataError.toUiText()) }
}
}
}
private fun simulate(attempt: Attempt): Result<Unit, DataError.Network> = when (attempt) {
Attempt.Success -> Result.Success(Unit)
is Attempt.Fail -> Result.Error(attempt.scenario.toDataError())
}
private sealed interface Attempt {
data object Success : Attempt
data class Fail(val scenario: ErrorScenario) : Attempt
}
private fun ErrorScenario.toDataError(): DataError.Network = when (this) {
ErrorScenario.NO_INTERNET -> DataError.Network.NO_INTERNET
ErrorScenario.NOT_FOUND -> DataError.Network.NOT_FOUND
ErrorScenario.SERVER_ERROR -> DataError.Network.SERVER_ERROR
}
private companion object {
const val LOAD_DELAY_MS = 400L
}
}

View File

@@ -1,12 +1,19 @@
package com.example.architecture.feature.characters.presentation.di
import com.example.architecture.feature.characters.domain.usecase.GetCharactersPageUseCase
import com.example.architecture.feature.characters.presentation.CharacterDetailViewModel
import com.example.architecture.feature.characters.presentation.CharacterListViewModel
import com.example.architecture.feature.characters.presentation.ErrorDemoViewModel
import org.koin.core.module.dsl.factoryOf
import org.koin.core.module.dsl.viewModelOf
import org.koin.dsl.module
/** Presentation DI for the characters feature. Lives with the (UI-agnostic) ViewModels it provides. */
val charactersPresentationModule = module {
// Stateless domain UseCase — `factoryOf` (a fresh, cheap instance per resolution). Koin supplies
// its CharacterRepository from charactersDataModule. See koin-constructor-dsl.
factoryOf(::GetCharactersPageUseCase)
viewModelOf(::CharacterListViewModel)
viewModelOf(::CharacterDetailViewModel)
viewModelOf(::ErrorDemoViewModel)
}

View File

@@ -0,0 +1,115 @@
package com.example.architecture.feature.characters.presentation
import androidx.lifecycle.SavedStateHandle
import app.cash.turbine.test
import assertk.assertThat
import assertk.assertions.isEqualTo
import assertk.assertions.isFalse
import assertk.assertions.isNotNull
import assertk.assertions.isNull
import assertk.assertions.isSameInstanceAs
import com.example.architecture.core.domain.DataError
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows
/**
* Unit tests for [CharacterDetailViewModel]. The character id arrives via [SavedStateHandle] (written
* by type-safe navigation), which is constructed directly here — proving the VM needs no navigation
* dependency. Collaborator is a [FakeCharacterRepository]; assertions use AssertK; the back event is
* observed with Turbine.
*/
@OptIn(ExperimentalCoroutinesApi::class)
class CharacterDetailViewModelTest {
private val dispatcher = StandardTestDispatcher()
private val repository = FakeCharacterRepository()
@BeforeEach
fun setUp() {
Dispatchers.setMain(dispatcher)
}
@AfterEach
fun tearDown() {
Dispatchers.resetMain()
}
private fun viewModel(characterId: Int = 1) =
CharacterDetailViewModel(SavedStateHandle(mapOf("characterId" to characterId)), repository)
@Test
fun `loads details on init`() = runTest(dispatcher.scheduler) {
repository.setDetails(characterDetails(1))
val viewModel = viewModel(characterId = 1)
advanceUntilIdle()
val state = viewModel.state.value
assertThat(state.isLoading).isFalse()
assertThat(state.error).isNull()
assertThat(state.details).isNotNull()
assertThat(state.details?.name).isEqualTo("Character 1")
}
@Test
fun `load failure surfaces an error and no details`() = runTest(dispatcher.scheduler) {
repository.failWith = DataError.Network.SERVER_ERROR
val viewModel = viewModel(characterId = 1)
advanceUntilIdle()
val state = viewModel.state.value
assertThat(state.error).isNotNull()
assertThat(state.details).isNull()
assertThat(state.isLoading).isFalse()
}
@Test
fun `retry after a failure clears the error and loads details`() = runTest(dispatcher.scheduler) {
repository.failWith = DataError.Network.NO_INTERNET
val viewModel = viewModel(characterId = 1)
advanceUntilIdle()
assertThat(viewModel.state.value.error).isNotNull()
// The next attempt will succeed.
repository.failWith = null
repository.setDetails(characterDetails(1))
viewModel.onAction(CharacterDetailAction.OnRetry)
advanceUntilIdle()
val state = viewModel.state.value
assertThat(state.error).isNull()
assertThat(state.details).isNotNull()
}
@Test
fun `back click emits NavigateBack`() = runTest(dispatcher.scheduler) {
repository.setDetails(characterDetails(1))
val viewModel = viewModel(characterId = 1)
advanceUntilIdle()
viewModel.events.test {
viewModel.onAction(CharacterDetailAction.OnBackClick)
advanceUntilIdle()
assertThat(awaitItem()).isSameInstanceAs(CharacterDetailEvent.NavigateBack)
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `missing character id fails fast`() {
// The route contract: type-safe nav must have written characterId into SavedStateHandle.
assertThrows<IllegalStateException> {
CharacterDetailViewModel(SavedStateHandle(), repository)
}
}
}

View File

@@ -0,0 +1,215 @@
package com.example.architecture.feature.characters.presentation
import androidx.lifecycle.SavedStateHandle
import app.cash.turbine.test
import assertk.assertThat
import assertk.assertions.hasSize
import assertk.assertions.isEmpty
import assertk.assertions.isEqualTo
import assertk.assertions.isFalse
import assertk.assertions.isInstanceOf
import assertk.assertions.isNotNull
import assertk.assertions.isNull
import assertk.assertions.isTrue
import assertk.assertions.prop
import com.example.architecture.core.domain.DataError
import com.example.architecture.feature.characters.domain.usecase.GetCharactersPageUseCase
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
/**
* Unit tests for [CharacterListViewModel] — driven entirely through its public MVI surface
* (State/Action/Event), so they prove the VM correct regardless of which renderer hosts it.
*
* Uses [StandardTestDispatcher] (not Unconfined) so launched work is queued until `advanceUntilIdle`,
* which lets the duplicate-paging test observe the *synchronous* loading-flag guard before any
* coroutine runs. Collaborator is a [FakeCharacterRepository] (a fake, not a mock); `state`/`events`
* are observed with Turbine; assertions use AssertK.
*/
@OptIn(ExperimentalCoroutinesApi::class)
class CharacterListViewModelTest {
private val dispatcher = StandardTestDispatcher()
private val repository = FakeCharacterRepository()
@BeforeEach
fun setUp() {
Dispatchers.setMain(dispatcher)
}
@AfterEach
fun tearDown() {
Dispatchers.resetMain()
}
private fun viewModel(savedStateHandle: SavedStateHandle = SavedStateHandle()) =
CharacterListViewModel(GetCharactersPageUseCase(repository), savedStateHandle)
@Test
fun `loads the first page on init`() = runTest(dispatcher.scheduler) {
repository.setPage(page = 1, characters = listOf(character(1), character(2)), nextPage = 2)
val viewModel = viewModel()
viewModel.state.test {
// restore() flips isLoading synchronously during construction, before the coroutine runs.
assertThat(awaitItem()).isEqualTo(CharacterListState(isLoading = true))
advanceUntilIdle()
val loaded = awaitItem()
assertThat(loaded).prop(CharacterListState::characters).hasSize(2)
assertThat(loaded).prop(CharacterListState::isLoading).isFalse()
assertThat(loaded).prop(CharacterListState::currentPage).isEqualTo(1)
assertThat(loaded).prop(CharacterListState::endReached).isFalse()
assertThat(loaded).prop(CharacterListState::error).isNull()
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `initial load failure emits a snackbar event and a full-screen error`() =
runTest(dispatcher.scheduler) {
repository.failWith = DataError.Network.NO_INTERNET
val viewModel = viewModel()
viewModel.events.test {
advanceUntilIdle()
assertThat(awaitItem()).isInstanceOf(CharacterListEvent.ShowSnackbar::class)
cancelAndIgnoreRemainingEvents()
}
advanceUntilIdle()
assertThat(viewModel.state.value).prop(CharacterListState::error).isNotNull()
assertThat(viewModel.state.value).prop(CharacterListState::characters).hasSize(0)
}
@Test
fun `does not load past the last page`() = runTest(dispatcher.scheduler) {
repository.setPage(page = 1, characters = listOf(character(1)), nextPage = 2)
repository.setPage(page = 2, characters = listOf(character(2)), nextPage = null) // last page
val viewModel = viewModel()
advanceUntilIdle() // init → page 1
viewModel.onAction(CharacterListAction.OnLoadNextPage)
advanceUntilIdle() // → page 2, end reached
assertThat(viewModel.state.value).prop(CharacterListState::endReached).isTrue()
assertThat(viewModel.state.value).prop(CharacterListState::characters).hasSize(2)
val callsBefore = repository.getCharactersCallCount
viewModel.onAction(CharacterListAction.OnLoadNextPage)
advanceUntilIdle() // guarded by endReached → no request
assertThat(repository.getCharactersCallCount).isEqualTo(callsBefore)
}
@Test
fun `rapid duplicate next-page actions load the page only once`() = runTest(dispatcher.scheduler) {
repository.setPage(page = 1, characters = listOf(character(1)), nextPage = 2)
repository.setPage(page = 2, characters = listOf(character(2)), nextPage = 3)
val viewModel = viewModel()
advanceUntilIdle() // init → page 1
val callsBefore = repository.getCharactersCallCount
// Both fire before any launched coroutine runs; the second sees the synchronously-set
// isLoadingNextPage flag and is guarded out.
viewModel.onAction(CharacterListAction.OnLoadNextPage)
viewModel.onAction(CharacterListAction.OnLoadNextPage)
advanceUntilIdle()
assertThat(repository.getCharactersCallCount).isEqualTo(callsBefore + 1)
assertThat(viewModel.state.value).prop(CharacterListState::currentPage).isEqualTo(2)
}
@Test
fun `ignores a next-page request while the initial load is in flight`() =
runTest(dispatcher.scheduler) {
repository.setPage(page = 1, characters = listOf(character(1)), nextPage = 2)
val viewModel = viewModel()
// restore() set isLoading = true synchronously; its coroutine hasn't run yet, so this
// OnLoadNextPage hits the `isLoading` guard in loadNextPage() and is dropped.
viewModel.onAction(CharacterListAction.OnLoadNextPage)
advanceUntilIdle()
// Only the single initial load ran — the guarded next-page request never fired.
assertThat(repository.getCharactersCallCount).isEqualTo(1)
}
@Test
fun `retry after a failed initial load rebuilds the list`() = runTest(dispatcher.scheduler) {
repository.failWith = DataError.Network.NO_INTERNET
val viewModel = viewModel()
viewModel.events.test {
advanceUntilIdle()
// The initial-load failure surfaces as a snackbar; consuming it is also how the
// rendezvous-Channel send in restore() completes so state can settle.
assertThat(awaitItem()).isInstanceOf(CharacterListEvent.ShowSnackbar::class)
assertThat(viewModel.state.value).prop(CharacterListState::characters).isEmpty()
// Empty branch of retry(): the repository recovers, OnRetry rebuilds from page 1.
repository.failWith = null
repository.setPage(page = 1, characters = listOf(character(1), character(2)), nextPage = 2)
viewModel.onAction(CharacterListAction.OnRetry)
advanceUntilIdle()
cancelAndIgnoreRemainingEvents()
}
assertThat(viewModel.state.value).prop(CharacterListState::characters).hasSize(2)
assertThat(viewModel.state.value).prop(CharacterListState::error).isNull()
}
@Test
fun `retry after a failed next page re-requests that page`() = runTest(dispatcher.scheduler) {
repository.setPage(page = 1, characters = listOf(character(1)), nextPage = 2)
val viewModel = viewModel()
advanceUntilIdle() // page 1 loaded (no event)
viewModel.events.test {
// Page 2 isn't configured yet → next-page load fails; list keeps page 1, shows an error.
viewModel.onAction(CharacterListAction.OnLoadNextPage)
advanceUntilIdle()
assertThat(awaitItem()).isInstanceOf(CharacterListEvent.ShowSnackbar::class)
assertThat(viewModel.state.value).prop(CharacterListState::characters).hasSize(1)
// Non-empty branch of retry(): with page 2 now available, OnRetry re-requests page 2 and
// appends it (currentPage stayed 1 because loadPage only advances on success).
repository.setPage(page = 2, characters = listOf(character(2)), nextPage = null)
viewModel.onAction(CharacterListAction.OnRetry)
advanceUntilIdle()
cancelAndIgnoreRemainingEvents()
}
assertThat(viewModel.state.value).prop(CharacterListState::characters).hasSize(2)
assertThat(viewModel.state.value).prop(CharacterListState::currentPage).isEqualTo(2)
assertThat(viewModel.state.value).prop(CharacterListState::error).isNull()
}
@Test
fun `restores up to the saved page after process death`() = runTest(dispatcher.scheduler) {
repository.setPage(page = 1, characters = listOf(character(1)), nextPage = 2)
repository.setPage(page = 2, characters = listOf(character(2)), nextPage = 3)
// Navigation/SavedStateHandle persisted the last loaded page across process death.
val savedStateHandle = SavedStateHandle(mapOf("currentPage" to 2))
val viewModel = viewModel(savedStateHandle)
advanceUntilIdle()
// Both pages are rebuilt (1 then 2), and currentPage is restored.
assertThat(viewModel.state.value).prop(CharacterListState::characters).hasSize(2)
assertThat(viewModel.state.value).prop(CharacterListState::currentPage).isEqualTo(2)
}
}

View File

@@ -0,0 +1,74 @@
package com.example.architecture.feature.characters.presentation
import com.example.architecture.core.domain.DataError
import com.example.architecture.core.domain.Result
import com.example.architecture.feature.characters.domain.CharacterRepository
import com.example.architecture.feature.characters.domain.model.Character
import com.example.architecture.feature.characters.domain.model.CharacterDetails
import com.example.architecture.feature.characters.domain.model.CharacterStatus
import com.example.architecture.feature.characters.domain.model.CharactersPage
/**
* In-memory [CharacterRepository] for ViewModel tests — a **fake**, not a mock: it has real behaviour
* (returns configured pages/details, counts calls, can be flipped to fail) so tests assert against a
* working collaborator instead of recording interactions. Configure pages via [setPage]/[setDetails];
* set [failWith] to make every call fail with a specific [DataError].
*/
class FakeCharacterRepository : CharacterRepository {
/** When non-null, every call fails with this error (overrides any configured data). */
var failWith: DataError? = null
var getCharactersCallCount = 0
private set
var getCharacterDetailsCallCount = 0
private set
private val pages = mutableMapOf<Int, CharactersPage>()
private val details = mutableMapOf<Int, CharacterDetails>()
fun setPage(page: Int, characters: List<Character>, nextPage: Int?) {
pages[page] = CharactersPage(characters = characters, nextPage = nextPage)
}
fun setDetails(value: CharacterDetails) {
details[value.id] = value
}
override suspend fun getCharacters(page: Int): Result<CharactersPage, DataError> {
getCharactersCallCount++
failWith?.let { return Result.Error(it) }
val pageData = pages[page] ?: return Result.Error(DataError.Network.NOT_FOUND)
return Result.Success(pageData)
}
override suspend fun getCharacterDetails(id: Int): Result<CharacterDetails, DataError> {
getCharacterDetailsCallCount++
failWith?.let { return Result.Error(it) }
val value = details[id] ?: return Result.Error(DataError.Network.NOT_FOUND)
return Result.Success(value)
}
}
/** Minimal list-item domain fixture. */
fun character(id: Int): Character = Character(
id = id,
name = "Character $id",
status = CharacterStatus.ALIVE,
species = "Human",
imageUrl = "https://example.com/$id.png",
)
/** Minimal detail domain fixture. */
fun characterDetails(id: Int): CharacterDetails = CharacterDetails(
id = id,
name = "Character $id",
status = CharacterStatus.ALIVE,
species = "Human",
type = "Genetic experiment",
gender = "Male",
origin = "Earth (C-137)",
location = "Citadel of Ricks",
imageUrl = "https://example.com/$id.png",
episodeCount = 10,
)

View File

@@ -36,12 +36,10 @@ timber = "5.0.1"
material = "1.12.0"
# Testing
junit4 = "4.13.2"
junitJupiter = "5.11.4"
androidJunit5 = "1.11.4"
junitPlatform = "1.11.4"
turbine = "1.2.0"
assertk = "0.28.1"
androidxTest = "1.7.0"
androidxTestExt = "1.3.0"
androidxTestRunner = "1.7.0"
androidxEspresso = "3.7.0"
@@ -97,7 +95,6 @@ koin-core = { module = "io.insert-koin:koin-core" }
koin-android = { module = "io.insert-koin:koin-android" }
koin-androidx-compose = { module = "io.insert-koin:koin-androidx-compose" }
koin-test = { module = "io.insert-koin:koin-test" }
koin-test-junit5 = { module = "io.insert-koin:koin-test-junit5" }
# --- Ktor ---
ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
@@ -116,13 +113,12 @@ coil-network-okhttp = { module = "io.coil-kt.coil3:coil-network-okhttp", version
timber = { module = "com.jakewharton.timber:timber", version.ref = "timber" }
# --- Testing ---
junit4 = { module = "junit:junit", version.ref = "junit4" }
junit-jupiter-api = { module = "org.junit.jupiter:junit-jupiter-api", version.ref = "junitJupiter" }
junit-jupiter-engine = { module = "org.junit.jupiter:junit-jupiter-engine", version.ref = "junitJupiter" }
junit-jupiter-params = { module = "org.junit.jupiter:junit-jupiter-params", version.ref = "junitJupiter" }
# Gradle 9 no longer bundles the launcher — it must be on the test runtime classpath explicitly.
junit-platform-launcher = { module = "org.junit.platform:junit-platform-launcher", version.ref = "junitPlatform" }
turbine = { module = "app.cash.turbine:turbine", version.ref = "turbine" }
assertk = { module = "com.willowtreeapps.assertk:assertk", version.ref = "assertk" }
androidx-test-core = { module = "androidx.test:core", version.ref = "androidxTest" }
androidx-test-runner = { module = "androidx.test:runner", version.ref = "androidxTestRunner" }
androidx-test-ext-junit = { module = "androidx.test.ext:junit", version.ref = "androidxTestExt" }
androidx-test-espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "androidxEspresso" }
@@ -147,6 +143,16 @@ ktor = [
lifecycle-compose = ["androidx-lifecycle-runtime-compose", "androidx-lifecycle-viewmodel-compose"]
views = ["androidx-appcompat", "material", "androidx-recyclerview", "androidx-fragment-ktx"]
unit-test = ["junit-jupiter-api", "kotlinx-coroutines-test", "turbine", "assertk"]
# Instrumented Compose UI test (androidTest): ComposeTestRule + AndroidJUnit4 runner.
# espresso-core/runner are pinned to current versions: Compose's test rule drives Espresso's
# onIdle, and the transitive espresso 3.5.0 calls InputManager.getInstance() (removed on API 34+),
# which crashes on modern devices. 3.7.0 fixes that reflection.
compose-ui-test = [
"androidx-compose-ui-test-junit4",
"androidx-test-ext-junit",
"androidx-test-espresso-core",
"androidx-test-runner",
]
[plugins]
# Upstream plugins
@@ -155,8 +161,6 @@ android-library = { id = "com.android.library", version.ref = "agp" }
kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
# Declared for milestone 5 (ViewModel/Compose tests on Android); wired when tests land.
android-junit5 = { id = "de.mannodermaus.android-junit5", version.ref = "androidJunit5" }
# Convention plugins (defined in :build-logic, resolved from the included build)
architecture-android-application = { id = "architecture.android.application" }
@@ -164,6 +168,7 @@ architecture-android-library = { id = "architecture.android.library" }
architecture-android-feature = { id = "architecture.android.feature" }
architecture-android-feature-views = { id = "architecture.android.feature.views" }
architecture-domain-module = { id = "architecture.domain.module" }
architecture-android-unit-test = { id = "architecture.android.unit.test" }
architecture-compose = { id = "architecture.compose" }
architecture-koin = { id = "architecture.koin" }
architecture-ktor = { id = "architecture.ktor" }