The architecture in this course exists to be testable. Write fast unit tests for ViewModels and repositories with JUnit and fakes, test coroutines and Flows, and verify UI with Compose UI tests.
A healthy Android test suite is mostly fast unit tests (logic in ViewModels and repositories, run on the JVM with no device), fewer integration tests, and a small number of slow end-to-end UI tests on a device or emulator. The architecture you built — interfaces, injected dependencies, state holders — is precisely what makes the bulk of your code unit-testable without the Android framework.
/\ UI tests (Compose/Espresso, on device) — few, slow
/ \ verify screens end-to-end
/----\ integration tests — some (DAO against in-memory Room)
/ \
/--------\ UNIT tests (JVM, no device) — MANY, fast, cheap
ViewModels, repositories, mappers, use cases
Why the pyramid: unit tests are milliseconds; UI tests are seconds + flaky.
The MVVM + repository + DI structure makes most logic unit-testable WITHOUT
the Android framework. That's the payoff of the architecture.Because your ViewModel depends on a repository interface, you test it by passing a fake implementation — no network, no database, no mocking framework needed. You drive the ViewModel and assert on its state. This is fast and deterministic, and it is only possible because you inverted the dependency; testing proves the architecture was worth it.
// a hand-written fake — no network, no DB, fully controlled
class FakeUserRepository(private val users: List<User>) : UserRepository {
override suspend fun getUsers() = users
override suspend fun refresh() {}
}
class HomeViewModelTest {
@Test
fun `load populates state with users`() = runTest { // coroutine test scope
val vm = HomeViewModel(FakeUserRepository(listOf(User(1, "Ada"))))
vm.load()
assertEquals(1, vm.uiState.value.users.size) // assert on the state
assertEquals("Ada", vm.uiState.value.users.first().name)
}
}
// injecting an interface -> substitute a fake -> fast, deterministic tests.Asynchronous code needs deterministic tests. kotlinx-coroutines-test provides runTest and a test dispatcher that controls virtual time, so delays are skipped and coroutines run predictably. For Flows, libraries like Turbine let you assert on emitted values in order. This makes the reactive parts of your app — the exact parts hardest to test by hand — reliably testable.
class UserRepositoryTest {
@Test
fun `observeUsers emits updated list after refresh`() = runTest {
val repo = DefaultUserRepository(fakeApi, fakeDao)
// Turbine collects Flow emissions for assertions
repo.observeUsers().test {
assertEquals(emptyList(), awaitItem()) // initial
repo.refresh() // triggers a DB write
assertEquals(1, awaitItem().size) // Flow re-emits new data
cancelAndConsumeRemainingEvents()
}
}
}
// runTest controls virtual time (delays are instant). Turbine asserts on
// Flow emissions in order. The reactive layer becomes deterministically testable.For the few end-to-end checks, the Compose test rule lets you launch a composable, find nodes by text or semantics, perform actions like clicks and text input, and assert on what is displayed. These run on a device/emulator so they are slower, so you keep them focused on critical user flows. Espresso plays the same role for legacy View-based screens.
class LoginScreenTest {
@get:Rule val composeTestRule = createComposeRule()
@Test
fun `entering credentials enables the button`() {
composeTestRule.setContent { LoginScreen() }
// find by text / semantics, act, assert
composeTestRule.onNodeWithText("Email").performTextInput("ada@x.com")
composeTestRule.onNodeWithText("Password").performTextInput("secret")
composeTestRule.onNodeWithText("Log in").assertIsEnabled().performClick()
composeTestRule.onNodeWithText("Welcome").assertIsDisplayed()
}
}
// keep UI tests FEW + focused on critical flows (they're slow). Espresso does
// the same for legacy XML/View screens.