> ## Documentation Index
> Fetch the complete documentation index at: https://docs.modaal.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# How to Build Testable Android Apps: What Duet Fixes in Place

> Make Android apps testable by keeping logic in pure reducers: nondeterminism lives behind effects, and recorded scenarios replay as the regression suite.

The reliable way to make an Android app testable is to keep every decision in code that takes no environment: a pure function from state and action to new state, with clocks, IDs, network and permissions pushed behind effect values a worker performs later. **Duet**, Modaal's cross-platform parity framework for native iOS and Android apps, fixes that architecture at project creation instead of leaving it as a per-team discipline — and then records each feature's behavior as fixture files that CI replays on every push. This page states what stays standard Android, what the scaffold fixes in place, what that costs, and the measured results from a shipped app.

## What stays standard Android?

The toolchain is unchanged: Kotlin, Jetpack Compose, Gradle, and a tree Android Studio opens and runs — nothing proprietary in it, no code generator you cannot leave. The architecture is the one Google's own [app architecture guide](https://developer.android.com/topic/architecture) recommends in general form — unidirectional data flow, state as data, a clear UI/logic boundary. What Duet changes is who enforces it: the shape is scaffolded and gated rather than described in a document, and the logic layer compiles to `commonMain` so the iPhone app in the same repository runs the identical code. [Inside a Duet Android app](/articles/duet-android-app-anatomy) walks the resulting tree file by file.

## What does a blank project leave open that Duet fixes?

A new Android project fixes almost nothing about structure: each screen's author chooses where logic sits, how state is held, and what a test can reach. Duet replaces those per-screen choices with three project-wide ones:

* **One feature shape.** Every feature is a serializable state, a sealed set of actions, and one pure [reducer](/articles/duet-glossary#reducer) — no hand-rolled ViewModel variants, no per-screen state idiom.
* **Effects as data.** A reducer never calls a repository, reads a clock, or generates an ID. It *returns* effect values; [workers](/articles/duet-glossary#worker) perform them and report back as actions. Anything nondeterministic round-trips through that seam, which is what makes behavior replayable.
* **Recordings as the test contract.** Each feature carries a [scenario](/articles/duet-glossary#scenario) — a `Given`/`When`/`Then` script that is compiled into fixture files. The fixtures are build products checked into the repository, and CI replays them byte-for-byte on every platform that implements the feature.

### The same behavior, hand-rolled and fixed

A typical hand-rolled ViewModel, and the failure modes a test has to fight:

```kotlin FavoritesViewModel.kt (a blank-project idiom) theme={null}
class FavoritesViewModel(
  private val repo: FavoritesRepository,
) : ViewModel() {
  private val _ui = MutableStateFlow(FavoritesUiState())
  val ui: StateFlow<FavoritesUiState> = _ui

  fun onFavoriteTapped(id: String) {
    viewModelScope.launch {
      try {
        repo.setFavorite(id, favoritedAt = System.currentTimeMillis())
        _ui.update { it.copy(favoriteIds = it.favoriteIds + id) }
      } catch (e: IOException) {
        _ui.update { it.copy(error = "Couldn't save") }
      }
    }
  }
}
```

Testing this class needs a fake repository, a test dispatcher, and control over a clock the code reads inline; the coroutine races the assertion, and the error branch fires only when the fake throws on cue. The same behavior as a Duet feature:

```kotlin FavoritesFeature.kt (commonMain) theme={null}
fun favoritesReducer(
  state: FavoritesState,
  action: FavoritesAction,
): Reduced<FavoritesState, FavoritesEffectPayload> =
  when (action) {
    is FavoritesAction.FavoriteTapped ->
      Reduced(
        state.copy(pending = state.pending + action.id),
        listOf(Effect.Run(FavoritesEffectPayload.SaveFavorite(action.id))))

    is FavoritesAction.SaveSucceeded ->
      Reduced(state.copy(
        pending = state.pending - action.id,
        favoriteIds = state.favoriteIds + action.id))

    is FavoritesAction.SaveFailed ->
      Reduced(state.copy(pending = state.pending - action.id, error = FavoritesError.SaveFailed))
  }
```

The timestamp is gone from the logic: if the saved-at time matters, the worker supplies it when it reports `SaveSucceeded`. And the test is no longer a class with fakes — it is a script over the reducer, recorded once and replayed forever:

```kotlin FavoritesScenarioTest.kt theme={null}
scenario<FavoritesState, FavoritesAction, FavoritesEffectPayload>(feature = "favorites") {
  given(FavoritesState())

  branch("save succeeds") {
    whenAction("favorite a memory", FavoritesAction.FavoriteTapped("m1"))
    thenEffects("exactly one save request") {
      it == effectsOf(Effect.Run(FavoritesEffectPayload.SaveFavorite("m1")))
    }
    whenAction("the worker reports success", FavoritesAction.SaveSucceeded("m1"))
    then("the favorite is on") { "m1" in it.favoriteIds }
  }

  branch("save fails") {
    whenAction("favorite a memory", FavoritesAction.FavoriteTapped("m1"))
    whenAction("the worker reports failure", FavoritesAction.SaveFailed("m1"))
    then("the tap can be retried") { it.pending.isEmpty() && it.error == FavoritesError.SaveFailed }
  }
}
```

Recording this scenario produces a fixture file; the JVM test lane replays it on every push, and when the iPhone app implements the same feature, the identical bytes gate that side too.

## How do the two setups compare?

<Frame>
  |                            | A blank Android project                        | A Duet project                                                |
  | -------------------------- | ---------------------------------------------- | ------------------------------------------------------------- |
  | Architecture               | Chosen per team, drifts per screen             | One shape, scaffolded: state + actions + reducer per feature  |
  | Where nondeterminism lives | Wherever it was called — clocks and IDs inline | Behind effects; values round-trip through workers as actions  |
  | The unit of test           | A ViewModel plus fakes and test dispatchers    | A scenario over a pure function; no fakes, no dispatchers     |
  | What CI checks             | The tests each author remembered to write      | The recorded corpus, replayed and byte-compared on every push |
  | A second platform          | A second codebase, kept in step by review      | The same fixtures gate the iPhone app's identical core        |
</Frame>

## What does it cost?

The fix has three costs; the [Duet overview](/articles/duet#what-duet-requires-of-you) carries the full list:

* **The architecture is fixed at creation.** A Duet project stays a Duet project; the conventions — the feature shape, the effect seam, the recording step — are not per-feature choices.
* **Views are still written per platform.** The testability applies to logic. Compose screens are ordinary Compose, tested the way the platform tests UI, and an Android-plus-iOS change was measured at roughly 40% more agent effort than the single-platform equivalent in Modaal's own runs.
* **The recording step is part of the loop.** Changing behavior means updating the scenario and re-recording, not just editing code until the app looks right. That is the point — but it is a step a blank project does not have.

## What happens when Android kills your app's process?

Android can kill an app's process while it is in the background and recreate the activity from a saved `Bundle` — an asymmetry iOS developers meet on day one of an Android port, documented in [Google's saved-state guidance](https://developer.android.com/topic/libraries/architecture/saving-states). In this architecture the answer is uniform: state is serializable data, so the navigation spine is captured as a value in `onSaveInstanceState` and restored into the rebuilt tree — the [anatomy page](/articles/duet-android-app-anatomy#what-is-in-the-android-app-module) shows the dozen lines in the activity that do it. There is no per-screen inventory of what to save, because there is no per-screen state idiom.

## Why does this suit AI coding agents?

An agent building a feature needs machine-checkable gates between its steps, or errors compound silently across a session. The scaffold's loop supplies them in a fixed order: the feature spec, then the scenario, then the recording, then the reducer that must satisfy it, then the screens — with `tools/duet verify` as the same gate CI runs. An agent (or a person) cannot wire a screen to logic that has no passing recording, and a behavior regression fails the build on the commit that caused it rather than surfacing in QA. This staged order is how Modaal's own agent builds every Duet feature — [the Duet overview](/articles/duet#how-a-feature-gets-built) walks the six steps.

## What are the measured results?

From Memory Lane, the shipped dual-platform app this site's Duet pages quote, each number with its scope:

* **Warm test suite: 1–3 seconds** for the dual-platform behavior suite at four-feature scale during the field migration — fast enough to run on every edit.
* **Mutation drill: 10 out of 10.** Ten seeded logic mutations, each caught by fixture replay at the recording layer.
* **Recordings byte-identical across platforms: 6 of 6** in the adoption measurement — the Kotlin core replays the exact fixture bytes the Swift reducers recorded.
* **Sixteen Android screens, zero logic edits.** The Android app's product surfaces were built against a core whose recordings already passed; no fixture was re-recorded to make Android work.

## When should you not use Duet?

Two cases are outside its lane today. Games and drawn playfields: no Duet card yet scaffolds a SpriteKit scene host, and a physics loop is not reducer-shaped work — Modaal's **2D game / Interactive app** template covers that ground, and Duet support for graphics-rich apps is planned. And teams that want to assemble a bespoke stack — their own DI framework, their own test strategy, per-screen architectural freedom — are choosing exactly the degrees of freedom Duet removes; a blank Android Studio project serves that intent better.

## Common questions

<AccordionGroup>
  <Accordion title="Is this MVI?" icon="diagram-project">
    It is in the same family — unidirectional flow with state as data and actions as the only inputs. What a generic MVI setup does not fix is where nondeterminism lives or what proves behavior: Duet adds the effect seam as a hard rule and recorded fixtures as the contract, replayed on both platforms in CI.
  </Accordion>

  <Accordion title="How is this different from ViewModel tests with fakes?" icon="flask">
    A ViewModel test exercises code against fakes you maintain, and it lives on one platform. A scenario drives a pure reducer with no fakes at all, and recording it produces an artifact — the fixture — that both the Android and iPhone implementations must replay byte-for-byte. The test suite is also a cross-platform agreement check, not only a regression net.
  </Accordion>

  <Accordion title="Do I lose Android Studio, Compose previews, or Jetpack libraries?" icon="folder-open">
    No. The project is a standard Gradle build; Android Studio opens it, Compose screens are ordinary Compose, and Jetpack libraries live where they always did — in the app module's workers and screens. The constraint is only that feature logic stays in its module and reaches I/O through effects.
  </Accordion>

  <Accordion title="What happens to in-flight work when the system kills the process?" icon="arrows-rotate">
    The state that must survive is serializable and rides the saved-instance Bundle; effects in flight are re-requested by the restored state where the feature models it that way. The scenario can pin that behavior — restoration after a system kill is a branch you record, not a bug class you meet in production.
  </Accordion>

  <Accordion title="Can I use Duet for an Android-only app?" icon="android">
    Structurally yes — the Android app and the shared core work with no iOS target. The recording discipline still pays (fast pure tests, a mutation-resistant suite), but the cross-platform gate is Duet's distinctive return; for a product that will only ever ship one platform, weigh the conventions against a lighter setup.
  </Accordion>
</AccordionGroup>

<Note>
  [Modaal](https://modaal.dev) scaffolds Duet projects that start from this architecture instead of retrofitting it.
</Note>

## Sources and further reading

* [Guide to app architecture](https://developer.android.com/topic/architecture) — Google's recommendation: UI layer, data layer, unidirectional data flow
* [Save UI states](https://developer.android.com/topic/libraries/architecture/saving-states) — what must survive when the system recreates the activity or kills the process, and how
* [Test apps on Android](https://developer.android.com/training/testing) — the platform's testing fundamentals this page's approach narrows
* [Thinking in Compose](https://developer.android.com/develop/ui/compose/mental-model) — state-driven UI on the rendering side of the seam
* [The Duet framework on GitHub](https://github.com/modaal-agent/duet) — both flavors, the recording toolchain and the versioned contracts

## Read next

<CardGroup cols={2}>
  <Card title="Inside a Duet Android app" icon="folder-tree" href="/articles/duet-android-app-anatomy">
    The tree this page's rules produce: feature modules, the thin app module, workers and the fixtures directory.
  </Card>

  <Card title="Duet overview" icon="mobile-screen-button" href="/articles/duet">
    What Duet is, the wizard cards that scaffold it, and the six-step loop every feature follows.
  </Card>

  <Card title="Handling platform-specific UI" icon="layer-group" href="/articles/cross-platform-ui-parity">
    Where the shared/native line sits on the rendering side, and how deliberate divergence is recorded.
  </Card>

  <Card title="Duet glossary" icon="book" href="/articles/duet-glossary">
    Reducer, worker, scenario, fixture, the checks — every term defined with one link each.
  </Card>

  <Card title="Duet tutorials" icon="graduation-cap" href="/tutorials/duet">
    The rules on this page applied by hand: a pure reducer and its recordings in Tutorial 1, the worker harness in Tutorial 4, the mutation drill in Tutorial 6.
  </Card>
</CardGroup>
