7 min read

What's Actually Worth Testing on Android

Coverage rewards testing the code that doesn't break. Two questions that decide whether a test earns its place, the six categories that consistently pass them, the five that don't, and why feedback time belongs in the value calculation.

AndroidTestingCode QualityEngineering Practice
HR

Hessam Rastegari

Senior Android Developer · 12 years shipping Android

TL;DR — Coverage measures execution, not verification, so optimising it produces tests of the code least likely to fail. Judge each test by two questions: could I break the code and keep this green? and if it fails, do I know what to do? What consistently passes: non-UI branching logic, DTO→domain mapping, Room migrations, the error quadrant (cache empty and network down), and a regression test for every bug you've fixed. What doesn't: verify(repo).load(), tests of framework behaviour, generated code, and anything where you stub a value and assert it came back. Feedback time is part of the value — a 40-second suite gets run, a 12-minute one gets outsourced to CI.

Why coverage points the wrong way

A coverage tool records which lines executed during the test run. That's all. It cannot distinguish a line that ran under an assertion from a line that ran while nothing was checked.

The consequence is structural, not moral: teams chasing a number write tests where tests are cheapest, and tests are cheapest for code with no dependencies, no branches, and no environment — data classes, getters, simple mappers with three fields. That's also the code least likely to contain a defect. You can reach 80% without touching the parsing edge case, the migration path, or the offline branch, and teams routinely do.

Coverage is a useful smell in one direction only: a module at 6% probably has untested logic worth looking at. As a target it is actively misleading.

Two questions that decide it

1. Could I break this code and keep the test green?

Mentally mutate the implementation — flip a boundary, drop a null check, remove a branch. If the test still passes, it asserts nothing. This is mutation testing done by hand, and doing it on ten tests will tell you more about your suite than any coverage report.

2. If this test fails, do I know what to do?

A good failure names the requirement that broke: "prices round half-up at two decimals" tells you exactly what changed. FeedViewModelTest > test load success tells you to open the test and reverse engineer the intent — and that's the test that eventually gets @Ignored during a deadline.

The six categories that consistently earn their place

1. Branching logic that isn't UI. Validation rules, retry and backoff, pagination arithmetic (off-by-one on the last page is a classic), price and date formatting.

Locale and time zones deserve their own paragraph, because they produce the bugs that survive review:

@Test fun `formats midnight correctly in a half-hour offset zone`() {
    val zone = ZoneId.of("Asia/Tehran")           // UTC+3:30
    val instant = Instant.parse("2026-11-06T20:30:00Z")
    assertEquals("00:00", formatter.time(instant, zone))
}

Your device is in one time zone with one locale. Your users are not, and Turkish İ, Arabic-Indic digits and half-hour offsets have all shipped real incidents.

2. DTO → domain mapping. The contract with the backend is where reality diverges from the spec. Every field the API team swore was non-null will one day be null.

Use real payloads as fixtures — copied from a production response, not hand-written to match your parser. A hand-written fixture encodes your assumptions twice and tests neither.

3. Room migrations. The highest-value integration test on Android, and the most skipped. The failure mode is a crash loop on launch for users with existing data — invisible to anyone who installs fresh, which is your entire QA process.

@Test fun migrate12To13() {
    helper.createDatabase(TEST_DB, 12).apply {
        execSQL("INSERT INTO article VALUES (1, 'title', NULL)")   // the awkward row
        close()
    }
    helper.runMigrationsAndValidate(TEST_DB, 13, true, MIGRATION_12_13)
}

Insert data that represents your worst existing rows, not the tidy case. Nulls where the new schema wants values are the whole point.

4. The error quadrant. Success is one path out of four or five. The bugs live in: cache empty and network failed; cached data present but stale; partial success in a batch; cancellation mid-write.

@Test fun `offline with no cache surfaces an offline error, not empty`() {
    api.failWith(IOException())
    cache.clear()
    assertEquals(FeedState.Offline, viewModel.state.value)   // not FeedState.Empty
}

"Empty" and "offline" rendering the same screen is one of the most common shipped UX bugs, and this test is four lines.

5. A regression test for every bug you fix. Nothing else in your suite has a proven failure mode. The bug happened once; the conditions that produced it still exist. Write the test before the fix, watch it fail, then fix.

6. Public contracts of shared modules. If :core:network is consumed by six feature teams, its behaviour under error conditions is an API. Test it like one.

The five that don't

Tests that assert the implementation.

// Fails on every refactor. Passes on every bug.
verify(repository).load()

// States a requirement
assertEquals(FeedState.Offline, viewModel.state.value)

verify on a collaborator couples the test to how, not what. Two exceptions where the call itself is the requirement: "we must not call the payments API twice" and "logout must clear the token store".

Tests of framework behaviour. That Room persists a row, that Retrofit parses JSON, that StateFlow emits. You're testing someone else's library, on their behalf, in your CI budget.

Generated and trivial code. Data class accessors, toString, Hilt modules, copy().

ViewModel tests that are one stub and one assertion. If the ViewModel only forwards a repository result into a state object, the test asserts that copy() works.

Anything scheduled for deletion. Tests on code being rewritten next sprint cost the same to write and get thrown away with it.

Feedback time is part of the value

Two suites that catch the same bug are not equally valuable.

  • Unit tests, JVM, no Android framework: hundreds in seconds. Run before every push.
  • Robolectric: seconds to a minute. Fine for a focused set.
  • Instrumented and UI tests: minutes, needs a device, flaky under load.

At about ten minutes, a suite stops being something engineers run and becomes something CI runs — which means the feedback arrives after context switching, which is where the cost actually lands. So the practical rule: the tests you rely on daily must run in the time you're willing to wait.

Which pushes logic out of Android-dependent classes on purpose. A pure function you can test on the JVM in 3ms is worth restructuring for, and that pressure improves the design as a side effect.

A concrete allocation

For a typical feature, the ratio I aim for:

  • ~70% JVM unit tests — mapping, logic, state transitions, error branches.
  • ~20% integration — Room with migrations, repository against MockWebServer, DI graph validation.
  • ~10% UI — the critical path only: sign-in, checkout, the one flow whose breakage is an incident.

Plus, outside the percentages: one regression test per production bug, forever.

That's not a rule to enforce with a linter. It's a shape — and if your suite is inverted, the symptom is familiar: a long, flaky pipeline that still lets formatting bugs reach production.

The rule

Stop asking "is this covered?" and start asking "what would this test have caught?" If the honest answer is "nothing" — if you can't name a plausible bug it prevents — you've written documentation with a maintenance cost and a runtime bill. Delete it, and go write the migration test instead.

Keep reading