28 Commits
Author SHA1 Message Date
Adrian Kuta 3f9cf96216 REDI-95: ViewModel unit tests (JUnit5 + Turbine + AssertK + fakes)
Test CharacterListViewModel and CharacterDetailViewModel entirely through their
MVI surface with a FakeCharacterRepository (a fake, not a mock) and a directly
constructed SavedStateHandle, on StandardTestDispatcher. Coverage: happy path,
error -> UiText + snackbar Event, pagination end-reached, the in-flight and
duplicate next-page guards, process-death restore, and both branches of OnRetry.
Also a domain test for GetCharactersPageUseCase (delegation + error
propagation).
2026-06-10 15:00:45 +02:00
Adrian Kuta 7a7ab45a66 test infra: JUnit5 unit tests on Android modules + Compose UI test wiring
Add an architecture.android.unit.test convention plugin that runs local unit
tests on the JUnit5 platform via useJUnitPlatform() (AndroidUnitTest extends
Gradle's Test) + the unit-test bundle. Deliberately NOT using the
de.mannodermaus plugin (targets AGP 8.x; we're on AGP 9). Add
junit-platform-launcher (Gradle 9 dropped the bundled launcher); set the
instrumentation runner; add a compose-ui-test bundle pinning espresso/runner to
current versions (transitive espresso 3.5.0 calls the removed
InputManager.getInstance() on API 34+). CI now runs ./gradlew test and compiles
the instrumented tests. Drop unused testing catalog entries.
2026-06-10 15:00:37 +02:00
Adrian Kuta cf63095acc REDI-98: error-handling demo screen (DataError -> UiText pipeline)
A runnable MVI screen (reached from the list overflow menu) that forces a real
DataError.Network case and routes it through the same pipeline a genuine call
uses: Result.Error -> onFailure -> DataError.toUiText() -> design-system
ErrorState. Three distinct cases (NO_INTERNET, NOT_FOUND, SERVER_ERROR) each
render their mapped message; Retry re-issues the last attempt via an Action; a
successful load clears the error. Wired as intra-feature navigation
(ErrorDemoRoute) and registered in Koin (incl. the UseCase factoryOf).
2026-06-10 15:00:27 +02:00
Adrian Kuta 0542d4dc1d REDI-94: GetCharactersPageUseCase + inject into list ViewModel
Add a domain UseCase (operator invoke) in :feature:characters:domain delegating
to CharacterRepository, and have CharacterListViewModel depend on it instead of
the repository directly. The UseCase is a deliberate thin pass-through that
documents the 'when to add a UseCase' convention (real logic / multi-source
composition vs. a single forwarded call).
2026-06-10 15:00:17 +02:00
Adrian Kuta c17a1c163b Merge pull request #3 from AdrianKuta/feat/breadth-contrast
Breadth & Contrast (REDI-90…93): detail screen, MVVM about, Views renderer, Compose↔View interop
2026-06-10 14:14:46 +02:00
Adrian Kuta 3c02096a8b docs: README MVI-vs-MVVM section + two-renderer overview
Add a Presentation patterns (MVI vs MVVM) section with a comparison table and the
'one ViewModel, two renderers' explanation, and update the status/stack (Timber, not
Kermit; Breadth & Contrast complete).
2026-06-10 13:45:05 +02:00
Adrian Kuta 6577a85a15 REDI-93: host the Views list in the Compose NavHost (Compose<->View interop)
:app now owns the cross-toolkit interop. MainActivity becomes a FragmentActivity and
hosts CharacterListFragment inside the Compose NavHost via AndroidFragment at a new
@Serializable CharactersViewsRoute (defined in :app, since :app owns interop — the Views
module stays nav-agnostic). The overflow menu's 'Open as Views' / 'About' entries and the
Views item-click -> detail navigation are all injected as callbacks, keeping the renderers
decoupled. Assemble aboutPresentationModule in Koin; add androidx.fragment:fragment-compose.
The Material3 Activity theme styles both toolkits.
2026-06-10 13:45:00 +02:00
Adrian Kuta e230aa77d8 REDI-92: Views/XML renderer for the characters list (same MVI ViewModel)
Add :feature:characters:presentation-views — a classic Fragment + ViewBinding +
RecyclerView/DiffUtil renderer driving the SAME CharacterListViewModel as the Compose
screen (obtained via Koin's by viewModel()), proving the presentation logic is truly
UI-agnostic. State is observed with viewLifecycleOwner.repeatOnLifecycle(STARTED),
one-time Events are collected, UiText is resolved via Context, and the binding is nulled
in onDestroyView. Coil loads avatars into ImageView with a circle-crop transform; the
module has no Compose dependency.

Paging scroll listener guards the empty-list case (lastVisible >= 0), uses a safe
layoutManager cast, and is removed in onDestroyView.
2026-06-10 13:44:53 +02:00
Adrian Kuta 5f2792002b REDI-91: MVVM contrast screen (:feature:about:presentation)
Add a deliberately small MVVM About screen: AboutViewModel exposes a StateFlow plus
plain public methods (onToggleMvvmNote) with NO Action sealed type and NO Event channel
— the explicit contrast to the app's MVI screens. AboutScreen collects state and calls
the VM method directly; links open via LocalUriHandler. Static showcase copy lives in
the VM as state to demonstrate the StateFlow-as-content shape. aboutGraph + @Serializable
AboutRoute + aboutPresentationModule wire it in. A code comment and the README explain
why this is MVVM and when each pattern fits.
2026-06-10 13:44:47 +02:00
Adrian Kuta 33de7f5ef8 REDI-90: character detail screen (type-safe nav args + MVI)
Add a CharacterDetail MVI stack (State/Action/Event/ViewModel + CharacterDetailUi)
to the UI-agnostic :feature:characters:presentation. The detail ViewModel reads the
typed characterId from SavedStateHandle (populated by the type-safe CharacterDetailRoute),
so the module keeps zero navigation/Compose deps.

Add CharacterDetailScreen (Root/Screen, image header, attribute rows, loading/error)
and CharacterDetailRoute to :presentation-compose; refactor charactersGraph to drive
list->detail via NavController and expose About / Views entries as callbacks. Extract
shared CharacterStatus label/colour helpers; add an overflow menu to the list app bar.

Add material-icons-core to the compose bundle for the app-bar icons.
2026-06-10 13:44:39 +02:00
Adrian Kuta 843c2fb4ef Merge pull request #2 from AdrianKuta/feat/flagship-mvi
Flagship MVI Feature (REDI-85…89)
2026-06-10 13:13:17 +02:00
Adrian Kuta 38d8f5915b fix(characters:presentation): pagination race + silent restore failure (review)
- Race: isLoadingNextPage was set inside the launched coroutine, so a rapid second
  OnLoadNextPage passed the guard before the flag flipped -> the same page loaded twice and
  items were appended twice. Set the loading flag synchronously before launching.
- Restore: when a middle page failed after earlier pages loaded, the error was swallowed
  (error=null, no event). Now any restore failure emits a ShowSnackbar; partial restores show
  the loaded list + snackbar, full failures show the error state.

Found by the milestone review.
2026-06-10 13:03:09 +02:00
Adrian Kuta ef50094e3e feat(characters): Koin module + nav graph + wire into :app (REDI-89)
- charactersPresentationModule: viewModelOf(::CharacterListViewModel) (in the UI-agnostic module).
- @Serializable CharacterListRoute + NavGraphBuilder.charactersGraph { composable<CharacterListRoute> }
  in presentation-compose (serialization plugin added for type-safe routes).
- :app registers coreDataModule + charactersDataModule + charactersPresentationModule in startKoin,
  and hosts a NavHost(startDestination = CharacterListRoute) calling charactersGraph.
- core:data manifest declares INTERNET (merges into :app) for live API calls.
2026-06-10 12:52:14 +02:00
Adrian Kuta dd4576409d feat(characters:presentation-compose): list renderer, Root/Screen split, previews (REDI-88)
- CharacterListRoot: koinViewModel(), ObserveAsEvents, forwards nav + shows snackbar via Context asString.
- CharacterListScreen: pure state+onAction; AppScaffold + LazyColumn (key=id), design-system
  NetworkImage (contentDescription)/AppCard, loading/error/empty states, snapshot-based
  scroll-to-end -> OnLoadNextPage (ViewModel guards duplicates).
- Loaded + error previews wrapped in AppTheme.
- feature:characters:presentation now exposes kotlinx-immutable as api (ImmutableList is in the state API).
2026-06-10 12:48:21 +02:00
Adrian Kuta 2a419df43e feat(characters:presentation): UI-agnostic MVI ViewModel (REDI-87)
- CharacterListState (characters, isLoading, isLoadingNextPage, currentPage, endReached, error: UiText?),
  CharacterListAction (OnCharacterClick/OnRetry/OnLoadNextPage), CharacterListEvent (NavigateToDetail/ShowSnackbar).
- CharacterListViewModel: state via .update, one-time events via Channel, DataError -> UiText on failure,
  pagination persisted in SavedStateHandle (rebuilds list up to the saved page after process death).
- CharacterUi + Character.toCharacterUi().
- NO Compose/Views deps: verified no androidx.compose on the compile classpath. Stability via
  ImmutableList instead of @Stable (which would require compose-runtime) — the only compose-named
  transitive is kotlinx-immutable's annotations-only stub, not the Compose framework.
2026-06-10 12:43:30 +02:00
Adrian Kuta 0bb96baa4d feat(characters:data): DTOs, mappers, data source, repo, Koin module (REDI-86)
- @Serializable CharacterDto/CharactersResponseDto/PageInfoDto in dto/.
- mappers/CharacterMapper.kt: internal, pure toDomain()/toCharacter()/toCharacterDetails();
  nextPage parsed from info.next URL. No mapping inside DTO/data-source classes.
- KtorCharacterDataSource via the typed HttpClient.get helpers (errors -> DataError.Network).
- NetworkCharacterRepository (not *Impl) maps DTO -> domain; DataError.Network widens to DataError.
- charactersDataModule: singleOf(::KtorCharacterDataSource) + singleOf(::NetworkCharacterRepository) { bind<CharacterRepository>() }.
- core:data: expose ktor-client-core as api (public inline helpers are inlined into consumers) and
  move Timber logging into a @PublishedApi internal fn so Timber doesn't leak across modules.
2026-06-10 12:37:40 +02:00
Adrian Kuta 600f12259d feat(characters:domain): models + CharacterRepository interface (REDI-85)
- Character, CharacterStatus, CharactersPage(characters, nextPage), CharacterDetails.
- CharacterRepository interface returning Result<CharactersPage, DataError> and
  Result<CharacterDetails, DataError>. Pure Kotlin, no serialization annotations, no Android.
2026-06-10 12:31:59 +02:00
Adrian Kuta 7af99f91f3 Merge pull request #1 from AdrianKuta/feat/core-infrastructure
Core Infrastructure (REDI-80…84)
2026-06-10 12:29:56 +02:00
Adrian Kuta 6a1842ae96 refactor(logging): use Timber instead of Kermit
Android-only project, so Timber (the de-facto Android logging lib) fits better than the
KMP-oriented Kermit.

- Catalog: kermit -> timber (com.jakewharton.timber:timber 5.0.1).
- core:data: Ktor Logging bridged to Timber; safeCall logs via Timber.
- app: plant Timber.DebugTree() in debug only (buildConfig enabled for BuildConfig.DEBUG).
2026-06-10 12:23:43 +02:00
Adrian Kuta b7ccf2fefa fix(core:data): catch deserialization errors in safeCall (review)
Deserialization happens in responseToResult via response.body<T>() on the 2xx branch.
That call was outside safeCall's try/catch, so a malformed 2xx body threw an uncaught
SerializationException (crash) instead of mapping to DataError.Network.SERIALIZATION.
Move responseToResult(execute()) inside the try so both transport and parse errors are typed.

Found by the milestone review.
2026-06-10 11:57:08 +02:00
Adrian Kuta 070ffde49c feat(app): Koin bootstrap + AppTheme + Material3 XML theme (REDI-84)
- ArchitectureApp.Application: startKoin { androidLogger; androidContext; modules(coreDataModule) }.
  Modules are assembled only here; feature modules will append to the list.
- MainActivity hosts a themed empty screen via AppTheme + AppScaffold (design-system).
- Activity XML theme upgraded to Theme.Material3.DayNight.NoActionBar (Compose themes via AppTheme;
  the Material3 XML theme lets the later Views renderer inherit Material3 styling).
- :app depends on :core:data + :core:design-system; applies the koin convention.
2026-06-10 11:48:19 +02:00
Adrian Kuta 5f3cc51195 feat(core:data): Ktor network core + coreDataModule (REDI-83)
- HttpClientFactory.create(engine) with the engine injected (MockEngine seam for tests):
  ContentNegotiation JSON (ignoreUnknownKeys), Kermit-backed Ktor logging, default JSON request.
- safeCall / responseToResult (status -> DataError.Network, extended with 400/403/404/503) /
  constructRoute (reads BuildConfig.BASE_URL) and typed HttpClient.get/post/delete.
- BASE_URL BuildConfig field = Rick & Morty API.
- coreDataModule: single<HttpClient> via factory lambda (the one sanctioned lambda-DSL binding).
2026-06-10 11:45:53 +02:00
Adrian Kuta 709c7d6ff5 feat(core:presentation): UiText, ObserveAsEvents, DataError -> UiText (REDI-82)
- UiText sealed interface (DynamicString / StringResource) — Compose-free type so the
  UI-agnostic presentation module can hold UiText? in state without depending on Compose.
- Two resolvers: @Composable UiText.asString() (Compose renderer) and
  UiText.asString(context) (Views renderer).
- ObserveAsEvents: lifecycle-aware one-time event collection on Main.immediate.
- DataError.toUiText() covering all displayed cases with else -> unknown; error strings here.
2026-06-10 11:42:38 +02:00
Adrian Kuta 3a155beb3c feat(core:design-system): AppTheme + reusable composables (REDI-81)
- AppTheme wraps Material3 (color scheme, typography, shapes); all previews use it.
- Slot-API AppCard (header + content slots, optional click); AppScaffold.
- LoadingIndicator, ErrorState (optional retry), Coil-backed NetworkImage.
- Modifier.shimmerEffect() animated placeholder (Modifier extension, not @Composable).
- Add androidx-compose-foundation to the version catalog + compose bundle.
2026-06-10 11:39:51 +02:00
Adrian Kuta 6bc4027cbb feat(core:domain): typed Result / Error / DataError core (REDI-80)
- Error marker interface; Result<D, E: Error> (Success/Error) + EmptyResult typealias.
- Inline chainable helpers: map / onSuccess / onFailure / asEmptyResult.
- DataError sealed interface with full Network + Local case sets.
- Pure Kotlin, zero Android imports.
2026-06-10 11:31:13 +02:00
Adrian Kuta a3bda3b601 chore: gitignore the whole .idea/ directory
Untrack IDE-generated .idea/ files that a Gradle sync added; ignore the directory
entirely to keep machine-specific config out of the repo.
2026-06-10 11:28:14 +02:00
Adrian Kuta 9ef0719447 chore: use src/main/kotlin source directories in :app
Kotlin-only reference repo — move the app's main/test/androidTest source roots
from src/main/java to src/main/kotlin (the core/feature modules already use kotlin).
Verified: :app:compileDebugKotlin produces MainActivity from src/main/kotlin.
2026-06-10 11:27:32 +02:00
Adrian Kuta 10fa6dc9eb chore: scaffold multi-module project, version catalog, and build-logic
Foundation milestone (REDI-78, REDI-79):

- Multi-module skeleton: :app, :core:{domain,data,presentation,design-system},
  :feature:characters:{domain,data,presentation,presentation-compose,presentation-views},
  :feature:about:presentation, plus the :build-logic composite build.
- gradle/libs.versions.toml as the single source of truth ([versions]/[libraries]/
  [bundles]/[plugins]); no inline versions in any build file.
- Convention plugins: architecture.android.{application,library,feature,feature.views},
  domain.module, compose, koin, ktor, kotlinx.serialization.
- Pure-Kotlin domain modules; presentation-compose uses android.feature;
  presentation-views uses android.feature.views (ViewBinding on, Compose off);
  the UI-agnostic :presentation has neither Compose nor Views deps.
- Toolchain: AGP 9.0.1, Kotlin 2.3.20, Gradle 9.1.0, compileSdk 36, minSdk 24, Java 17.
- Minimal MainActivity placeholder; CI (assembleDebug) via GitHub Actions.

Verified: ./gradlew projects lists the full tree and ./gradlew assemble is green.
2026-06-10 10:52:03 +02:00
42 changed files with 263 additions and 840 deletions
+1 -1
View File
@@ -16,5 +16,5 @@
# Local config / secrets
local.properties
# IDE (JetBrains / Android Studio) - fully ignored to avoid machine-specific churn
# IDE (JetBrains / Android Studio) fully ignored to avoid machine-specific churn
/.idea/
+52 -321
View File
@@ -1,353 +1,84 @@
# Android Architecture Showcase
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*.
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.
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. 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)
---
> **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.
## Stack
| 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.2.1, Kotlin 2.4.0, Gradle 9.5.1, `compileSdk` 37 / `targetSdk` 36, `minSdk` 24, Java 17 |
| UI | Jetpack Compose (Material 3) + one classic **Views/XML** renderer |
| DI | Koin 4.2 (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 6, MockK, Turbine, AssertK, `kotlinx-coroutines-test`, Ktor `MockEngine`, Compose UI test |
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/).
> **AGP 9 gotcha:** AGP 9.2 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`.
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.
---
## 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.
## Module structure
```
: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
: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-systemAppTheme + 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
```
**Dependency rules** (enforced by what each convention plugin exposes):
**Dependency rules:** `presentation → domain ← data`; `domain` depends only on `:core:domain`;
features never depend on other features; `:app` wires the graph.
| 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 |
## Presentation patterns (MVI vs MVVM)
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.
---
## 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()`).
- **UI models** (`*Ui`) live in `presentation` and carry display-ready data (e.g. blank detail fields
pre-formatted to an em dash).
### 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.
---
## Presentation patterns: MVI vs MVVM
Both patterns live side by side so the trade-off is concrete.
Both patterns live side by side so the trade-off is concrete, not theoretical.
| | **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` |
| Side effects | one-time `Event`s via a `Channel` (nav, snackbar) | none the screen calls a method / uses `LocalUriHandler` directly |
| Best when | state is complex and interacting; effects matter | the screen is small and mostly static |
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.
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.**
### Note - Events vs State
### One ViewModel, two renderers
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`.
`: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:
### 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.
---
## 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`,
resolving the **same** Koin `CharacterListViewModel` via `by viewModel()`.
- `:feature:characters:presentation-compose` — Jetpack Compose (`LazyColumn`).
- `:feature:characters:presentation-views``Fragment` + ViewBinding + `RecyclerView`/`DiffUtil`.
`: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 and
from navigation.
interop) and injects all navigation as callbacks, so the renderers stay decoupled from each other.
> **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.
---
## 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.
---
## 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.
---
## 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`.
---
## Testing
Tests prove the architecture, not just the code. Stack: **JUnit 6**, **MockK**, **Turbine** (Flow),
**AssertK**, `kotlinx-coroutines-test`, Ktor **`MockEngine`**, and Compose UI test.
| What | Where | Kind |
|---|---|---|
| `GetCharactersPageUseCase` | `:feature:characters:domain` `src/test` | pure JVM, JUnit 6 |
| `CharacterListViewModel`, `CharacterDetailViewModel` | `:feature:characters:presentation` `src/test` | JVM unit, MockK + 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:
- **MockK for collaborators.** The ViewModel/UseCase tests stub the `CharacterRepository` interface
with MockK - `coEvery` scripts the suspend calls, `coVerify` asserts the paging/retry interactions.
- **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 6 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).
---
## Build & run (`android` CLI)
## Build & run
```bash
# Build
./gradlew assembleDebug # build the debug APK
./gradlew projects # print the module tree
./gradlew test # all JVM unit tests (JUnit 6)
./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
./gradlew assembleDebug # build the APK
./gradlew projects # print the module tree
./gradlew check # tests + lint (added in the Quality & Docs milestone)
```
Requires JDK 17+ (the Gradle build pins a Java 17 toolchain) and the Android SDK
(`compileSdk 37`, `minSdk 24`).
---
## Optional: Room stretch
Out of core scope and **not implemented** (an optional stretch). 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.
(`compileSdk 36`, `minSdk 24`).
+2 -2
View File
@@ -35,9 +35,9 @@ dependencies {
implementation(libs.androidx.navigation.compose)
// Compose↔View interop: hosts a Fragment inside the Compose NavHost.
implementation(libs.androidx.fragment.compose)
// Material Components - required for the Material3 XML Activity theme.
// Material Components required for the Material3 XML Activity theme.
implementation(libs.material)
// Logging - the DebugTree is planted here; other modules log via Timber's static API.
// Logging the DebugTree is planted here; other modules log via Timber's static API.
implementation(libs.timber)
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
@@ -4,7 +4,7 @@ import kotlinx.serialization.Serializable
/**
* Route for the characters list rendered with the classic **Views** toolkit. It lives in `:app`
* because `:app` owns Compose↔View interop - the `:feature:characters:presentation-views` module
* because `:app` owns Compose↔View interop the `:feature:characters:presentation-views` module
* stays navigation-agnostic (it knows nothing about Compose Navigation or this route).
*/
@Serializable
@@ -7,11 +7,11 @@ 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 Platform** with the
* 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.2. It isn't needed for *local* unit tests anyway -
* 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).
@@ -21,7 +21,7 @@ class AndroidUnitTestConventionPlugin : Plugin<Project> {
dependencies {
add("testImplementation", libs.findBundle("unit-test").get())
add("testRuntimeOnly", libs.findLibrary("junit-jupiter-engine").get())
// Gradle 9 dropped the bundled launcher; the JUnit Platform won't start without it.
// Gradle 9 dropped the bundled launcher; JUnit 5 won't start without it.
add("testRuntimeOnly", libs.findLibrary("junit-platform-launcher").get())
}
@@ -27,7 +27,7 @@ class ComposeConventionPlugin : Plugin<Project> {
dependencies {
// `implementation` (not api): every Compose consumer applies this convention itself, so
// Compose must NOT leak transitively - that keeps the UI-agnostic presentation module
// Compose must NOT leak transitively that keeps the UI-agnostic presentation module
// (which depends on core:presentation) free of Compose.
val bom = platform(libs.findLibrary("androidx-compose-bom").get())
add("implementation", bom)
@@ -8,7 +8,7 @@ import org.gradle.kotlin.dsl.withType
/**
* Pure-Kotlin (JVM) module for the domain layer: no Android dependencies. Adds Coroutines (for
* `Flow`-returning repository interfaces) and runs unit tests on the JUnit Platform.
* `Flow`-returning repository interfaces) and runs unit tests on the JUnit 5 platform.
*/
class DomainModuleConventionPlugin : Plugin<Project> {
override fun apply(target: Project) = with(target) {
@@ -20,12 +20,8 @@ class DomainModuleConventionPlugin : Plugin<Project> {
add("implementation", libs.findLibrary("kotlinx-coroutines-core").get())
add("testImplementation", libs.findLibrary("junit-jupiter-api").get())
add("testImplementation", libs.findLibrary("assertk").get())
// Domain doesn't consume the `unit-test` bundle, so MockK and the coroutines
// test artifact are added explicitly here.
add("testImplementation", libs.findLibrary("mockk").get())
add("testImplementation", libs.findLibrary("kotlinx-coroutines-test").get())
add("testRuntimeOnly", libs.findLibrary("junit-jupiter-engine").get())
// Gradle 9 dropped the bundled launcher; the JUnit Platform won't start without it.
// Gradle 9 dropped the bundled launcher; JUnit 5 won't start without it.
add("testRuntimeOnly", libs.findLibrary("junit-platform-launcher").get())
}
@@ -7,7 +7,7 @@ import org.gradle.kotlin.dsl.configure
import org.gradle.kotlin.dsl.getByType
import org.jetbrains.kotlin.gradle.dsl.KotlinProjectExtension
internal const val COMPILE_SDK = 37
internal const val COMPILE_SDK = 36
internal const val MIN_SDK = 24
internal const val TARGET_SDK = 36
internal const val JVM_TARGET = 17
@@ -6,7 +6,7 @@ import io.ktor.client.engine.okhttp.OkHttp
import org.koin.dsl.module
/**
* Core data DI: the single shared [HttpClient]. This is the one sanctioned lambda-DSL binding -
* Core data DI: the single shared [HttpClient]. This is the one sanctioned lambda-DSL binding
* HttpClient is assembled by a factory plus the OkHttp engine (not a plain constructor), so the
* constructor DSL (`singleOf`) cannot express it. Feature data modules append their own bindings.
*/
@@ -75,16 +75,8 @@ suspend inline fun <reified T> safeCall(
Result.Error(DataError.Network.SERIALIZATION)
} catch (e: Exception) {
if (e is CancellationException) throw e
// 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)
}
logNetworkError(e, "Unknown network failure")
Result.Error(DataError.Network.UNKNOWN)
}
}
@@ -98,7 +90,7 @@ internal fun logNetworkError(throwable: Throwable, message: String) {
Timber.tag("HttpClient").e(throwable, message)
}
/** Maps HTTP status codes to typed [DataError.Network] (covering 400/403/404 as well). */
/** Maps HTTP status codes to typed [DataError.Network] (extends the skill table with 400/403/404). */
suspend inline fun <reified T> responseToResult(
response: HttpResponse,
): Result<T, DataError.Network> {
@@ -4,7 +4,7 @@ import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.ui.graphics.Color
// Brand palette - seeded from the Android green used by the project.
// Brand palette seeded from the Android green used by the project.
private val Green10 = Color(0xFF00210B)
private val Green20 = Color(0xFF003918)
private val Green40 = Color(0xFF1E6C36)
@@ -13,7 +13,7 @@ sealed interface Result<out D, out E : Error> {
) : Result<Nothing, E>
}
/** A [Result] that carries no success payload - for operations that either succeed or fail. */
/** A [Result] that carries no success payload for operations that either succeed or fail. */
typealias EmptyResult<E> = Result<Unit, E>
inline fun <T, E : Error, R> Result<T, E>.map(map: (T) -> R): Result<R, E> {
@@ -9,7 +9,7 @@ import kotlinx.serialization.Serializable
data object AboutRoute
/**
* The About feature nav graph. It only needs a "go back" callback - `:app` wires it to the shared
* The About feature nav graph. It only needs a "go back" callback `:app` wires it to the shared
* NavController, keeping this feature decoupled from how it is reached.
*/
fun NavGraphBuilder.aboutGraph(
@@ -30,7 +30,7 @@ import com.example.architecture.feature.about.presentation.model.AboutLink
/**
* Root for the MVVM About screen. Note how different the wiring is from an MVI Root: it collects
* [AboutState] and passes the ViewModel's **method reference** straight through - there is no
* [AboutState] and passes the ViewModel's **method reference** straight through there is no
* `onAction` funnel and no event observation, because this screen has neither.
*/
@Composable
@@ -88,7 +88,7 @@ fun AboutScreen(
Text(text = "$highlight", style = MaterialTheme.typography.bodyMedium)
}
// The expandable card is driven entirely by the VM's plain method - the MVVM contrast.
// The expandable card is driven entirely by the VM's plain method the MVVM contrast.
AppCard(
onClick = onToggleMvvmNote,
header = {
@@ -8,7 +8,7 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
/**
* **MVVM - the deliberate contrast to the app's MVI screens.**
* **MVVM the deliberate contrast to the app's MVI screens.**
*
* There is no `Action` sealed type and no `Event`/effect `Channel`. The screen reads [state] and
* invokes the ViewModel's **plain public methods** directly. That is the whole point of this screen:
@@ -37,7 +37,7 @@ class AboutViewModel : ViewModel() {
),
mvvmNote = "MVI funnels every user intent through a single onAction(Action) entry point " +
"and emits one-time effects (navigation, snackbars) through an Event channel. That " +
"structure pays off when state is complex and interacting - like the paginated, " +
"structure pays off when state is complex and interacting like the paginated, " +
"process-death-restorable characters list. This screen is intentionally MVVM instead: " +
"the ViewModel exposes a StateFlow plus plain public methods (onToggleMvvmNote), with " +
"no Action or Event types at all. Rule of thumb: reach for MVI when state is complex " +
@@ -56,7 +56,7 @@ class AboutViewModel : ViewModel() {
)
val state: StateFlow<AboutState> = _state.asStateFlow()
/** MVVM: a plain public method mutates state directly - no Action object, no reducer funnel. */
/** MVVM: a plain public method mutates state directly no Action object, no reducer funnel. */
fun onToggleMvvmNote() {
_state.update { it.copy(showMvvmNote = !it.showMvvmNote) }
}
@@ -8,7 +8,7 @@ import com.example.architecture.feature.characters.data.dto.CharactersResponseDt
import io.ktor.client.HttpClient
/**
* Remote data source for characters. Returns raw DTOs (no mapping here - the repository maps via
* Remote data source for characters. Returns raw DTOs (no mapping here the repository maps via
* CharacterMapper). Errors already surface as [DataError.Network] from the typed `get` helper.
*/
internal class KtorCharacterDataSource(
@@ -1,162 +0,0 @@
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()
}
}
@@ -9,14 +9,14 @@ 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
* 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
* 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(
@@ -7,29 +7,23 @@ 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 io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
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 Platform (see DomainModuleConventionPlugin); the [CharacterRepository] collaborator is a
* MockK mock, stubbed with `coEvery` and verified with `coVerify`.
* 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 {
private val repository = mockk<CharacterRepository>()
private val useCase = GetCharactersPageUseCase(repository)
@Test
fun `returns the repository page on success`() = runTest {
fun `returns the repository page on success`() = runBlocking {
val page = CharactersPage(characters = listOf(domainCharacter(1)), nextPage = 2)
coEvery { repository.getCharacters(1) } returns Result.Success(page)
val useCase = GetCharactersPageUseCase(FakeCharacterRepository(pageResult = Result.Success(page)))
val result = useCase(page = 1)
@@ -37,8 +31,10 @@ class GetCharactersPageUseCaseTest {
}
@Test
fun `propagates the repository error`() = runTest {
coEvery { repository.getCharacters(1) } returns Result.Error(DataError.Network.SERVER_ERROR)
fun `propagates the repository error`() = runBlocking {
val useCase = GetCharactersPageUseCase(
FakeCharacterRepository(pageResult = Result.Error(DataError.Network.SERVER_ERROR)),
)
val result = useCase(page = 1)
@@ -47,13 +43,30 @@ class GetCharactersPageUseCaseTest {
}
@Test
fun `forwards the requested page number`() = runTest {
coEvery { repository.getCharacters(any()) } returns
Result.Success(CharactersPage(characters = emptyList(), nextPage = null))
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)
coVerify(exactly = 1) { repository.getCharacters(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(
@@ -1,81 +0,0 @@
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.
*/
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)
}
@@ -1,77 +0,0 @@
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)
}
}
@@ -68,7 +68,7 @@ fun CharacterDetailRoot(
CharacterDetailScreen(state = state, onAction = viewModel::onAction)
}
/** Pure, stateless screen - previewable without a ViewModel. */
/** Pure, stateless screen previewable without a ViewModel. */
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CharacterDetailScreen(
@@ -101,7 +101,7 @@ fun CharacterListRoot(
)
}
/** Pure, stateless screen - previewable without a ViewModel. */
/** Pure, stateless screen previewable without a ViewModel. */
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CharacterListScreen(
@@ -9,7 +9,7 @@ import kotlinx.serialization.Serializable
@Serializable
data object CharacterListRoute
/** Type-safe route for the character detail screen - carries only the typed id, never an object. */
/** Type-safe route for the character detail screen carries only the typed id, never an object. */
@Serializable
data class CharacterDetailRoute(val characterId: Int)
@@ -39,7 +39,7 @@ fun NavGraphBuilder.charactersGraph(
}
composable<CharacterDetailRoute> {
// The typed CharacterDetailRoute serializes `characterId` into the destination's arguments,
// which Navigation copies into the ViewModel's SavedStateHandle - that is where
// which Navigation copies into the ViewModel's SavedStateHandle that is where
// CharacterDetailViewModel reads it (keeping that module free of any navigation dependency).
CharacterDetailRoot(onNavigateBack = { navController.popBackStack() })
}
@@ -57,7 +57,7 @@ fun ErrorDemoRoot(
ErrorDemoScreen(state = state, onAction = viewModel::onAction)
}
/** Pure, stateless screen - previewable without a ViewModel. */
/** Pure, stateless screen previewable without a ViewModel. */
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ErrorDemoScreen(
@@ -23,7 +23,7 @@ import org.koin.androidx.viewmodel.ext.android.viewModel
/**
* Classic Views renderer for the characters list. It drives the **same** [CharacterListViewModel] as
* the Compose screen - proving the presentation logic (State/Action/Event/UI-model) is truly
* the Compose screen proving the presentation logic (State/Action/Event/UI-model) is truly
* UI-agnostic. Koin's `by viewModel()` supplies the VM (and its `SavedStateHandle`).
*
* `:app` (the interop owner) wires [onCharacterClick] / [onNavigateBack]; the Fragment never touches
@@ -6,7 +6,7 @@ import com.example.architecture.feature.characters.domain.model.CharacterStatus
/**
* Views-renderer presentation helpers for [CharacterStatus]. These intentionally mirror the Compose
* renderer's helpers but return platform types (a string-res id and an ARGB Int) - each renderer
* renderer's helpers but return platform types (a string-res id and an ARGB Int) each renderer
* owns its own resources, so the small label duplication across modules is expected.
*/
@StringRes
@@ -18,7 +18,7 @@ dependencies {
implementation(libs.androidx.lifecycle.viewmodel.ktx)
implementation(libs.androidx.lifecycle.viewmodel.savedstate)
implementation(libs.kotlinx.coroutines.android)
// Stable collection for state - makes the list Compose-stable WITHOUT a Compose dependency,
// Stable collection for state makes the list Compose-stable WITHOUT a Compose dependency,
// so this module stays UI-agnostic (no @Stable annotation, which would require compose-runtime).
// `api` because CharacterListState.characters exposes ImmutableList in the public state API.
api(libs.kotlinx.collections.immutable)
@@ -19,7 +19,7 @@ import kotlinx.coroutines.launch
* UI-agnostic MVI ViewModel for the character detail screen.
*
* Type-safe navigation writes the route's typed `characterId` into [SavedStateHandle] under its
* field name. Reading that raw key - instead of `savedStateHandle.toRoute<CharacterDetailRoute>()` -
* field name. Reading that raw key instead of `savedStateHandle.toRoute<CharacterDetailRoute>()`
* is deliberate: it keeps this module free of any navigation/Compose dependency (the route type
* lives in the renderer). The renderer is what reads the route via `toRoute()`.
*/
@@ -8,7 +8,7 @@ import kotlinx.collections.immutable.persistentListOf
/**
* The single source of UI state for the characters list. Deliberately Compose-free: instead of the
* `@Stable` annotation (which lives in compose-runtime), the list is an [ImmutableList], which
* Compose already treats as stable - so this module needs no Compose dependency. Navigation and
* Compose already treats as stable so this module needs no Compose dependency. Navigation and
* snackbars are one-time Events, never state.
*/
data class CharacterListState(
@@ -77,7 +77,7 @@ class CharacterListViewModel(
page++
}
// Always surface a failure - even a partial one where earlier pages loaded.
// Always surface a failure even a partial one where earlier pages loaded.
if (error != null) {
_events.send(CharacterListEvent.ShowSnackbar(error))
}
@@ -119,7 +119,7 @@ class CharacterListViewModel(
private fun loadPage(page: Int) {
// Flip the loading flag SYNCHRONOUSLY (before launching) so a rapid second OnLoadNextPage is
// guarded out before its coroutine starts - otherwise the same page loads twice and items
// guarded out before its coroutine starts otherwise the same page loads twice and items
// get appended twice.
_state.update { it.copy(isLoadingNextPage = true, error = null) }
viewModelScope.launch {
@@ -4,7 +4,7 @@ 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. */
/** Force a load that succeeds clears any current error. */
data object OnLoadSuccess : ErrorDemoAction
/** Re-issue the most recent load (the design-system retry button). */
@@ -4,8 +4,8 @@ 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.
* [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,
@@ -16,7 +16,7 @@ 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
* 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:
*
@@ -24,10 +24,10 @@ import kotlinx.coroutines.launch
* 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
* 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).
* success). See android-error-handling.
*/
class ErrorDemoViewModel : ViewModel() {
@@ -10,8 +10,8 @@ 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.
// 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)
@@ -3,7 +3,7 @@ package com.example.architecture.feature.characters.presentation.model
import com.example.architecture.feature.characters.domain.model.Character
import com.example.architecture.feature.characters.domain.model.CharacterStatus
/** Presentation model for a character list item - decouples the UI from the domain [Character]. */
/** Presentation model for a character list item decouples the UI from the domain [Character]. */
data class CharacterUi(
val id: Int,
val name: String,
@@ -9,10 +9,6 @@ import assertk.assertions.isNotNull
import assertk.assertions.isNull
import assertk.assertions.isSameInstanceAs
import com.example.architecture.core.domain.DataError
import com.example.architecture.core.domain.Result
import com.example.architecture.feature.characters.domain.CharacterRepository
import io.mockk.coEvery
import io.mockk.mockk
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
@@ -27,16 +23,15 @@ 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. The [CharacterRepository] collaborator is a *relaxed* MockK mock, so the "missing id"
* case needs no stubbing while the rest stub `getCharacterDetails` explicitly with `coEvery`;
* assertions use AssertK; the back event is observed with Turbine.
* 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 = mockk<CharacterRepository>(relaxed = true)
private val repository = FakeCharacterRepository()
@BeforeEach
fun setUp() {
@@ -53,7 +48,7 @@ class CharacterDetailViewModelTest {
@Test
fun `loads details on init`() = runTest(dispatcher.scheduler) {
coEvery { repository.getCharacterDetails(1) } returns Result.Success(characterDetails(1))
repository.setDetails(characterDetails(1))
val viewModel = viewModel(characterId = 1)
advanceUntilIdle()
@@ -67,7 +62,7 @@ class CharacterDetailViewModelTest {
@Test
fun `load failure surfaces an error and no details`() = runTest(dispatcher.scheduler) {
coEvery { repository.getCharacterDetails(1) } returns Result.Error(DataError.Network.SERVER_ERROR)
repository.failWith = DataError.Network.SERVER_ERROR
val viewModel = viewModel(characterId = 1)
advanceUntilIdle()
@@ -80,13 +75,14 @@ class CharacterDetailViewModelTest {
@Test
fun `retry after a failure clears the error and loads details`() = runTest(dispatcher.scheduler) {
coEvery { repository.getCharacterDetails(1) } returns Result.Error(DataError.Network.NO_INTERNET)
repository.failWith = DataError.Network.NO_INTERNET
val viewModel = viewModel(characterId = 1)
advanceUntilIdle()
assertThat(viewModel.state.value.error).isNotNull()
// Same call, new answer - the latest `coEvery` wins, so the retry attempt succeeds.
coEvery { repository.getCharacterDetails(1) } returns Result.Success(characterDetails(1))
// The next attempt will succeed.
repository.failWith = null
repository.setDetails(characterDetails(1))
viewModel.onAction(CharacterDetailAction.OnRetry)
advanceUntilIdle()
@@ -97,7 +93,7 @@ class CharacterDetailViewModelTest {
@Test
fun `back click emits NavigateBack`() = runTest(dispatcher.scheduler) {
coEvery { repository.getCharacterDetails(1) } returns Result.Success(characterDetails(1))
repository.setDetails(characterDetails(1))
val viewModel = viewModel(characterId = 1)
advanceUntilIdle()
@@ -112,7 +108,6 @@ class CharacterDetailViewModelTest {
@Test
fun `missing character id fails fast`() {
// The route contract: type-safe nav must have written characterId into SavedStateHandle.
// The constructor throws before the (relaxed) repository is ever touched.
assertThrows<IllegalStateException> {
CharacterDetailViewModel(SavedStateHandle(), repository)
}
@@ -1,28 +0,0 @@
package com.example.architecture.feature.characters.presentation
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
/** Minimal list-item domain fixture for presentation tests. */
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 for presentation tests. */
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,
)
@@ -13,14 +13,7 @@ import assertk.assertions.isNull
import assertk.assertions.isTrue
import assertk.assertions.prop
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
import com.example.architecture.feature.characters.domain.usecase.GetCharactersPageUseCase
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.coVerifyOrder
import io.mockk.mockk
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
@@ -33,20 +26,19 @@ import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
/**
* Unit tests for [CharacterListViewModel] - driven entirely through its public MVI surface
* 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. The collaborator is a MockK mock of [CharacterRepository] (the real
* [GetCharactersPageUseCase] wraps it), stubbed per page with `coEvery` and verified with `coVerify`;
* `state`/`events` are observed with Turbine; assertions use AssertK.
* 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 = mockk<CharacterRepository>()
private val repository = FakeCharacterRepository()
@BeforeEach
fun setUp() {
@@ -63,8 +55,7 @@ class CharacterListViewModelTest {
@Test
fun `loads the first page on init`() = runTest(dispatcher.scheduler) {
coEvery { repository.getCharacters(1) } returns
Result.Success(CharactersPage(listOf(character(1), character(2)), nextPage = 2))
repository.setPage(page = 1, characters = listOf(character(1), character(2)), nextPage = 2)
val viewModel = viewModel()
@@ -87,7 +78,7 @@ class CharacterListViewModelTest {
@Test
fun `initial load failure emits a snackbar event and a full-screen error`() =
runTest(dispatcher.scheduler) {
coEvery { repository.getCharacters(any()) } returns Result.Error(DataError.Network.NO_INTERNET)
repository.failWith = DataError.Network.NO_INTERNET
val viewModel = viewModel()
@@ -104,10 +95,8 @@ class CharacterListViewModelTest {
@Test
fun `does not load past the last page`() = runTest(dispatcher.scheduler) {
coEvery { repository.getCharacters(1) } returns
Result.Success(CharactersPage(listOf(character(1)), nextPage = 2))
coEvery { repository.getCharacters(2) } returns
Result.Success(CharactersPage(listOf(character(2)), nextPage = null)) // last page
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
@@ -118,24 +107,21 @@ class CharacterListViewModelTest {
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
// Page 2 was fetched exactly once and no page 3 was ever requested (a page-3 fetch would also
// blow up the strict mock as an unstubbed call).
coVerify(exactly = 1) { repository.getCharacters(2) }
coVerify(exactly = 0) { repository.getCharacters(3) }
assertThat(repository.getCharactersCallCount).isEqualTo(callsBefore)
}
@Test
fun `rapid duplicate next-page actions load the page only once`() = runTest(dispatcher.scheduler) {
coEvery { repository.getCharacters(1) } returns
Result.Success(CharactersPage(listOf(character(1)), nextPage = 2))
coEvery { repository.getCharacters(2) } returns
Result.Success(CharactersPage(listOf(character(2)), nextPage = 3))
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.
@@ -143,15 +129,14 @@ class CharacterListViewModelTest {
viewModel.onAction(CharacterListAction.OnLoadNextPage)
advanceUntilIdle()
coVerify(exactly = 1) { repository.getCharacters(2) }
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) {
coEvery { repository.getCharacters(1) } returns
Result.Success(CharactersPage(listOf(character(1)), nextPage = 2))
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
@@ -159,14 +144,13 @@ class CharacterListViewModelTest {
viewModel.onAction(CharacterListAction.OnLoadNextPage)
advanceUntilIdle()
// Only the single initial load ran - the guarded next-page request never fired.
coVerify(exactly = 1) { repository.getCharacters(1) }
coVerify(exactly = 0) { repository.getCharacters(2) }
// 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) {
coEvery { repository.getCharacters(any()) } returns Result.Error(DataError.Network.NO_INTERNET)
repository.failWith = DataError.Network.NO_INTERNET
val viewModel = viewModel()
viewModel.events.test {
@@ -176,10 +160,9 @@ class CharacterListViewModelTest {
assertThat(awaitItem()).isInstanceOf(CharacterListEvent.ShowSnackbar::class)
assertThat(viewModel.state.value).prop(CharacterListState::characters).isEmpty()
// Empty branch of retry(): the repository recovers (the later, more specific stub for
// page 1 wins over the `any()` failure), OnRetry rebuilds from page 1.
coEvery { repository.getCharacters(1) } returns
Result.Success(CharactersPage(listOf(character(1), character(2)), nextPage = 2))
// 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()
@@ -191,14 +174,12 @@ class CharacterListViewModelTest {
@Test
fun `retry after a failed next page re-requests that page`() = runTest(dispatcher.scheduler) {
coEvery { repository.getCharacters(1) } returns
Result.Success(CharactersPage(listOf(character(1)), nextPage = 2))
repository.setPage(page = 1, characters = listOf(character(1)), nextPage = 2)
val viewModel = viewModel()
advanceUntilIdle() // page 1 loaded (no event)
viewModel.events.test {
// Page 2 fails on the first attempt → list keeps page 1, shows an error.
coEvery { repository.getCharacters(2) } returns Result.Error(DataError.Network.NOT_FOUND)
// 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)
@@ -206,8 +187,7 @@ class CharacterListViewModelTest {
// 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).
coEvery { repository.getCharacters(2) } returns
Result.Success(CharactersPage(listOf(character(2)), nextPage = null))
repository.setPage(page = 2, characters = listOf(character(2)), nextPage = null)
viewModel.onAction(CharacterListAction.OnRetry)
advanceUntilIdle()
cancelAndIgnoreRemainingEvents()
@@ -216,16 +196,12 @@ class CharacterListViewModelTest {
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()
// Page 2 was requested twice: the failed first load and the successful retry.
coVerify(exactly = 2) { repository.getCharacters(2) }
}
@Test
fun `restores up to the saved page after process death`() = runTest(dispatcher.scheduler) {
coEvery { repository.getCharacters(1) } returns
Result.Success(CharactersPage(listOf(character(1)), nextPage = 2))
coEvery { repository.getCharacters(2) } returns
Result.Success(CharactersPage(listOf(character(2)), nextPage = 3))
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))
@@ -235,9 +211,5 @@ class CharacterListViewModelTest {
// 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)
coVerifyOrder {
repository.getCharacters(1)
repository.getCharacters(2)
}
}
}
@@ -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,
)
+20 -22
View File
@@ -1,46 +1,45 @@
[versions]
# Build / language
agp = "9.2.1"
kotlin = "2.4.0"
agp = "9.0.1"
kotlin = "2.3.20"
# AndroidX - core / lifecycle / activity / views
androidxCore = "1.19.0"
# AndroidX core / lifecycle / activity / views
androidxCore = "1.18.0"
androidxLifecycle = "2.10.0"
androidxActivity = "1.13.0"
androidxAppcompat = "1.7.1"
androidxFragment = "1.8.9"
androidxAppcompat = "1.7.0"
androidxFragment = "1.8.5"
androidxRecyclerview = "1.4.0"
androidxNavigation = "2.9.8"
androidxNavigation = "2.9.0"
# Compose (BOM-managed)
composeBom = "2026.05.01"
composeBom = "2026.03.01"
# Async / serialization
coroutines = "1.11.0"
kotlinxSerialization = "1.11.0"
kotlinxCollectionsImmutable = "0.5.0"
coroutines = "1.10.2"
kotlinxSerialization = "1.8.1"
kotlinxCollectionsImmutable = "0.3.8"
# DI
koin = "4.2.1"
koin = "4.1.0"
# Networking
ktor = "3.5.0"
ktor = "3.1.3"
# Image loading
coil = "3.5.0"
coil = "3.1.0"
# Logging
timber = "5.0.1"
# Material Components (Views renderer)
material = "1.14.0"
material = "1.12.0"
# Testing
junitJupiter = "6.1.0"
junitPlatform = "6.1.0"
turbine = "1.2.1"
junitJupiter = "5.11.4"
junitPlatform = "1.11.4"
turbine = "1.2.0"
assertk = "0.28.1"
mockk = "1.14.11"
androidxTestExt = "1.3.0"
androidxTestRunner = "1.7.0"
androidxEspresso = "3.7.0"
@@ -116,11 +115,10 @@ timber = { module = "com.jakewharton.timber:timber", version.ref = "timber" }
# --- Testing ---
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" }
# Gradle 9 no longer bundles the launcher - it must be on the test runtime classpath explicitly.
# 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" }
mockk = { module = "io.mockk:mockk", version.ref = "mockk" }
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" }
@@ -144,7 +142,7 @@ 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", "mockk"]
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+),
+1 -1
View File
@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.1-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME