← Back to main index | ← Back to folder
🧪 Testing Pyramid Strategy
flowchart TD
subgraph pyramid[" "]
E2E["🔺 E2E / UI Tests<br/>Espresso, Compose UI<br/>Slow · Few · Expensive"]
INT["🔷 Integration Tests<br/>Fake repos, in-memory DB<br/>Medium speed · Medium count"]
UNIT["🟩 Unit Tests<br/>JUnit5 + MockK + Turbine<br/>Fast · Many · Cheap"]
end
UNIT --> INT --> E2E
classDef level1 fill:#c8e6c9
classDef level2 fill:#90caf9
classDef level3 fill:#ffcc80
class UNIT level1
class INT level2
class E2E level3
Important
runTest with virtual clock auto-skips delays (no waiting). StandardTestDispatcher queues work. Use
Dispatchers.setMain() for Main dispatcher. Turbine for Flow testing.
runTest virtual time · StandardTestDispatcher queued · advanceUntilIdle control · Dispatchers.setMain · Turbine flow testing
💻 Code Example
@Test
fun loadUser_withDelay() = runTest { // Virtual clock active
val viewModel = UserViewModel(fakeRepo)
viewModel.load()
advanceUntilIdle() // Skip all delays instantly (virtual time)
assertEquals(UiState.Success(user), viewModel.state.value)
}| Tool | Purpose | When |
|---|---|---|
runTest |
Test builder; auto-skips delays | All coroutine tests |
StandardTestDispatcher |
Queues work; manual advanceUntilIdle() |
Tests needing delay control |
UnconfinedTestDispatcher |
Eager execution; immediate | Collecting without delay |
Dispatchers.setMain() |
Replace Main in @Before | Main dispatcher tests |
Turbine |
flow.test { awaitItem() } |
Flow/StateFlow testing |
🔩 Under the Hood
Normal delay (without runTest):
// Real time: Thread.sleep() blocks actual OS thread
delay(1000) // Blocks for 1 secondVirtual clock (inside runTest):
@Test
fun test() = runTest { // Virtual clock activated
val startTime = currentTime
delay(1000) // Virtual: no actual wait, clock advances 1000ms
advanceUntilIdle() // Skip to next delayed operation
val endTime = currentTime // Instant later (in virtual time)
// Test completes in milliseconds, not seconds!
}TestCoroutineScheduler internals:
class TestCoroutineScheduler {
private var virtualTime = 0L // Virtual clock
private val delayedTasks = PriorityQueue<Task>() // Delay queue
fun delay(ms: Long) {
val task = Task(virtualTime + ms, block)
delayedTasks.add(task)
// No actual sleep; task queued at future virtual time
}
fun advanceUntilIdle() {
while (delayedTasks.isNotEmpty()) {
val nextTask = delayedTasks.poll()
virtualTime = nextTask.time // Jump to task's scheduled time
nextTask.execute()
}
}
}StandardTestDispatcher (recommended):
@Test
fun test() = runTest { // Uses StandardTestDispatcher by default
val flow = flowOf(1, 2, 3)
flow.collect { value ->
// Collector runs queued, awaiting advanceUntilIdle()
}
advanceUntilIdle() // Now collector executes
}UnconfinedTestDispatcher (eager):
@Test
fun test() = runTest(UnconfinedTestDispatcher()) {
val flow = flowOf(1, 2, 3)
flow.collect { value ->
// Collector executes immediately (not queued)
println(value)
}
// No advanceUntilIdle() needed
}@Before
fun setupMainDispatcher() {
Dispatchers.setMain(StandardTestDispatcher())
}
@After
fun resetMainDispatcher() {
Dispatchers.resetMain() // Must reset to avoid leaking test state
}
@Test
fun test() = runTest {
val viewModel = ViewModelThatUsesMain() // Uses replaced Main dispatcher
viewModel.updateUI() // Safe: runs on virtual clock
}@Test
fun searchFlow_emitsResults() = runTest {
val searchFlow = searchViewModel.results
searchFlow.test {
searchViewModel.search("query")
// Collect emissions in sequence
assertEquals(Loading, awaitItem())
assertEquals(Success(results), awaitItem())
// Ensure no more emissions
expectNoEvents()
}
}- TestCoroutineScheduler — virtual clock implementation
- Dispatchers — can be replaced for testing
- Job lifecycle — cancellation works same in tests
- Turbine library — Flow testing DSL (kotlinx-coroutines-test dependency)
Problem (real delays in tests):
- Delaying 1000ms in test = 1000ms real wait (slow test suite)
- Flaky timeouts if CI slower than dev machine
Virtual clock solution:
- Skip delays in virtual time (tests run in milliseconds)
- Deterministic: same sequence always produces same result
- Testable: control time, test timeout scenarios
| A user knows | An understander also knows |
|---|---|
| "runTest auto-skips delays" | Virtual clock (TestCoroutineScheduler) jumps simulation time; no actual OS waits |
| "advanceUntilIdle() executes pending work" | Scheduler processes all queued tasks in virtual time order until idle |
| "StandardTestDispatcher queues work" | Tasks don't execute immediately; queued until advanceUntilIdle() called |
| "Must call resetMain() after test" | Dispatchers.setMain() global (affects all tests if not reset). Must restore to avoid test interference. |
- Multiple advanceUntilIdle() calls: Multiple calls execute queued work in batches. First call runs all work scheduled in initial time. Second call runs work scheduled by first batch, etc.
- Unconfined + blocking: UnconfinedTestDispatcher executes immediately but blocks if coroutine tries to switch dispatchers (unlike real Main which queues).
- Flow collection timing: If test doesn't await all items, collector left hanging. Turbine.expectNoEvents() validates nothing pending (or timeout).
- setMain + AndroidDispatchers conflict: AndroidDispatchers.Main (Jetpack) may not recognize test replacement. Explicitly use test dispatcher in ViewModel for testing.
Tip
Use createComposeRule() to test Compose UI on JVM. Find nodes by text/tag. Robolectric 4.16+ lets Compose
tests run without emulator. Verify with assertions; simulate interactions with performClick().
createComposeRule() · onNodeWithText() or onNodeWithTag() · Robolectric JVM rendering · assertIsDisplayed() · performClick()
| Tool | Purpose |
|---|---|
createComposeRule() |
Compose test environment |
onNodeWithText("X") |
Find by text |
onNodeWithTag("X") |
Find by Modifier.testTag() |
assertIsDisplayed() |
Verify visibility |
performClick() |
Simulate tap |
| Robolectric 4.16 | Compose on JVM — no emulator |
| Screenshot testing | com.android.compose.screenshot or Roborazzi |
💻 Code Example
@get:Rule
val composeRule = createComposeRule()
@Test
fun button_click_updates_text() {
composeRule.setContent { MyButton() }
composeRule.onNodeWithTag("my_button").performClick()
composeRule.onNodeWithText("Clicked!").assertIsDisplayed()
}🔩 Under the Hood
What createComposeRule() does:
@get:Rule
val composeRule = createComposeRule()
// Internally:
// 1. Creates Composition with test clock (same virtual time as coroutine tests)
// 2. Renders Compose tree into test FrameLayout (Robolectric)
// 3. Provides SemanticsTree for node selection
// 4. Binds Dispatchers.Main to test dispatcher (no real Main thread)Why JVM rendering works:
- Robolectric shadows Android framework (no emulator needed)
- Paints Compose UI to bitmap in memory
- Semantics tree extracted from composition (not from real Surface)
- Performance: 100x faster than emulator (no IPC, no GPU)
Under the hood:
// Test rendering flow:
composeRule.setContent { MyComposable() }
// → Composition created with TestCoroutineScheduler
// → Robolectric Shadow* classes intercept Canvas/Paint calls
// → Semantics collected from Recomposable
// → Node tree available for queriesFinder strategy:
// onNodeWithText() internally:
fun onNodeWithText(text: String): SemanticsNodeInteraction {
val matcher = { semantics: SemanticsNode ->
semantics.config.getOrNull(SemanticsProperties.Text)?.contains(text) == true
}
return onNode(matcher) // Linear scan of SemanticsTree
}
// onNodeWithTag() uses testTag modifier:
Modifier.testTag("my_button") // Adds to semantics.config[TestTag]
// Then finds by matcherPerformance consideration: Finding nodes is O(n) tree scan. Use specific matchers (tag > text > role).
Virtual time applies:
@Test
fun animation_completes() = runTest { // Shares scheduler with Compose
composeRule.setContent { AnimatedButton() }
composeRule.clock.advanceTimeByFrame() // Skips animation frames
composeRule.onNode(...).assertExists()
}- Semantics — accessibility tree used for node selection (same as TalkBack)
- Robolectric — Android framework shadowing
- TestCoroutineScheduler — virtual time for animations, delays
- Composition — internal Compose state management
Problem: Compose is declarative, not inspectable like View XML.
- View tests could read hierarchy via Espresso
- Compose tree exists only during recomposition (functional)
Solution: Semantics tree.
- Expose metadata (text, contentDescription, roles) without exposing internal state
- Stable across recompositions
- Works on JVM (Robolectric shadows it)
| A user knows | An understander also knows |
|---|---|
| "Find nodes by text/tag" | Semantics tree is O(n) linear scan; tag matching is O(1) in semantics.config |
| "createComposeRule() renders to screen" | Robolectric shadows Canvas; renders to bitmap in memory, no actual display |
| "Clock controls animations" | TestCoroutineScheduler advanced via composeRule.clock; animations calculated in virtual time |
| "performClick() simulates tap" | Injects SemanticsAction.OnClick into node; triggers lambda without real touch event |
- Node not found → silent wait:
onNode().performClick()waits (default 1s) if not found, then throws. No fast-fail. UsewaitUntilExists()if async rendering. - Text matching is substring:
onNodeWithText("Click")matches "Click Me" and "Clickable". UseuseUnmergedTree = trueto search unmerged nodes only. - Recomposition timing:
setContent()queues recomposition. CallcomposeRule.waitForIdle()before assertions if state updates are delayed. - Modifier.testTag persists: Don't use in production code. Use dev-only
BuildConfig.DEBUGcheck if accidentally added.
Tip
Test Double replaces real dependency. Fakes are real logic (in-memory DB). Mocks verify interactions (was X called?). Stubs return hardcoded values. Use Fakes by default (fast, deterministic). Mock only for verification or when faking impractical.
Fake (real impl, simplified) · Mock (verify interactions) · Stub (hardcoded returns)
| Test Double | Definition | When | Example |
|---|---|---|---|
| Fake | Real impl, simplified | Test logic/integration | In-memory Repository |
| Mock | Verifies interactions | Verify side effects | Verify analytics logged |
| Stub | Hardcoded return | One-off test data | every { repo.get() } returns user |
💻 Code Example
// Fake (preferred)
class FakeUserRepository : UserRepository {
override suspend fun getUser(id: Int) = User(id, "Test")
}
// Mock (verify call)
val repo = mockk<UserRepository>()
coVerify { repo.saveUser(any()) }
// Stub (minimal setup)
every { repo.getUser() } returns fakeUser🔩 Under the Hood
What it does:
// Real repository (Retrofit + Room):
class UserRepository(
val api: UserApi,
val db: UserDao
) {
suspend fun getUser(id: Int): User {
val cached = db.getUser(id) // Try cache
if (cached != null) return cached
val user = api.fetchUser(id) // Network call
db.insertUser(user) // Save to DB
return user
}
}
// Fake repository (in-memory):
class FakeUserRepository : UserRepository {
private val cache = mutableMapOf<Int, User>()
override suspend fun getUser(id: Int): User {
val user = cache[id] ?: throw NotFoundException()
return user
}
// Test-only: manually populate
fun addUser(user: User) { cache[user.id] = user }
}
// Test:
val repo = FakeUserRepository()
repo.addUser(User(1, "Alice"))
val user = repo.getUser(1) // ✅ Returns immediately
// No actual DB I/O or networkWhy Fakes work:
- Implement the same interface (UserRepository)
- Provide predictable behavior (no network variability)
- Keep test logic readable (no framework boilerplate)
- Fast (in-memory operations ~1μs vs API call ~100ms)
MockK internals:
val repo = mockk<UserRepository>() // Generates proxy at runtime
// MockK creates:
// 1. JVM Proxy implements UserRepository
// 2. Every method call logged to call history
// 3. Returns default values (null, 0, empty collections)
repo.saveUser(user) // Logged: [Call(saveUser, [User(...)])]
coVerify { repo.saveUser(any()) } // Scans history
// → Finds matching call
// → Passes testWhy MockK uses reflection/bytecode generation:
// Without framework (manual mock):
class ManualMockRepository : UserRepository {
var saveUserCalls = mutableListOf<User>()
override suspend fun saveUser(user: User) {
saveUserCalls.add(user)
}
}
// Tedious: manual tracking for every method
// MockK does it automatically via proxyWhen to use Mocks:
// Use case: Verify analytics logged
val analytics = mockk<Analytics>()
viewModel.trackEvent("button_clicked")
verify { analytics.log(Event("button_clicked")) }
// Why not Fake here?
// - Analytics is fire-and-forget (no return value to test)
// - We care that it WAS CALLED, not what it does
// - Faking it (tracking in-memory list) is reinventing mockWhen to use Stubs:
@Test
fun test() {
val userApi = mockk<UserApi>()
// Stub: hardcoded return for specific call
every { userApi.getUser(1) } returns User(1, "Alice")
every { userApi.getUser(2) } returns User(2, "Bob")
// Any other ID returns null (MockK default)
val result = userApi.getUser(999) // null
}Stubs in production code (not tests):
// When actual implementation not yet written
class UserApi {
fun getUser(id: Int): User = stub() // TODO: implement
}
// Compilation error: forces implementationHierarchy (best to worst):
-
Fake — Full logic, fast, deterministic
val repo = FakeUserRepository() // Use this
-
Stub (within Mock) — Minimal setup, OK for simple cases
val repo = mockk<UserRepository>() every { repo.getUser(1) } returns user // OK
-
Mock (verification) — Use only for side effects
val analytics = mockk<Analytics>() viewModel.track() verify { analytics.log(...) } // OK only here
Why this order:
- Fakes test actual logic (closest to prod)
- Stubs test with minimal setup
- Mocks test behavior, not logic (loosest coupling)
// Real: Database with Room, constraints, triggers
val roomDb: MyDatabase = mockk() // Too complex to fake
// Solution: Stub the queries
every { roomDb.userDao().getUser(1) } returns User(1, "Test")
// Alternative: In-memory Room database
@get:Rule
val inMemoryDb = room.inMemoryDatabaseBuilder(
MyDatabase::class.java
).build() // Real Room, real DB engine, in RAM
@Test
fun test() {
inMemoryDb.userDao().insertUser(user)
val result = inMemoryDb.userDao().getUser(user.id)
assertEquals(user, result) // Tests actual SQL
}- Interfaces — Fakes/Mocks must implement same contract
- Proxy pattern — MockK uses JVM Proxy or bytecode generation
- Reflection — MockK inspects method signatures at runtime
- Dependency Injection — Fakes injected same way as real impl
Problem: How to test without side effects?
- Real database: slow, shared state between tests
- Real API: flaky network, external dependencies
- Real file system: leftover test files, cleanup complexity
Solution: Test Doubles.
- Fake: Trade accuracy for speed (tests logic, not I/O)
- Mock: Trade completeness for verification (tests contracts, not impl)
| A user knows | An understander also knows |
|---|---|
| "Fakes are simple implementations" | Fakes implement full interface; test tests actual logic (if() branches, loops, null checks) |
| "Mocks verify that a function was called" | MockK uses Proxy or CGLIB to intercept calls; maintains call history; verify() scans history |
| "Stubs return hardcoded values" | Stubs configured via every {...} returns ... DSL; any unconfigured method returns MockK default (null, 0, empty) |
| "Use Fakes by default" | Fakes are closest to production (test real logic); slower to set up (write Fake class) but faster to run (in-memory) |
- Fake gets out of sync: Real impl changes, Fake not updated. Tests pass, prod fails. Use golden tests or contract tests to catch.
- Mock verification order:
verify(ordering = Ordering.SEQUENCE)checks call order. Unordered by default—can pass even if calls happen in different order than expected. - every {...} returns runs immediately:
every { expensive() } returns valuedoesn't delay. If you need lazy computation, useevery { expensive() } answers { computeValue() }. - Stub + Mock mix: Using both (stub return + verify call) couples test to impl. Better: Fake (checks logic) + Mock verification (only for logging/analytics).
💻 Code Example
class FakeUserRepository : UserRepository {
private var shouldFail = false
private var failureMessage = ""
fun setShouldFail(msg: String) { shouldFail = true; failureMessage = msg }
fun reset() { shouldFail = false }
override suspend fun getUser(id: Int): User {
if (shouldFail) throw Exception(failureMessage)
return User(id, "Test User")
}
}
@Test
fun loadUser_onSuccess() = runTest {
val repo = FakeUserRepository()
val viewModel = UserViewModel(repo)
viewModel.load()
advanceUntilIdle() // Let coroutine finish
assertEquals(UiState.Success(User(1, "Test User")), viewModel.state.value)
}
@Test
fun loadUser_onError() = runTest {
val repo = FakeUserRepository()
repo.setShouldFail("Network error")
val viewModel = UserViewModel(repo)
viewModel.load()
advanceUntilIdle()
assertEquals(UiState.Error("Network error"), viewModel.state.value)
}Say: "I use Fakes for unit tests (state/integration), Mocks for interaction verification (logging, tracking). Fakes are default—simpler, faster, KMP-compatible."