Skip to content
All posts

Testing a local-first Android app in 2026: Room, Flow, and Compose UI tests that catch real bugs

A practical testing strategy for local-first Android apps in 2026 — in-memory Room DAO tests, Turbine for Flow, and the few Compose UI tests actually worth writing.

MFKAPPS 5 min read

When there’s no server, there’s no server-side team catching your bugs before they ship. Every mistake in a local-first Android app lands directly in a user’s database, on their device, with no ops dashboard to notice it first. That’s the argument I keep coming back to for why testing a local-first app deserves more care than testing a thin client that mostly renders API responses — the DB layer is the product, and I’m the only line of defense in front of it.

This is the testing setup I run across Granyn, Subly, and the rest — three layers, none of them exotic, all of them cheap enough that skipping them has no good excuse.

The three layers I actually test

Not everything needs the same rigor. I split tests by how expensive a bug at that layer would be:

  1. DAO tests — every query, against a real in-memory Room database. This is where money gets miscounted or a filter silently drops rows.
  2. Repository/Flow tests — the mapping and combination logic sitting between the DAO and the UI. This is where “it works in the DB but the screen shows stale data” bugs live.
  3. A handful of Compose UI tests — only for flows where a regression would be embarrassing, not for every screen.

Everything else — ViewModels that just delegate, DTO mappers with no branching — gets skipped. Testing code with no logic in it is busywork, not safety.

Layer 1: DAO tests against an in-memory Room database

Room ships with an in-memory database builder built for exactly this. No mocking, no fakes — the real SQL runs, just not persisted to disk:

@RunWith(AndroidJUnit4::class)
class SubscriptionDaoTest {
    private lateinit var db: AppDatabase
    private lateinit var dao: SubscriptionDao

    @Before
    fun setup() {
        db = Room.inMemoryDatabaseBuilder(
            ApplicationProvider.getApplicationContext(),
            AppDatabase::class.java,
        ).build()
        dao = db.subscriptionDao()
    }

    @After
    fun teardown() = db.close()

    @Test
    fun totalForCurrency_sumsOnlyMatchingRows() = runTest {
        dao.insert(subscription(amountMinor = 999, currency = "USD"))
        dao.insert(subscription(amountMinor = 500, currency = "EUR"))
        dao.insert(subscription(amountMinor = 1200, currency = "USD"))

        val total = dao.totalForCurrency("USD").first()

        assertEquals(2199L, total)
    }
}

This one test would have caught a real bug I shipped once: a SUM() query without a WHERE currency = clause, quietly adding euros to dollars. It passed code review because the SQL looked right. It failed the moment a test asserted on the actual number.

A few rules that keep this layer worth the time:

  • Test the query, not the framework. Don’t write a test asserting insert() followed by getById() returns the same row — that’s testing Room, not your app. Test the queries with real logic: sums, filters, date-range boundaries, sort order.
  • Boundary rows are where bugs hide. WHERE nextChargeDate <= :today — write the test with a subscription due exactly today, not just clearly-past and clearly-future ones.
  • Migrations get their own test, loading each historical schema from the exported JSON and running MigrationTestHelper forward. This is a different post’s worth of detail — see the local-first Android post for the exportSchema setup this depends on.

Layer 2: Flow-based repository tests with Turbine

The repository layer usually combines two or three Flows — DAO output plus a DataStore setting, say — and that combination logic is exactly the kind of thing that’s obvious when you write it and wrong six months later after a refactor. Turbine makes asserting on Flow emissions readable instead of a runBlocking mess of collectors:

@Test
fun observeDueToday_excludesSkippedDoses() = runTest {
    val repository = DoseRepository(fakeDao, fakeClock)

    repository.observeDueToday().test {
        assertEquals(emptyList(), awaitItem())

        fakeDao.insert(dose(status = PENDING, time = today9am))
        assertEquals(1, awaitItem().size)

        fakeDao.updateStatus(doseId, SKIPPED)
        assertEquals(0, awaitItem().size)

        cancelAndConsumeRemainingEvents()
    }
}

The value here isn’t testing Room again — the DAO is faked at this layer, since that’s already covered — it’s testing the sequence of states the UI will actually see. A Flow bug rarely shows up as “wrong data,” it shows up as “right data, one emission too late,” and that’s much harder to catch by reading the code than by watching the emissions go by in a test.

Layer 3: the few Compose UI tests worth writing

I don’t UI-test every screen — most of Compose UI testing on a small app is low-value churn against a UI that’ll be redesigned before the test pays for itself. I reserve it for flows where a silent regression is genuinely costly:

  • The notification-action path (mark taken / snooze / skip) — because a broken tap here means a missed dose, not a cosmetic glitch.
  • Onboarding’s permission-request step — because a regression here is invisible in manual testing (you, the developer, already granted every permission months ago) and fatal to a new user’s first five minutes.
@Test
fun markingDoseTaken_removesItFromDueList() {
    composeTestRule.setContent { DoseListScreen(state = dueDoseState) }

    composeTestRule.onNodeWithText("Metformin — 500mg").assertIsDisplayed()
    composeTestRule.onNodeWithContentDescription("Mark taken").performClick()
    composeTestRule.onNodeWithText("Metformin — 500mg").assertDoesNotExist()
}

That’s the whole budget for UI tests on a solo-maintained app: a handful of tests on the paths where “it silently stopped working” is the worst possible outcome, not a suite that tries to cover every pixel.

What I deliberately don’t test

Skipping these isn’t laziness, it’s where the return on time actually goes to zero:

  • Getters/setters and data classes with no logic. Nothing to break.
  • Compose previews and pure layout. A screenshot-diff tool catches this better than an assertion-based test ever will, and I haven’t found the ROI to add one at this scale yet.
  • Third-party library internals. Trust Room’s query execution; test your SQL, not SQLite.

The payoff

None of this is a big test suite — DAO tests for the queries with real logic, a dozen Turbine tests for the Flows that combine state, and a handful of Compose tests for the paths where silence is expensive. What it buys is specific: every time I touch a query or refactor a repository, the failure shows up as a red test in a few seconds, not as a support email three weeks later from someone whose subscription total was wrong by exactly the amount of one euro-denominated row.

For a one-person shop, that’s the actual point of testing — not proving the code is correct in some abstract sense, but making sure that when something breaks, you’re the one who finds out first.