Unidirectional state management for Android, built in Kotlin
Komposed is a lightweight Kotlin library for predictable, testable state management on Android. State transitions are pure functions. Side effects are typed values routed to registered handlers. Everything composes — features, reducers, and tests alike.
Inspired by the core ideas of The Composable Architecture (TCA) by Point-Free, Komposed adapts its fundamental building blocks — reducers, effects, lenses, and middleware — to idiomatic Kotlin and Jetpack Compose, without requiring any prior TCA knowledge.
Every state change follows a single predictable path. Actions flow down through middleware into reducers; new state and effects flow back up to the UI.
A single global state is sliced into feature sub-states using Lens<GLOBAL, LOCAL>. Each reducer operates only on its own slice — completely unaware of siblings.
Effects are typed sealed values produced by reducers. The store routes each effect to the registered EffectHandler by type, which dispatches zero, one, or many actions back into the store.
Core library modules have zero Android feature dependencies; each sample feature is its own Gradle module following the same internal layering.
Each layer of the architecture has a dedicated testing tool. Pure reducers need no coroutines; integration tests use TestScope for deterministic async.
Real production screens are rarely simple. A checkout flow aggregates cart items, delivery addresses, billing summaries, and payment state — all in one screen, all updating concurrently, all potentially triggering each other. The naive approach is a god ViewModel with 800 lines of interleaved business logic. Komposed solves this differently: each domain becomes an isolated feature module — its own state, reducer, and effect handler — and the screen is their composition, not their owner.
reducers {
cartReducer.scoped(CheckoutState.cartLens)
deliveryReducer.scoped(CheckoutState.deliveryLens)
placeOrderReducer.scoped(CheckoutState.placeOrderLens)
}
effectHandlers {
cartEffectHandler.register()
deliveryEffectHandler.register()
placeOrderEffectHandler.register()
}Adding a new feature to a screen is registering its reducer and handler. The existing features don't change. DeliveryReducer has zero awareness of CartState. Modules stay independent; the store is their meeting point.
Komposed doesn't ask teams to follow SRP by convention. The architecture enforces it structurally by assigning each layer exactly one job:
| Layer | Sole responsibility |
|---|---|
| Reducer | Receives state + action, returns next state and optionally an effect. No I/O. No coroutines. No dependencies. Pure function. |
| EffectHandler | Performs async work (network, disk, streams). Dispatches resulting actions back to the store. Never mutates state directly. |
| Store | Routes actions through middleware, invokes reducers, launches effect handlers on the right thread. The runtime — not a god object. |
| Middleware | Intercepts every action before it reaches a reducer. The right place for logging, analytics, A/B flags, and navigation. None of it belongs in business logic. |
| Subscription | Watches a derived value of global state. When the value changes after any state update, dispatches a new action — enabling reactive cross-domain communication without coupling modules together. |
Because each layer has one job, changing one never forces changes in the others. The delivery reducer can be rewritten without touching the store, the middleware, or the tests for any other feature.
Every state change follows one path:
Action → Middleware → Reducer → State + Effect → EffectHandler → Action → …
There is no way to mutate state from a coroutine, a callback, or a side channel. State is always a deterministic function of the actions that preceded it. This makes it trivial to reproduce any bug by replaying the action sequence and trivial to understand any behaviour by reading the reducer — a pure function with no hidden inputs.
The framework provides purpose-built APIs for each layer, so teams spend time asserting app behaviour — not fighting boilerplate.
Reducers are plain functions. No coroutines, no mocks, no TestScope:
cartReducer.given(CartState(), CartAction.LoadCart)
.assertState(CartState(isLoading = true))
.assertEffect<CartEffect.Load>()Effect handlers receive an anonymous repository; dispatched actions come back as a List<A> — no capture variable needed:
val result = handlerWith(repo).handle(CartEffect.Load)
assert(result.single() == CartAction.CartLoaded(items))Integration tests use TestStore, which records every dispatched action in order — including those produced by effects — so the full app flow is assertable as a sequence:
store.dispatch(CartAction.LoadCart)
advanceUntilIdle()
store.assertState(CheckoutState(cart = CartState(items = listOf(testItem))))
store.assertActionsDispatched(CartAction.LoadCart, CartAction.CartLoaded(testItem))Navigation is an assertable effect, not a side channel through a mocked NavController:
placeOrderReducer.given(state, PlaceOrderAction.OrderPlaced(orderId = "ORD-99"))
.assertNavigationEffect { nav ->
assert(nav.navigations.last() == OrderDetailsRoute(orderId = "ORD-99"))
}Cross-domain subscriptions are testable as end-to-end integration scenarios in TestStore. The subscription is declared once at the composition boundary; the modules it connects remain independently testable in isolation:
@Test
fun `selecting a delivery address pushes its fee into BillState via subscription`() = runTest {
val store = buildStore(this)
store.dispatch(DeliveryAction.FetchDeliveryAddresses)
advanceUntilIdle()
store.dispatch(DeliveryAction.OnDeliveryAddressSelected(1L))
assert(store.state.value.billState.deliveryFees == 10.0)
}Every layer — state transitions, effect dispatch, action ordering, and navigation — is independently and exhaustively testable without a device or an emulator.
Add the dependencies to your module's build.gradle.kts:
implementation("io.github.atwa:komposed:1.1.0")
// Testing utilities — add to the test source set only
testImplementation("io.github.atwa:komposed-testing:1.1.0")Or with a version catalog (gradle/libs.versions.toml):
[versions]
komposed = "1.1.0"
[libraries]
komposed = { group = "io.github.atwa", name = "komposed", version.ref = "komposed" }
komposed-testing = { group = "io.github.atwa", name = "komposed-testing", version.ref = "komposed" }implementation(libs.komposed)
testImplementation(libs.komposed.testing)Requirements: minSdk 24 · Kotlin 2.0 · Java 11 · kotlinx-coroutines-core (transitive)
io.github.atwa.komposed → Store, Lens (main entry points)
io.github.atwa.komposed.reducer → ReduceType, Reducer, ReducerRegistry, DSLs
io.github.atwa.komposed.effect → Effect, EffectHandler
io.github.atwa.komposed.middleware → Middleware, navigationMiddleware
io.github.atwa.komposed.navigation → Navigator, NavOptions, PopUpTo
io.github.atwa.komposed.subscription → Subscription, SubscriptionRegistry, subscriptions { } DSL
The five-minute path from zero to a working store:
// 1. State
data class CounterState(val count: Int = 0)
// 2. Actions
sealed interface CounterAction {
data object Increment : CounterAction
data object Decrement : CounterAction
}
// 3. Reducer (pure — no I/O, no injected handlers)
import io.github.atwa.komposed.reducer.ReduceType.Companion.reduce
import io.github.atwa.komposed.reducer.reducer
val counterReducer = reducer<CounterState, CounterAction> { state, action ->
when (action) {
CounterAction.Increment -> state.copy(count = state.count + 1).reduce()
CounterAction.Decrement -> state.copy(count = state.count - 1).reduce()
}
}
// 4. Store (inside ViewModel)
import io.github.atwa.komposed.createStore
import io.github.atwa.komposed.reducer.reducers
val store = createStore(
initialValue = CounterState(),
scope = viewModelScope,
reducers = reducers { counterReducer.register() },
)
// 5. UI
val state by store.state.collectAsStateWithLifecycle()
Text("Count: ${state.count}")
Button(onClick = { store.dispatch(CounterAction.Increment) }) { Text("+") }State is an immutable data class. Every change produces a new instance via copy(). The store exposes it as StateFlow<S>, so Compose collects it automatically.
data class CartState(
val items: List<CartItem> = emptyList(),
val isLoading: Boolean = false,
val error: String? = null,
)For multi-feature screens, compose sub-states and declare Lenses as companions:
data class CheckoutState(
val cart: CartState = CartState(),
val delivery: DeliveryState = DeliveryState(),
val placeOrder: PlaceOrderState = PlaceOrderState(),
) {
companion object {
val cartLens = lens(CheckoutState::cart) { copy(cart = it) }
val deliveryLens = lens(CheckoutState::delivery) { copy(delivery = it) }
val placeOrderLens = lens(CheckoutState::placeOrder) { copy(placeOrder = it) }
}
}Actions are sealed interface hierarchies. They carry all data a reducer needs to compute the next state and are the only way to trigger a change.
sealed interface CartAction {
data object LoadCart : CartAction
data class CartLoaded(val items: List<CartItem>) : CartAction
data class CartFailed(val message: String) : CartAction
data class RemoveItem(val itemId: String) : CartAction
}Every reducer returns a ReduceType<STATE> — a sealed type with three variants, each constructed via a companion DSL:
import io.github.atwa.komposed.reducer.ReduceType.Companion.reduce
import io.github.atwa.komposed.reducer.ReduceType.Companion.withEffect
import io.github.atwa.komposed.reducer.ReduceType.Companion.effect| Variant | DSL | When to use |
|---|---|---|
Reduce |
state.reduce() |
Pure state transition, no side effects |
ReduceWithEffect |
state.withEffect { MyEffect(…) } |
State change + async work |
SideEffect |
effect { MyEffect(…) } |
Async work with no state change |
Reducers are always pure — they receive state and action, return a ReduceType, and have zero external dependencies. Side effects are expressed as typed sealed values, never as lambdas or injected handler calls.
import io.github.atwa.komposed.reducer.reducer
val cartReducer = reducer<CartState, CartAction> { state, action ->
when (action) {
CartAction.LoadCart ->
state.copy(isLoading = true).withEffect { CartEffect.Load }
is CartAction.CartLoaded ->
state.copy(items = action.items, isLoading = false).reduce()
is CartAction.CartFailed ->
state.copy(isLoading = false, error = action.message).reduce()
is CartAction.RemoveItem ->
state.copy(items = state.items.filterNot { it.id == action.itemId }).reduce()
}
}Effects are typed sealed values that describe intent — not lambdas that execute it. Declare one per feature:
import io.github.atwa.komposed.effect.Effect
sealed interface CartEffect : Effect {
data object Load : CartEffect
data class Remove(val itemId: String) : CartEffect
}The store routes each effect to the registered handler by its runtime type. The only built-in effect type is NavigationEffect — all others are feature-defined.
EffectHandler<E, A> is the single interface for all async work. Implement it directly — no intermediate feature interface is needed:
import io.github.atwa.komposed.effect.EffectHandler
class CartEffectHandlerImpl @Inject constructor(
private val repository: CartRepository,
) : EffectHandler<CartEffect, CartAction> {
override suspend fun handle(effect: CartEffect, dispatch: suspend (suspend () -> CartAction) -> Unit) {
when (effect) {
CartEffect.Load -> // single action result
dispatch {
repository.getItems().fold(
onSuccess = { CartAction.CartLoaded(it) },
onFailure = { CartAction.CartFailed(it.message ?: "Unknown error") },
)
}
is CartEffect.Remove -> // fire-and-forget
repository.removeItem(effect.itemId) // no dispatch needed
CartEffect.ObserveStock -> // flow of actions
repository.stockStream().collect { item -> dispatch { CartAction.StockUpdated(item) } }
}
}
}All three async patterns are handled by the same interface:
- Single action — call
dispatch { }with a lambda returning the action - Fire-and-forget — omit
dispatchentirely - Multiple actions / streaming — call
dispatch { }inside a loop orFlow.collect
The dispatch lambda runs on Dispatchers.IO (where handle executes) and the resulting action is automatically posted back to the main thread by the store. The action type A is enforced at compile time — a CartEffectHandler can never accidentally dispatch a DeliveryAction.
Middleware intercepts every dispatched action before it reaches a reducer. Call next(action) to forward downstream. Omit it to swallow the action.
import io.github.atwa.komposed.middleware.createMiddleware
// Logging
fun <S> loggingMiddleware() = createMiddleware<S> { _, action, next ->
Log.d("Komposed", "→ ${action::class.simpleName}")
next(action)
}
// Analytics
fun <S> analyticsMiddleware() = createMiddleware<S> { _, action, next ->
analytics.track(action::class.simpleName)
next(action)
}
// Feature flag — swallows action when flag is off
fun <S> featureFlagMiddleware(flag: Boolean) = createMiddleware<S> { _, action, next ->
if (action is BetaFeatureAction && !flag) return@createMiddleware
next(action)
}Middleware executes in declaration order. The built-in navigationMiddleware(navigator) intercepts any NavigationEffect before reducers see it.
A Lens<GLOBAL, LOCAL> is an optics pair — a getter and a copy-setter — used to lift a local-state reducer into a global-state reducer:
import io.github.atwa.komposed.lens
val cartLens = lens(CheckoutState::cart) { copy(cart = it) }
// Pull a local reducer up to global state
val globalCartReducer: Reducer<CheckoutState, CartAction> =
cartReducer.pullback(cartLens)The reducers { } DSL calls pullback automatically via .scoped(lens).
Store<S> is the runtime container. It exposes:
state: StateFlow<S>— the current state, observable by the UIdispatch(action: Any)— the single entry point for all state changes
import io.github.atwa.komposed.createStore
import io.github.atwa.komposed.reducer.reducers
import io.github.atwa.komposed.reducer.effectHandlers
import io.github.atwa.komposed.middleware.navigationMiddleware
val store = createStore(
initialValue = CheckoutState(),
scope = viewModelScope,
middlewares = listOf(
loggingMiddleware(),
analyticsMiddleware(),
navigationMiddleware(navigator),
),
reducers = reducers {
cartReducer.scoped(CheckoutState.cartLens)
deliveryReducer.scoped(CheckoutState.deliveryLens)
placeOrderReducer.scoped(CheckoutState.placeOrderLens)
},
effectHandlers = effectHandlers {
cartEffectHandler.register()
deliveryEffectHandler.register()
placeOrderEffectHandler.register()
},
subscriptions = subscriptions {
subscription(
selector = { it.deliveryFee },
action = { BillAction.DeliveryFeeUpdated(it) },
)
},
)reducers { } registration methods:
| Method | Use when |
|---|---|
Reducer.scoped(lens) |
Reducer operates on a local state slice |
Reducer.register() |
Reducer already operates on the full global state |
effectHandlers { } registration:
effectHandlers {
cartEffectHandler.register() // E inferred from receiver type
deliveryEffectHandler.register()
}The effect type E is inferred automatically — no explicit type argument needed.
After a reducer returns:
Reduce→ state updated on main thread; subscriptions evaluatedReduceWithEffect→ state updated on main thread; subscriptions evaluated; effect launched onDispatchers.IOSideEffect→ effect launched onDispatchers.IO, no state changeNavigationEffect— re-dispatched through the full middleware chain on the main thread;navigationMiddlewareintercepts and executes it; reducers never see it- Subscription — after every state update, each registered selector is evaluated against the previous and new state; if the derived value changed, the corresponding action is dispatched through the full pipeline automatically
Threading is enforced by the store internally. Client code passes a plain CoroutineScope (e.g. viewModelScope) with no dispatcher override.
| What | Dispatcher |
|---|---|
| Action routing through middleware | Dispatchers.Main.immediate |
| Reducer invocation and state updates | Dispatchers.Main.immediate |
EffectHandler.handle body |
Dispatchers.IO |
| Actions dispatched back from handlers | Dispatchers.Main.immediate |
NavigationEffect re-dispatch |
Dispatchers.Main.immediate |
// ViewModel — no dispatcher override needed
val store = createStore(
initialValue = CheckoutState(),
scope = viewModelScope, // plain scope, no + Dispatchers.Main.immediate
reducers = reducers { ... },
effectHandlers = effectHandlers { ... },
)Because Dispatchers.Main.immediate executes synchronously when the caller is already on the main thread, dispatch() feels like a regular function call from the UI — no observable async delay.
TestStore replaces both dispatchers with UnconfinedTestDispatcher so coroutines execute eagerly in tests. Synchronous state transitions (pure reducer actions) are visible immediately after dispatch(). Call advanceUntilIdle() only when asserting the outcome of an async effect round-trip.
@Test
fun `SelectAddress routes to delivery reducer`() = runTest {
val store = buildStore(this)
store.dispatch(DeliveryAction.OnDeliveryAddressSelected(1L))
// No advanceUntilIdle() needed — UnconfinedTestDispatcher runs eagerly
store.assertState(CheckoutState(deliveryState = DeliveryState(selectedAddressId = 1L)))
}
@Test
fun `FetchAddresses effect loads addresses into state`() = runTest {
val store = buildStore(this)
store.dispatch(DeliveryAction.FetchDeliveryAddresses)
advanceUntilIdle() // wait for the effect handler coroutine to complete
assert(store.state.value.deliveryState.addresses.isNotEmpty())
}Navigation is treated as just another dispatched effect. NavController never appears in a ViewModel or reducer.
import io.github.atwa.komposed.navigation.Navigator
import io.github.atwa.komposed.navigation.NavOptions
@Singleton
class NavigatorImpl @Inject constructor() : Navigator {
private var navController: NavController? = null
fun bind(controller: NavController) { navController = controller }
override fun <T : Any> navigate(route: T) {
navController?.navigate(route)
}
override fun <T : Any> navigate(route: T, navOptions: NavOptions) {
val androidOptions = navOptions {
launchSingleTop = navOptions.launchSingleTop
restoreState = navOptions.restoreState
navOptions.popUpTo?.let { popUpTo(it.route) { inclusive = it.inclusive; saveState = it.saveState } }
}
navController?.navigate(route, androidOptions)
}
override fun navigateUp() { navController?.navigateUp() }
override fun popBackStack() { navController?.popBackStack() }
override fun <T : Any> popBackStackTo(route: T, inclusive: Boolean, saveState: Boolean) {
navController?.popBackStack(route, inclusive, saveState)
}
}@Composable
fun AppNavHost(navigator: NavigatorImpl = hiltViewModel()) {
val navController = rememberNavController()
LaunchedEffect(navController) { navigator.bind(navController) }
NavHost(navController, startDestination = CartRoute) {
composable<CartRoute> { CheckoutScreen() }
composable<OrderDetailsRoute> { OrderDetailsScreen() }
}
}// Simple navigation
is CartAction.GoBack ->
effect { NavigationEffect { navigateUp() } }
// Navigate to a route
is PlaceOrderAction.OrderPlaced ->
state.copy(isCheckoutInProgress = false).withEffect {
NavigationEffect { navigate(OrderDetailsRoute(orderId = action.orderId)) }
}
// Navigate with options — single top, pop up to login
is AuthAction.LoginSuccess ->
effect {
NavigationEffect {
navigate(
HomeRoute,
NavOptions(
launchSingleTop = true,
popUpTo = PopUpTo(LoginRoute, inclusive = true),
)
)
}
}When an action needs data from multiple sub-states, map the parent state to a domain model and let the composable or ViewModel construct the action. Each sub-reducer stays pure and unaware of its siblings:
// CheckoutState.kt — returns a domain model, not an action
fun toCheckoutParams() = CheckoutParams(
addressId = deliveryState.selectedAddressId, // null = no address selected
deliveryNote = deliveryState.deliveryNote,
addressLine = deliveryState.selectedAddress?.addressLine ?: "",
city = deliveryState.selectedAddress?.city ?: "",
deliveryFees = deliveryFee,
serviceFees = billState.serviceFees,
orderTotal = billState.orderTotal,
)
// Composable — constructs the action with the params
onCheckout = { dispatch(PlaceOrderAction.Checkout(state.toCheckoutParams())) }
// PlaceOrderReducer — owns the validation logic
is PlaceOrderAction.Checkout -> action.params.addressId?.let {
state.copy(isCheckoutInProgress = true).withEffect {
PlaceOrderEffect.PlaceOrder(action.params)
}
} ?: state.copy(errorMessage = "Please select a delivery address").reduce()Subscriptions solve a specific production problem: one feature module reacting to state changes in a sibling module without importing it.
A Subscription pairs a state selector (S) -> T with an action factory (T) -> Any. After every state update the store evaluates each selector; if the derived value changed, the action is dispatched automatically through the full pipeline — middleware, reducer, and any further effects.
import io.github.atwa.komposed.subscription.subscriptions
subscriptions = subscriptions {
subscription(
selector = { state -> state.deliveryFee },
action = { fee -> BillAction.DeliveryFeeUpdated(fee) },
)
}The BillModule defines DeliveryFeeUpdated as a plain action carrying a Double. It has no import from DeliveryModule or CheckoutModule. The wiring lives entirely at the composition boundary — the ViewModel, which already sees both modules:
// BillModule — no knowledge of DeliveryModule
sealed interface BillAction {
data class DeliveryFeeUpdated(val fee: Double) : BillAction
// ...
}
val billReducer = reducer<BillState, BillAction> { state, action ->
when (action) {
is BillAction.DeliveryFeeUpdated -> state.copy(deliveryFees = action.fee).reduce()
// ...
}
}// CheckoutViewModel — composition boundary, can see both modules
subscriptions = subscriptions {
subscription(
selector = { it.deliveryFee }, // reads from DeliveryState via CheckoutState
action = { BillAction.DeliveryFeeUpdated(it) }, // feeds into BillReducer
)
}- The selector is evaluated on the previous and new state after every
ReduceorReduceWithEffect - The action is dispatched only when the value changes — identical values produce no dispatch
- Dispatched actions pass through the full middleware chain — logged, tracked by analytics, visible in
TestStore.dispatchedActions SideEffectresults (no state change) do not trigger subscription evaluation
@Test
fun `selecting a delivery address pushes its fee into BillState via subscription`() = runTest {
val store = buildStore(this) // store wired with the subscription
store.dispatch(DeliveryAction.FetchDeliveryAddresses)
advanceUntilIdle()
store.dispatch(DeliveryAction.OnDeliveryAddressSelected(1L))
assert(store.state.value.billState.deliveryFees == 10.0)
}
@Test
fun `subscription does not fire when delivery fee is unchanged`() = runTest {
val store = buildStore(this)
store.dispatch(DeliveryAction.FetchDeliveryAddresses)
advanceUntilIdle()
store.dispatch(DeliveryAction.OnDeliveryAddressSelected(1L))
val feeBefore = store.state.value.billState.deliveryFees
store.dispatch(DeliveryAction.OnDeliveryAddressSelected(1L)) // same address, same fee
assert(store.state.value.billState.deliveryFees == feeBefore)
}The ViewModel creates the store lazily and owns the coroutine scope. It injects typed EffectHandler instances directly — no intermediate feature interfaces:
import io.github.atwa.komposed.effect.EffectHandler
import io.github.atwa.komposed.subscription.subscriptions
@HiltViewModel
class CheckoutViewModel @Inject constructor(
private val cartEffectHandler: EffectHandler<CartEffect, CartAction>,
private val deliveryEffectHandler: EffectHandler<DeliveryEffect, DeliveryAction>,
private val placeOrderEffectHandler: EffectHandler<PlaceOrderEffect, PlaceOrderAction>,
private val navigator: Navigator,
) : ViewModel() {
val store by lazy {
createStore(
initialValue = CheckoutState(),
scope = viewModelScope,
middlewares = listOf(
loggingMiddleware(),
analyticsMiddleware(),
navigationMiddleware(navigator),
),
reducers = reducers {
cartReducer.scoped(CheckoutState.cartLens)
deliveryReducer.scoped(CheckoutState.deliveryLens)
placeOrderReducer.scoped(CheckoutState.placeOrderLens)
},
effectHandlers = effectHandlers {
cartEffectHandler.register()
deliveryEffectHandler.register()
placeOrderEffectHandler.register()
},
subscriptions = subscriptions {
subscription(
selector = { it.deliveryFee },
action = { BillAction.DeliveryFeeUpdated(it) },
)
},
)
}
init {
store.dispatch(CartAction.LoadCart)
store.dispatch(DeliveryAction.FetchAddresses)
}
fun onCheckout() = store.dispatch(
PlaceOrderAction.Checkout(store.state.value.toCheckoutParams())
)
}Bind each handler to the fully-parameterized library interface — Dagger resolves by full parameterized type at compile time:
@Module
@InstallIn(SingletonComponent::class)
abstract class CartModule {
@Binds
abstract fun bindCartEffectHandler(
impl: CartEffectHandlerImpl,
): EffectHandler<CartEffect, CartAction>
}Collect the store's StateFlow with collectAsStateWithLifecycle and forward user events as dispatched actions:
@Composable
fun CheckoutScreen(viewModel: CheckoutViewModel = hiltViewModel()) {
val state by viewModel.store.state.collectAsStateWithLifecycle()
CartSection(
items = state.cart.items,
isLoading = state.cart.isLoading,
onRemove = { viewModel.store.dispatch(CartAction.RemoveItem(it)) },
)
DeliverySection(
addresses = state.delivery.addresses,
selectedAddressId = state.delivery.selectedAddressId,
onSelect = { viewModel.store.dispatch(DeliveryAction.SelectAddress(it)) },
)
CheckoutButton(
isLoading = state.placeOrder.isCheckoutInProgress,
onClick = { viewModel.store.dispatch(
PlaceOrderAction.Checkout(state.toCheckoutParams())
)},
)
}reducer.given(state, action) invokes the reducer and returns a chainable ReduceResult. No fake handlers or provide() calls needed — reducers are pure:
import io.github.atwa.komposed.testing.given
import io.github.atwa.komposed.testing.assertState
import io.github.atwa.komposed.testing.assertEffect
import io.github.atwa.komposed.testing.assertNoEffect
class CartReducerTest {
@Test
fun `LoadCart sets isLoading and emits Load effect`() {
cartReducer.given(CartState(), CartAction.LoadCart)
.assertState(CartState(isLoading = true))
.assertEffect<CartEffect.Load>()
}
@Test
fun `CartLoaded populates items and clears isLoading`() {
val items = listOf(CartItem("1", "Widget", 9.99))
cartReducer.given(CartState(isLoading = true), CartAction.CartLoaded(items))
.assertState(CartState(items = items, isLoading = false))
.assertNoEffect()
}
@Test
fun `CartFailed stores error message`() {
cartReducer.given(CartState(isLoading = true), CartAction.CartFailed("Timeout"))
.assertState(CartState(isLoading = false, error = "Timeout"))
.assertNoEffect()
}
@Test
fun `OrderPlaced emits NavigationEffect to OrderDetails`() {
cartReducer.given(CartState(), CartAction.OrderPlaced(orderId = "42"))
.assertNavigationEffect { nav ->
assert(nav.navigations.last() == OrderDetailsRoute("42"))
}
}
}Available assertions:
| Method | What it checks |
|---|---|
assertState(expected) |
nextState == expected |
assertNoStateChange() |
nextState == previousState |
assertEffect<E>(verify) |
Effect is of type E; optional lambda to inspect its fields |
assertNoEffect() |
No effect was emitted |
assertNavigationEffect { nav -> } |
Effect is NavigationEffect; executes it on a TestNavigator spy |
stateDiff() |
Returns List<PropertyChange(name, before, after)> for debugging |
The komposed-testing module provides a handle(effect) extension that collects every dispatched action and returns them as a List<A> — no capture variable or lambda needed:
import io.github.atwa.komposed.testing.handle
class CartEffectHandlerImplTest {
private fun handlerWith(repository: CartRepository) = CartEffectHandlerImpl(repository)
@Test
fun `Load dispatches CartLoaded on success`() = runTest {
val items = listOf(CartItem("1", "Widget", 9.99))
val repo = object : CartRepository {
override suspend fun getItems() = Result.success(items)
}
val result = handlerWith(repo).handle(CartEffect.Load)
assert(result.single() == CartAction.CartLoaded(items))
}
@Test
fun `Load dispatches CartFailed with message on failure`() = runTest {
val repo = object : CartRepository {
override suspend fun getItems() = Result.failure<List<CartItem>>(RuntimeException("Network error"))
}
val result = handlerWith(repo).handle(CartEffect.Load)
assert(result.single() == CartAction.CartFailed("Network error"))
}
}Use result.single() when exactly one action is expected, result.isEmpty() for fire-and-forget effects, or iterate for streaming handlers.
TestStore wraps a real store and injects a recording middleware that captures every action, including secondary ones produced by effects. Pass fake EffectHandler implementations directly:
import io.github.atwa.komposed.testing.TestStore
class CheckoutStoreTest {
private val fakeCartHandler = object : EffectHandler<CartEffect, CartAction> {
override suspend fun handle(effect: CartEffect, dispatch: suspend (suspend () -> CartAction) -> Unit) {
when (effect) {
CartEffect.Load -> dispatch { CartAction.CartLoaded(listOf(testItem)) }
}
}
}
private val fakeDeliveryHandler = object : EffectHandler<DeliveryEffect, DeliveryAction> {
override suspend fun handle(effect: DeliveryEffect, dispatch: suspend (suspend () -> DeliveryAction) -> Unit) {
when (effect) {
DeliveryEffect.FetchAddresses ->
dispatch { DeliveryAction.AddressesLoaded(listOf(testAddress)) }
}
}
}
private fun buildStore(scope: TestScope) = TestStore(
initialState = CheckoutState(),
scope = scope,
reducers = reducers {
cartReducer.scoped(CheckoutState.cartLens)
deliveryReducer.scoped(CheckoutState.deliveryLens)
},
effectHandlers = effectHandlers {
fakeCartHandler.register()
fakeDeliveryHandler.register()
},
)
@Test
fun `LoadCart effect round-trip updates state`() = runTest {
val store = buildStore(this)
store.dispatch(CartAction.LoadCart)
advanceUntilIdle()
store.assertState(CheckoutState(cart = CartState(items = listOf(testItem))))
}
@Test
fun `SelectAddress routes to delivery reducer`() = runTest {
val store = buildStore(this)
store.dispatch(DeliveryAction.SelectAddress(1L))
store.assertState(CheckoutState(delivery = DeliveryState(selectedAddressId = 1L)))
}
}TestStore members:
| Member | Description |
|---|---|
state: StateFlow<S> |
Live state — same as the real store |
dispatchedActions: List<Any> |
Every action seen in order, including effect results |
assertState(expected) |
Throws with a before/after message on mismatch |
assertActionsDispatched(vararg expected) |
Full equality check on the complete action sequence |
Call advanceUntilIdle() after dispatching an action that triggers an effect.
@Test
fun `OrderPlaced emits NavigationEffect to OrderDetails`() {
placeOrderReducer.given(PlaceOrderState(), PlaceOrderAction.OrderPlaced(orderId = "order-99"))
.assertNavigationEffect { nav ->
assert(nav.navigations.last() == OrderDetailsRoute(orderId = "order-99"))
}
}
@Test
fun `LoginSuccess navigates to Home with single-top and popUpTo`() {
authReducer.given(AuthState(), AuthAction.LoginSuccess)
.assertNavigationEffect { nav ->
val (route, options) = nav.navigationsWithOptions.last()
assert(route == HomeRoute)
assert(options.launchSingleTop)
assert(options.popUpTo?.route == LoginRoute)
assert(options.popUpTo?.inclusive == true)
}
}
@Test
fun `GoBack calls navigateUp once`() {
cartReducer.given(CartState(), CartAction.GoBack)
.assertNavigationEffect { nav ->
assert(nav.navigatedUpCount == 1)
}
}TestNavigator properties:
| Property | Type | Description |
|---|---|---|
navigations |
List<Any> |
Every route passed to navigate(), in call order |
navigationsWithOptions |
List<Pair<Any, NavOptions>> |
Routes paired with their NavOptions, for options assertions |
navigatedUpCount |
Int |
Number of navigateUp() calls |
poppedBackStackCount |
Int |
Number of popBackStack() calls |
poppedBackStackToRoutes |
List<Triple<Any, Boolean, Boolean>> |
Arguments of each popBackStackTo() call |
komposed/ Core library — Store, Reducer, Effect, EffectHandler, Middleware, Lens, Navigator
komposed-testing/ Test helpers — depends only on :komposed
sample/
├── (app) Entry point · AppNavHost · Hilt setup
├── checkout/ Composed checkout screen · CheckoutState · CheckoutViewModel
│ ├── delivery/ DeliveryReducer · DeliveryEffect · DeliveryEffectHandlerImpl
│ ├── bill/ BillReducer · BillEffect · BillEffectHandlerImpl
│ └── placeOrder/ PlaceOrderReducer · PlaceOrderEffect · PlaceOrderEffectHandlerImpl · CheckoutParams
├── orderDetails/ Order confirmation screen
└── core/
├── navigation/ Route definitions · NavigatorImpl · Hilt binding
└── middleware/ loggingMiddleware · analyticsMiddleware
Each feature sub-module follows the same internal layering:
presentation/ Action sealed interface · State data class · Effect sealed interface · Reducer · Composable
domain/ Plain data models (no Android, no framework)
data/ Repository interface + impl · EffectHandlerImpl · Hilt @Binds module
MIT License
Copyright (c) 2024 Ahmed Atwa
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.