{
  "$type": "site.standard.document",
  "bskyPostRef": {
    "cid": "bafyreidlvul2q36kdgvpuuwy2i76cevgbawczi2aitenji3jmviqz4qdpu",
    "uri": "at://did:plc:25rdn5elo5izoxrmtis34zuk/app.bsky.feed.post/3mpdvyicer462"
  },
  "coverImage": {
    "$type": "blob",
    "ref": {
      "$link": "bafkreidfh7wj4ea74vwnfhifuyvoj5unbfhx3wk2k3xukz6ri2ccfnw6ru"
    },
    "mimeType": "image/webp",
    "size": 90398
  },
  "path": "/theaxcode/stable-screenshot-tests-and-flow-benchmarks-in-kmp-20pb",
  "publishedAt": "2026-06-28T11:22:20.000Z",
  "site": "https://dev.to",
  "tags": [
    "kotlin",
    "multiplatform",
    "testing",
    "ui",
    "previous post on architecture",
    "@Composable",
    "@Test"
  ],
  "textContent": "In the previous post on architecture, I argued that a good KMP suite is a _consequence of architecture_ : keep the core framework-free, depend on ports, and most of your behavior becomes pure `commonTest` that runs without a device. That covered the fast tiers. This post is about the two slow ones that everybody actually fights with — **screenshot tests** and **on-device benchmarks**.\n\nThey're the flakiest tiers in any mobile suite. A snapshot that passes on your laptop and fails in CI; a startup benchmark that swings 40% run to run. The usual reaction is to distrust the tools and stop gating on them. But the flakiness almost never comes from Paparazzi or Macrobenchmark — it comes from **non-determinism leaking into the thing under test**. And the same component separation that powers the WeatherConditions core is exactly what seals those leaks.\n\n##  Where the flakiness actually comes from\n\nA screenshot diff and a benchmark number are both functions of their inputs. If any input wobbles, the output wobbles:\n\nSource of wobble | Wrecks screenshots | Wrecks benchmarks\n---|---|---\nLive network / forecast data | different pixels each run | timing dominated by I/O jitter\nReal clock (`now()`, \"4pm\", \"in 2h\") | timestamps shift the layout | —\nRunning animations | mid-frame capture | frame timing noise\nAsync loading order | captured during a spinner | warm-up variance\nFont / locale / density differences | sub-pixel + reflow diffs | —\n\nEvery one of these is a _dependency the UI shouldn't own_. WeatherConditions already pushes all of them behind ports in `CoreLib` — `ForecastService`, `LocationService`, `Clock`. The trick is to let the test exploit that boundary.\n\n##  The separation that makes pixels deterministic\n\nThe rule: **the screen is a pure function of state.** A composable that takes a `UiState` and renders it — no `ViewModel` lookup, no `remember { fetch() }`, no clock read inside the body. The presenter (which _does_ call `PlayabilityCalculator` through the ports) lives one layer up.\n\n\n\n    // Stateless and total — same input always renders the same pixels.\n    @Composable\n    fun ScoreScreen(state: ScoreUiState, onRefresh: () -> Unit = {}) {\n        when (state) {\n            is ScoreUiState.Loading -> ScoreSkeleton()\n            is ScoreUiState.Error   -> ErrorPanel(state.message, onRefresh)\n            is ScoreUiState.Loaded  -> ScoreCard(state.score)   // ActivityScore + factors\n        }\n    }\n\n\nBecause `ScoreScreen` never reaches for data, a screenshot test just _hands it_ a frozen state — no network, no clock, no async:\n\n\n\n    private val frozenScore = ActivityScore(\n        value = 62,\n        factors = listOf(\n            ScoreFactor(\"wind\", delta = -18),\n            ScoreFactor(\"rain after 4pm\", delta = -12),\n            ScoreFactor(\"temperature\", delta = +8),\n        ),\n    )\n\n    @Test fun scoreScreen_loaded_dark() = paparazzi.snapshot {\n        ViewPointTheme(dark = true) {\n            ScoreScreen(ScoreUiState.Loaded(frozenScore))\n        }\n    }\n\n\nTwo more things to nail down so the render is bit-stable:\n\n  * **Pin the clock.** Anything that prints a time goes through the `Clock` port; the test injects `FixedClock(\"2026-06-27T16:00Z\")`. No port read = no drifting timestamps.\n  * **Kill animations.** Snapshot with the animation clock paused (Paparazzi/Roborazzi render a single settled frame), so you never catch a transition mid-flight.\n\n\n\nThat's the whole stability story: deterministic inputs in, identical pixels out. A golden now changes _only_ when the UI genuinely changes — which is the entire point of a golden.\n\n##  Screenshot-testing a flow, not just a widget\n\nThe same separation lets you snapshot an entire **key user flow** by enumerating its states instead of clicking through it. The score→rank journey has four states worth locking down — and because the screen is stateless, you can produce each one directly:\n\n\n\n    @Test fun rankFlow_states() {\n        snapshotState(\"loading\", RankUiState.Loading)\n        snapshotState(\"loaded\",  RankUiState.Loaded(rankedFixture))   // 3 locations, deterministic order\n        snapshotState(\"empty\",   RankUiState.Empty)\n        snapshotState(\"error\",   RankUiState.Error(\"No forecast\"))\n    }\n\n\nNo emulator drive, no waiting on a spinner, no \"is it loaded yet\" race. Each state of the flow is its own golden, so a regression tells you _exactly which state_ broke. (When you do want real interaction — scroll, click, recomposition — Roborazzi runs the same goldens through Robolectric on the JVM; Compose UI tests cover the Android side and simulator tests the iOS side, as in the testing post.)\n\nThe deterministic order in `rankedFixture` matters: `rank_locations` sorts by score, so the fixture must produce a stable tie-break. That's a domain property — and it's already covered by a fast `commonTest`, which is why the screenshot can trust it.\n\n##  Benchmarking the flow, not just cold start\n\nThe testing post measured cold `StartupTimingMetric`. Useful, but users don't feel \"startup\" — they feel _the flow_ : open the app, pick a location, watch the ranked list paint. To benchmark that meaningfully you have to remove the one thing that makes the number meaningless — the network.\n\nSeparated components make this clean: point the benchmark build's `ForecastService` at a **canned fixture adapter** so every run scores the identical data. Now Macrobenchmark measures _your_ compute and rendering, not Google Weather's latency that morning.\n\n\n\n    @Test fun rankFlow_frames() = benchmarkRule.measureRepeated(\n        packageName = \"com.abyxcz.viewpoint\",\n        metrics = listOf(FrameTimingMetric(), TraceSectionMetric(\"score_rank\")),\n        iterations = 10,\n        startupMode = StartupMode.WARM,\n    ) {\n        startActivityAndWait()\n        device.findObject(By.res(\"location-input\")).text = \"New York City\"\n        device.findObject(By.res(\"rank-button\")).click()\n        device.wait(Until.hasObject(By.res(\"rank-list\")), 5_000)\n    }\n\n\n\n    // In production code: a custom trace section scopes the work the benchmark cares about.\n    trace(\"score_rank\") {\n        val ranked = ActivityScorer(registry).rank(contexts, profile)  // CoreLib, shared\n        emit(RankUiState.Loaded(ranked))\n    }\n\n\n`FrameTimingMetric` catches jank in the list paint; `TraceSectionMetric(\"score_rank\")` isolates the scoring compute. With the network swapped out, run-to-run variance collapses from \"is this a real regression or just wifi?\" to a tight distribution where a true regression actually stands out. Bake the wins in with a Baseline Profile and you've got a flow benchmark you can gate on.\n\n##  The stability payoff\n\nTier | Before separation | After separation\n---|---|---\nScreenshot | flakes on data/time/animation; diffs ignored | golden changes only on real UI change\nFlow benchmark | swings with network; too noisy to gate | tight variance; regressions visible\nDiagnosis | \"something on that screen broke\" | \"the _error_ state broke\" / \"scoring got slower\"\n\nBoth tiers went from \"nice in theory, muted in practice\" to \"trustworthy enough to fail a PR\" — without a fancier tool. The leverage was architectural.\n\n##  Things to Keep in mind\n\n  * **Goldens need an owner.** Intentional UI changes mean re-recording images; review the diff, don't rubber-stamp it. Record on **one canonical environment** (CI) so font/density differences don't reintroduce the flakiness you just removed.\n  * **Fakes can drift.** The benchmark's canned `ForecastService` and the snapshot fixtures must stay shaped like the real contracts. Generate them from the same `commonTest` fixtures so there's one source of truth.\n  * **One discipline to hold:** the screen must stay a pure function of state. The day someone calls a `ViewModel` or reads the clock inside a composable, determinism leaks back in. An ArchUnit rule (\"UI package must not depend on ports/use-cases directly\") keeps that honest — same pattern the testing post used to protect the core.\n\n\n\n##  Slow and Stable Wins\n\nScreenshot tests and flow benchmarks aren't flaky because the tools are bad — they're flaky because non-deterministic dependencies are baked into what you're measuring. Separate the stateless UI from the shared core the way WeatherConditions already does, feed it frozen state and canned adapters through the existing ports, and both tiers become deterministic: pixels that only move when the design moves, timings that only move when the code gets slower. The architecture you built for portability turns out to be the same architecture that makes the slow tests trustworthy.",
  "title": "Stable Screenshot Tests and Flow Benchmarks in KMP"
}