Reducer unit tests clear in about five milliseconds each, with no dispatcher setup, no MockK, no Turbine, and no ViewModel. That speed is not a micro-optimization — it reshapes the test pyramid, moves 80% of Android presentation tests to pure JUnit, and keeps the feedback loop under a second on a laptop. This article walks through the Kotlin contract that makes the five-millisecond figure possible, and the disciplines that keep it there.
What a reducer is
object FactsReducer : MviReducer<FactsState, FactsIntent> {
override fun reduce(state: FactsState, intent: FactsIntent): Reduction<FactsState, FactsIntent> =
when (intent) {
// User gestures
FactsIntent.Refresh -> state.onRefresh()
FactsIntent.LoadNextPage -> state.onLoadNextPage()
FactsIntent.DismissError -> state.onDismissError()
FactsIntent.FetchRandomFact -> state // middleware only
FactsIntent.Retry -> state.onRetry()
is FactsIntent.SearchChanged -> state.onSearchChanged(intent.query)
is FactsIntent.ToggleBookmark -> state.onToggleBookmarkRequested(intent.factId)
is FactsIntent.FactClicked -> state // navigation → effect only
// Internal
is FactsIntent.SearchQueryCommitted -> state.onSearchCommitted(intent.query)
is FactsIntent.FactsReceived -> state.onFactsReceived(intent.facts, intent.append)
is FactsIntent.FactsLoadFailed -> state.onFactsLoadFailed(intent.cause)
is FactsIntent.BookmarkStarted -> state.onBookmarkStarted(intent.factId)
is FactsIntent.BookmarkConfirmed -> state.onBookmarkConfirmed(intent.factId)
is FactsIntent.BookmarkFailed -> state.onBookmarkFailed(intent.factId, intent.cause)
}.wrap()
private fun FactsState.onRefresh(): FactsState =
if (isRefreshing || isLoading) this
else copy(isRefreshing = true, error = null)
// ... unchanged ...
private fun FactsState.wrap(): Reduction<FactsState, FactsIntent> = Reduction(this)
}
The reducer is a top-level object implementing MviReducer<S, I>. It is a plain fun, never suspend, never inline, never generic, with no constructor parameters, no @Inject-ed collaborators, and no reference to the enclosing Store. Given a state and an intent, it returns a Reduction(newState, followUps) — one new state plus an optional list of follow-up intents the runtime dispatches next.
The when is exhaustive against a sealed FactsIntent interface. No else branch, by design. When a future branch adds FactsIntent.ExportCsv, the Kotlin compiler rejects the reducer until a matching case exists, so the contract stays total and silent-drop bugs are compile errors. Each branch is a one-line delegation to a named extension helper — state.onRefresh(), state.onLoadNextPage() — which keeps the when scannable at a glance and lets each helper be tested in isolation, one gesture at a time, under 300 lines per reducer.
Rule: reduce takes (State, Intent), returns Reduction, and depends on nothing else.
No-op detection
// Bad — always allocates, always recomposes.
private fun FactsState.onDismissError(): FactsState = copy(error = null)
// Good — same instance when already dismissed.
private fun FactsState.onDismissError(): FactsState =
if (error == null) this else copy(error = null)
Every intent runs the reducer. Returning copy() unconditionally allocates a new state reference and triggers wave recompositions across every subscribed composable, even when no field changed. The fix is a reference check first, a copy second. Compose's structural-equality skip then short-circuits the recomposition, and the scrolling list stops stuttering at the end of the feed.
Apply the pattern to every branch, not to hot paths alone. The obvious offenders are SearchChanged(query), which fires per keystroke, and the bookmark loading pair BookmarkStarted / BookmarkConfirmed, which fires per tap. Less obvious is FactsReceived with an empty list during pagination — when the backend has no more pages, the branch must return the same state instance, not a fresh copy() with identical fields. Reducer tests lock the invariant with a single assertSame(state, out.state) call, which doubles as a grep-able marker that the branch is intentional rather than forgotten.
The pairing with Compose is what closes the loop. FactsScreen subscribes via collectAsStateWithLifecycle(), which uses structural equality to decide whether a new state warrants a recomposition. Returning the same reference from a no-op branch short-circuits that check before any @Composable in the tree reruns, and a correctly-stable FactCard(fact: CatFact, onClick: () -> Unit) skips too. The same discipline applies to ImmutableList and ImmutableSet: if a branch appends zero items, return the input list — persistentListOf() constructors and .toImmutableList() round-trips both allocate fresh instances that defeat skipping for the whole list column.
Rule: on a no-op intent, return the same state reference so Compose skips the recomposition.
Mutation composition
typealias Mutation<S> = (S) -> S
operator fun <S> Mutation<S>.plus(next: Mutation<S>): Mutation<S> =
{ s -> next(this(s)) }
// Compose for an intent that updates multiple concerns:
private fun FactsState.onFactsReceived(
facts: ImmutableList<CatFact>, append: Boolean,
): FactsState {
val updateFacts: Mutation<FactsState> = { s ->
val merged = if (append) (s.facts + facts).distinctBy { it.id }.toImmutableList() else facts
s.copy(facts = merged)
}
val clearLoading: Mutation<FactsState> = { it.copy(isLoading = false, isLoadingMore = false, isRefreshing = false) }
val clearError: Mutation<FactsState> = { it.copy(error = null) }
val advancePage: Mutation<FactsState> = { it.copy(currentPage = if (append) it.currentPage + 1 else 1) }
return (updateFacts + clearLoading + clearError + advancePage)(this)
}
A Mutation<S> is a function from state to state — nothing more. The + operator composes two mutations left-to-right, and a branch that updates several concerns at once becomes a pipeline of named mutations applied to the input state. The codebase uses one name: Partial and PartialState are banned aliases, and introducing them fails review on sight.
Reach for mutations when several concerns co-update, when each concern deserves an independent unit test, or when the reducer approaches the 300-line ceiling and decomposition is the alternative to splitting the store into two. Avoid them for single-field updates — a named Mutation wrapping one copy is noise. The rule of thumb: three concerns in one branch, compose mutations; one concern, inline copy. Middleware-level orchestration stays out of scope entirely, and a follow-up Intent in the Reduction is the only way the reducer asks the runtime for anything beyond the new state.
Rule: compose Mutations when a branch updates independent concerns; inline copy when it updates one field.
Why tests are 5ms
| Tier | Tests per feature | Runtime (wall clock) |
|---|---|---|
| Reducer | 30–50 | under 1 s total |
| Middleware | 10–20 | under 3 s total |
| Store integration | 5–10 | about 5 s total |
| Screen | 5–10 | device-bound |
| Macrobenchmark | 2–4 | physical device, slow |
class FactsReducerTest {
@Test
fun `Refresh on idle sets isRefreshing and clears error`() {
val out = FactsReducer.reduce(
state = FactsState(error = UiText.Raw("stale")),
intent = FactsIntent.Refresh,
)
assertTrue(out.state.isRefreshing)
assertNull(out.state.error)
assertTrue(out.followUps.isEmpty())
}
@Test
fun `Refresh while already refreshing returns same instance`() {
val state = FactsState(isRefreshing = true)
val out = FactsReducer.reduce(state, FactsIntent.Refresh)
assertSame(state, out.state)
}
// ... unchanged ...
}
The reducer is pure data in, pure data out. Tests call FactsReducer.reduce(state, intent) directly, assert on the returned state and followUps, and exit. There is no @Before, no Dispatchers.setMain, no runTest, no MockK, no fake repository, no Turbine, no Compose rule. A well-populated reducer tier carries 30–50 tests per feature — happy path, no-op branch, error mapping, follow-up list — and the full file clears in under a second.
Contrast with the middleware tier, where every test spins up a StandardTestDispatcher, a fake repository, a dispatch capture list, and ends with advanceUntilIdle. Useful and necessary, but an order of magnitude slower per test. The store-integration tier piles on real middlewares, Turbine, and SavedStateHandle wiring; the screen tier adds a Compose rule; macrobenchmark adds a physical device. That asymmetry is what lets the reducer tier stay the default and every tier above it the exception. The numbers in the diagram are typical orders of magnitude on a warm JVM, not benchmark-grade measurements; the relative shape is the point, and it holds across every feature in the playbook.
Rule: if a reducer test needs a dispatcher or a fake, it belongs to the middleware tier.
What belongs outside the reducer
| Violation | Fix |
|---|---|
Reducer calls repository.refresh()
|
Move to FactsDataMiddleware.process(Refresh)
|
| Reducer emits a side effect | Middleware's job; remove emit from the reducer |
Reducer uses System.currentTimeMillis()
|
Pass the time through the intent payload |
| Reducer throws on unknown combinations | Fold into state validation in a middleware; reducer is total |
when has an else branch |
Remove; let the compiler enforce exhaustiveness |
Reducer helper is suspend
|
Move to a middleware |
Branch unconditionally returns copy()
|
Add a no-op reference check |
Asynchronous work lives in middlewares. Repositories, use cases, debouncing, retries with backoff, analytics, SavedStateHandle persistence, keyed per-entity cancellation — every one of those belongs to a middleware, not the reducer. The reducer stays a pure function over (State, Intent); the runtime, through the middleware layer, handles I/O and dispatches result intents (FactsReceived, FactsLoadFailed, BookmarkConfirmed) back into the store for the reducer to fold into state. A middleware is the shape that accepts suspend, scope.launch, Flow operators, and runTest — all of which the reducer refuses.
Non-determinism is the other category. System.currentTimeMillis(), UUID.randomUUID(), Random.nextInt(), and any @Inject val clock: Clock all break time-travel replay and fail the property-based fuzz test every feature ships. The discipline is to pass time and identifiers through the intent payload — FactsReceived(facts, fetchedAt) — so that replaying a captured intent stream reproduces the bug exactly, in milliseconds, against the pure reducer. The same applies to error mapping: Throwable.toUiText() lives in :core:designsystem, takes no Android Context, and stays pure so the reducer can call it without tainting the tier. Intents are the only currency that crosses the boundary; the reducer never calls out, and the middleware never writes in.
Rule: if a helper needs suspend, a clock, or a repository, it belongs to a middleware.




Top comments (0)