DEV Community

Cover image for Pure Reducers in Kotlin: Why Your Android Unit Tests Should Run in 5 Milliseconds
Ouday khaled
Ouday khaled

Posted on • Originally published at expert-android-playbook.hashnode.dev

Pure Reducers in Kotlin: Why Your Android Unit Tests Should Run in 5 Milliseconds

Pure reducers hero

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

Reducer signature annotated

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)
}
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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

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)
}
Enter fullscreen mode Exit fullscreen mode

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

Test speed comparison

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 ...
}
Enter fullscreen mode Exit fullscreen mode

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.

Where to go next

Top comments (0)