Zygote
App architectureintermediate

Testing

The test pyramid on Android is decided by one constraint the other platforms do not have: the device is slow and the framework is not mockable. Architecture exists, in part, to move tests up the pyramid.

11 minAPI 21–37 · 17interactivequizverified 2026-10-27

A JVM test runs in milliseconds. A device test takes seconds to install, launch and synchronise.1 Everything about Android's recommended architecture — ViewModels, repositories, interfaces — exists partly to move as much verification as possible into the fast column.

TL;DR#

  • Unit tests (JVM) are fast and cover ViewModels, repositories and mappers. They are the bulk of a healthy suite.1
  • runs Android framework code on the JVM, for the cases where a fake is not enough.
  • Instrumented tests (androidTest) run on a device or emulator and cover integration: database, navigation, UI.
  • Compose UI tests assert on semantics, and are the cheapest way to test a screen's states.
  • covers performance; baseline profiles make the measured build representative.

Mental model#

A health check-up with three tiers.

A blood test at home (unit test) is cheap, fast and catches most problems. A visit to a clinic (Robolectric) is slower but covers things the home test cannot. A full scan (instrumented) is expensive and reserved for what only it can see.

Where the analogy breaks#

The tiers are not ordered by importance; they are ordered by cost. A screen that only an instrumented test can verify is a screen whose architecture is hiding something — usually that the logic lives in the view.

The big picture#

Loading diagram…
Text description of this diagram

The three-layer architecture annotated with the test type that covers each layer. The data layer is covered by JVM unit tests with fake data sources, plus instrumented tests for Room and migrations. The domain layer is covered entirely by JVM unit tests. The UI layer's ViewModel is covered by JVM tests with a test dispatcher; the composables are covered by Compose UI tests; and the full screen is covered by a small number of instrumented end-to-end tests. The diagram shows most tests concentrated at the bottom of the cost pyramid.

Deep dive#

Unit tests: the bulk#

class NotesViewModelTest {
 
    private val repository = FakeNotesRepository()
    private val dispatcher = UnconfinedTestDispatcher()
    private val viewModel = NotesViewModel(repository, dispatcher)
 
    @Test
    fun `empty repository produces empty state`() = runTest {
        viewModel.state.test {
            assertEquals(NotesUiState(), awaitItem())
        }
    }
 
    @Test
    fun `refresh failure surfaces an error`() = runTest {
        repository.failWith = IOException("offline")
 
        viewModel.refresh()
        advanceUntilIdle()
 
        assertEquals("offline", viewModel.state.value.error)
    }
}

Four things make that test possible, and each is an architectural decision:

  1. NotesRepository is an interface, so a fake can implement it.
  2. The dispatcher is a constructor parameter, so the test can use a test dispatcher.
  3. The state is a value, so assertions are assertEquals.
  4. The ViewModel has no Context, so no Robolectric is needed.

runTest and advanceUntilIdle come from kotlinx-coroutines-test.1 UnconfinedTestDispatcher runs coroutines eagerly, which removes the need for advanceUntilIdle in most cases and makes tests deterministic.

Fakes versus mocks#

class FakeNotesRepository : NotesRepository {
    private val notes = MutableStateFlow<List<Note>>(emptyList())
    var failWith: Throwable? = null
 
    override fun observe(query: String): Flow<List<Note>> =
        notes.map { list -> list.filter { it.title.contains(query, ignoreCase = true) } }
 
    override suspend fun refresh(): Result<Unit> {
        failWith?.let { return Result.failure(it) }
        return Result.success(Unit)
    }
 
    fun emit(vararg items: Note) { notes.value = items.toList() }
}

A hand-written fake is usually better than a mocking framework on Android:

  • It has behaviour, not just recorded calls, so a test asserting "state becomes Ready" works.
  • It compiles against the interface, so an interface change breaks the fake immediately.
  • It is reusable across tests, which is how a test suite stays readable.

Mocking frameworks are still useful for verifying an interaction ("was analytics.track called with these parameters?"). Use them for that, not for state.

Robolectric: the middle tier#

When the code genuinely needs framework classes — a Context, a Resources, a Looper — Robolectric runs a simulated Android runtime on the JVM.

@RunWith(RobolectricTestRunner::class)
class NotesFormatterTest {
 
    @Test
    fun `formats a relative timestamp`() {
        val context = ApplicationProvider.getApplicationContext<Context>()
        val formatter = NotesFormatter(context)
 
        val label = formatter.relative(Instant.now().minus(2, ChronoUnit.HOURS))
 
        assertEquals("2 hours ago", label)
    }
}

Robolectric is slower than a plain unit test (hundreds of milliseconds versus single-digit milliseconds) but far faster than an emulator. It is the right tool for resource lookups, Context wrappers, and framework behaviour that a fake cannot reproduce.

Compose UI tests#

Compose UI tests do not need a device if you use Robolectric, and they assert on semantics rather than on pixels:1

class NotesScreenTest {
 
    @get:Rule val rule = createComposeRule()
 
    @Test
    fun emptyState_showsMessage() {
        rule.setContent {
            NotesScreen(state = NotesUiState(), onQueryChange = {}, onDelete = {})
        }
 
        rule.onNodeWithText("No notes yet").assertIsDisplayed()
    }
 
    @Test
    fun deleteButton_invokesCallback() {
        var deleted: Note? = null
        val note = Note("1", "Shopping", Instant.EPOCH)
 
        rule.setContent {
            NotesScreen(
                state = NotesUiState(notes = persistentListOf(note)),
                onQueryChange = {},
                onDelete = { deleted = it },
            )
        }
 
        rule.onNodeWithContentDescription("Delete note").performClick()
 
        assertEquals(note, deleted)
    }
}

The second test is the payoff for a stateless screen: no ViewModel, no repository, no device — just a callback assertion. That is what "hoist state" buys you in test terms.

Instrumented tests: the small top#

Some things genuinely require a device:

  • Room schema and migrations.
  • Navigation graph behaviour.
  • Cross-process interactions.
  • Anything that depends on the real Looper and real timing.
@RunWith(AndroidJUnit4::class)
class NotesDaoTest {
 
    private lateinit var db: NotesDatabase
    private lateinit var dao: NotesDao
 
    @Before fun setUp() {
        db = Room.inMemoryDatabaseBuilder(
            ApplicationProvider.getApplicationContext(),
            NotesDatabase::class.java,
        ).build()
        dao = db.notesDao()
    }
 
    @After fun tearDown() = db.close()
 
    @Test fun upsert_replacesExistingRow() = runTest {
        dao.upsertAll(listOf(NoteEntity("1", "A", 0)))
        dao.upsertAll(listOf(NoteEntity("1", "B", 1)))
 
        assertEquals("B", dao.observeMatching("").first().single().title)
    }
}

An in-memory database is fast and isolated; it is still an instrumented test because Room's generated code and SQLite are real.

Testing migrations#

A migration test is the highest-value instrumented test in most apps, because a broken migration is a data-loss bug that no unit test catches.

@RunWith(AndroidJUnit4::class)
class MigrationTest {
    @get:Rule
    val helper = MigrationTestHelper(
        InstrumentationRegistry.getInstrumentation(),
        NotesDatabase::class.java,
    )
 
    @Test
    fun migrate1To2_preservesRows() {
        helper.createDatabase(TEST_DB, 1).apply {
            execSQL("INSERT INTO notes (id, title) VALUES ('1', 'A')")
            close()
        }
 
        val db = helper.runMigrationsAndValidate(TEST_DB, 2, true, MIGRATION_1_2)
 
        db.query("SELECT title FROM notes").use { cursor ->
            assertTrue(cursor.moveToFirst())
            assertEquals("A", cursor.getString(0))
        }
    }
}

Performance tests#

Macrobenchmark measures what users feel: startup and frame timing, on a release build.2

@get:Rule val benchmarkRule = MacrobenchmarkRule()
 
@Test
fun scroll_isNotJanky() = benchmarkRule.measureRepeated(
    packageName = "com.example.notes",
    metrics = listOf(FrameTimingMetric()),
    iterations = 5,
    startupMode = StartupMode.WARM,
) {
    startActivityAndWait()
    device.findObject(By.res("notes-list")).fling(Direction.DOWN)
}

The result is a FrameTimingMetric with percentiles. Comparing two runs of the same test with a baseline profile installed versus not is the standard way to prove a performance change.

Test doubles for the framework#

Some framework classes are hard to fake because they are final or because they are constructed by the system. Three strategies, in order of preference:

Wrap them in an interface you own. A Clock, a DispatcherProvider, a FileStore. The wrapper is trivial and the fake is trivial.

Use a library fake. TestNavHostController for navigation, TestDriver for WorkManager, an in-memory Room database for persistence. These are the real implementation with the device removed.

Use Robolectric. When the code genuinely needs a Context, a Resources or a Looper, Robolectric provides a simulated runtime. It is the last resort because it adds seconds to the suite.

The ordering is not arbitrary. Each step down costs more per test, and the cost multiplies across a suite. A team that reaches for Robolectric first usually has a codebase that constructs its dependencies internally.

Keeping the suite fast#

A test suite that takes twenty minutes stops being run. Three practices keep it in the seconds:

  • One assertion per behaviour, not one test per method. A test that checks four things fails with an ambiguous message.
  • No sleeps. advanceUntilIdle() and advanceTimeBy() are deterministic; Thread.sleep(1000) is not.
  • Parallel modules. Gradle runs independent modules' tests in parallel; a monolith cannot.
// Deterministic and instant, even though it tests a 500 ms debounce.
@Test
fun `debounce collapses rapid input`() = runTest {
    val viewModel = SearchViewModel(repository, testDispatcher)
    viewModel.onQueryChange("a")
    viewModel.onQueryChange("ab")
    advanceTimeBy(300)
    assertEquals(1, repository.calls)
}

What to test, and what not to#

Test:

  • State transitions. Given this input, this state.
  • Mapping. DTO → entity → domain, including nulls and malformed dates.
  • Migrations. Data survives version changes.
  • Business rules. The logic that would be expensive to get wrong.
  • Critical user paths. One or two end-to-end tests, not fifty.

Do not test:

  • Framework behaviour. That TextView renders text is Google's test, not yours.
  • Generated code. Room's DAO implementation, Hilt's graph, Compose's compiler output.
  • Trivial getters. They add suite runtime and catch nothing.
  • Implementation details. A test that asserts a private method was called will break on every refactor and catch no bugs.

Flakiness is a design signal#

A test that fails intermittently is usually reporting a real property of the code: an un-injected clock, a shared mutable singleton, a dispatcher that was not substituted, or an ordering assumption.

The wrong fix is a retry. The right fix is to find which of those four it is — and each of them is also a production bug waiting to happen. A test that depends on wall-clock time will behave differently on a device whose clock has drifted; a test that depends on a singleton will behave differently when the singleton is initialised in a different order.

That is why "the test is flaky" is worth taking seriously rather than quarantining. It is the cheapest available detector for a category of bug that is otherwise invisible until a user reports it.

A suite that ships#

The practical target is not a coverage number; it is a suite that runs in under a minute and that the team trusts enough to run before every commit. Three properties get you there:

  • Fast by default. JVM tests for everything that does not need a device, and the device tests kept few and deliberate.
  • Deterministic. No sleeps, no real dispatchers, no wall-clock time.
  • Diagnostic. A failure names the behaviour that broke, not the line that changed.

A suite with those three properties gets run. A suite without them gets skipped, and a skipped suite has the same value as no suite at all.

Coverage as a diagnostic, not a target#

Line coverage tells you what ran, not what was verified. A suite with 90% coverage that asserts nothing is worse than 40% coverage with real assertions, because it creates false confidence.

Use coverage to find untested branches in complex code — a migration with no test, an error path that never runs. Do not use it as a gate that incentivises assertion-free tests.

How it evolved#

Architecture changes29 releases
APIVersionWhat changed
11.0
  • Initial AOSP platform: Linux kernel, Dalvik VM, core libraries
  • The first zygote-based app spawning model
41.6 Donut
  • Support for different screen densities via resource qualifiers
52.0 Eclair
  • First multi-core-friendly scheduling and graphics work
82.2 Froyo
  • Trace-based JIT compiler added to Dalvik
  • Support for x86 and ARMv7 optimisations
92.3 Gingerbread
  • Concurrent garbage collector added to Dalvik (GC pause reduction)
113.0 Honeycomb
  • Hardware acceleration for the View system (opt-in per app)
133.2 Honeycomb
  • New screen-size resource qualifiers for compatibility scaling
144.0 Ice Cream Sandwich
  • One system image for phones and tablets
  • Hardware acceleration on by default
164.1 Jelly Bean
  • Project Butter: vsync, triple buffering, Choreographer
  • RenderThread architecture begins
174.2 Jelly Bean
  • Multi-user support on tablets
  • SELinux enforcement begins in the kernel (behind the scenes)
184.3 Jelly Bean
  • SELinux enforcement rolled out more broadly
  • Binder changes for restricted profiles
194.4 KitKat
  • Android Runtime (ART) appears as an experimental runtime
  • Kernel moved under a tighter vendor control
204.4W KitKat (Wear)
  • First time the platform shipped for a new form factor
215.0 Lollipop
  • ART becomes the only runtime: ahead-of-time compilation at install
  • 64-bit ARM and x86 support
  • RenderThread and OpenGL ES 3.1
  • Project Volta and JobScheduler
  • SELinux fully enforcing
225.1 Lollipop
  • Device protection ties verified boot to Google accounts
236.0 Marshmallow
  • Runtime permission model changes how every app asks for access
  • Doze batches background work
  • Fingerprint HAL
  • Adoptable storage
247.0 Nougat
  • Project Treble groundwork
  • A/B seamless updates
  • Hybrid JIT + AOT compilation in ART
  • MediaServer split into sandboxed processes
268.0 Oreo
  • Project Treble: the vendor interface separates vendor and system code
  • Background execution limits
  • Broadcast and service restrictions
  • HIDL for HALs
278.1 Oreo
  • Android Go: a lighter configuration of the same platform
289 Pie
  • App Standby Buckets
  • Mainline groundwork
  • Non-SDK interface restrictions (greylists) begin
  • BiometricPrompt
2910
  • Project Mainline: APEX and updatable system modules
  • Scoped storage
  • Bubbles
  • Project Mainline starts shipping through Play
3011
  • GKI: the Generic Kernel Image splits the kernel into core + vendor modules
  • App hibernation
  • Package visibility filtering
  • Incremental app installs
3112
  • Bluetooth permissions split into the nearby-devices family
  • Exact alarm permissions
  • Foreground service launch restrictions
3212L
  • Window size classes and activity embedding
3313 Tiramisu
  • Granular media permissions replace READ_EXTERNAL_STORAGE
  • The notification permission becomes a runtime permission
  • Predictive back gesture
3414 Upside Down Cake
  • Foreground service types are mandatory
  • Selected photos access via READ_MEDIA_VISUAL_USER_SELECTED
3515 Vanilla Ice Cream
  • 16 KB page size support for native libraries
  • Edge-to-edge is enforced when targeting 35
3616 Baklava
  • The release moves to Q2 (June) with a Q4 feature drop
  • Two SDK releases per year starting with 36
  • Desktop windowing arrives in developer options then stable
3717 Cinnamon Bun
  • Strict per-device app memory limits are enforced by the system
  • Generational garbage collection is added to ART's Concurrent Mark-Compact collector
  • A lock-free MessageQueue for apps targeting API 37
  • Compose-first: new platform and Jetpack APIs target Compose only; View libraries enter maintenance mode
  • Hybrid APK signing (v3.2) combines classical and ML-DSA post-quantum signatures
  • Native library dynamic code loading must be read-only

The tooling moved in one direction: make tests faster and more representative. Robolectric made framework code testable on the JVM, Compose made UI testable without a device, Macrobenchmark made performance measurable in CI, and baseline profiles made the measured build match what users run.

Under the hood#

Curiosity corner#

  • Espresso's idling resources exist because the main thread's queue can be non-empty when a test asserts. The modern equivalent in Compose is the test clock, which is deterministic by construction.
  • runTest uses a virtual clock. A test that waits an hour takes microseconds, which is why testing a debounce is cheap.
  • A migration test is the only test that catches data loss, because it exercises the real SQLite upgrade path.

Try it#

Run only JVM tests

./gradlew :app:testDebugUnitTest

Run instrumented tests on a connected device

./gradlew :app:connectedDebugAndroidTest

Check yourself#

Check yourself

1 / 12

Why model a screen as one immutable `UiState` rather than several independent `StateFlow`s?

0 answered · 0 correct

Sources#

Sources

Every version number, API level, class name and date on this page is traced to one of these. Pages are re-verified against them; last_verified above says when.

  1. docsבדיקת אפליקציות ב-Android | Test your app on Androiddeveloper.android.comaccessed 2026-10-27↩
  2. docsMacrobenchmark overviewdeveloper.android.comaccessed 2026-10-27↩
  3. docsJetpackdeveloper.android.comaccessed 2026-10-27↩
  4. docsBaseline Profiles overviewdeveloper.android.comaccessed 2026-10-27↩
  5. docsGuide to app architecturedeveloper.android.comaccessed 2026-10-27↩
  6. docsThinking in Composedeveloper.android.comaccessed 2026-10-27↩