Android Testing Essentials
October 17, 2025 · 8 min read
Android, Kotlin, Testing, MockK, Compose Testing, Interview Prep
Part 9 of the Android Deep Dive series
Robust testing ensures app reliability and maintainability. In 2025-2026 interviews, testing questions have evolved beyond basic JUnit knowledge - expect questions about testing coroutines, Flows, and Compose UI. Companies increasingly use testing proficiency as a signal for senior-level candidates.
2025-2026 Trend
Turbine has become the standard for Flow testing. Interviewers expect you to know flow.test { } patterns and how to handle StateFlow emissions properly.
Unit Testing Setup
// build.gradle.kts (app)
dependencies {
testImplementation("junit:junit:4.13.2")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.0")
testImplementation("io.mockk:mockk:1.13.9")
testImplementation("app.cash.turbine:turbine:1.0.0")
testImplementation("com.google.truth:truth:1.4.2")
androidTestImplementation("androidx.compose.ui:ui-test-junit4")
androidTestImplementation("io.mockk:mockk-android:1.13.9")
androidTestImplementation("com.google.dagger:hilt-android-testing:2.51")
}
Testing ViewModels
@OptIn(ExperimentalCoroutinesApi::class)
class MealsViewModelTest {
// Replace Main dispatcher with test dispatcher
@get:Rule
val mainDispatcherRule = MainDispatcherRule()
private lateinit var viewModel: MealsViewModel
private val mealRepository: MealRepository = mockk()
private val errorHandler: ErrorHandler = mockk()
@Before
fun setup() {
// Default mock behavior
coEvery { mealRepository.observeMeals(any(), any()) } returns flowOf(emptyList())
every { errorHandler.getErrorMessage(any()) } returns "Error"
viewModel = MealsViewModel(mealRepository, errorHandler)
}
@Test
fun `loadMeals updates state with meals`() = runTest {
// Given
val meals = listOf(
Meal(id = "1", name = "Breakfast", calories = 400.0),
Meal(id = "2", name = "Lunch", calories = 600.0)
)
coEvery { mealRepository.observeMeals(any(), any()) } returns flowOf(meals)
// When
viewModel.loadMeals(LocalDate.now())
// Then
val state = viewModel.uiState.value
assertThat(state.meals).hasSize(2)
assertThat(state.isLoading).isFalse()
}
@Test
fun `loadMeals shows error on failure`() = runTest {
// Given
coEvery { mealRepository.syncMeals(any()) } returns Result.Error(
AppException.NetworkError("No connection")
)
// When
viewModel.loadMeals(LocalDate.now())
// Then
val state = viewModel.uiState.value
assertThat(state.error).isNotNull()
}
@Test
fun `deleteMeal removes meal from list`() = runTest {
// Given
val meal = Meal(id = "1", name = "Breakfast", calories = 400.0)
coEvery { mealRepository.deleteMeal(any()) } returns Result.Success(Unit)
// When
viewModel.deleteMeal(meal)
// Then
coVerify { mealRepository.deleteMeal(meal.id) }
}
}
// Main dispatcher rule for testing coroutines
@OptIn(ExperimentalCoroutinesApi::class)
class MainDispatcherRule(
private val testDispatcher: TestDispatcher = UnconfinedTestDispatcher()
) : TestWatcher() {
override fun starting(description: Description) {
Dispatchers.setMain(testDispatcher)
}
override fun finished(description: Description) {
Dispatchers.resetMain()
}
}
Interview Tip
The MainDispatcherRule replaces Dispatchers.Main with a test dispatcher. This avoids "Module with Main dispatcher not found" errors in unit tests.
Testing Flows with Turbine
@OptIn(ExperimentalCoroutinesApi::class)
class FlowTestExample {
@Test
fun `state flow emits loading then success`() = runTest {
val viewModel = MealsViewModel(fakeRepository)
viewModel.uiState.test {
// Initial state
assertThat(awaitItem().isLoading).isFalse()
// Trigger load
viewModel.loadMeals(LocalDate.now())
// Loading state
assertThat(awaitItem().isLoading).isTrue()
// Success state
val successState = awaitItem()
assertThat(successState.isLoading).isFalse()
assertThat(successState.meals).isNotEmpty()
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `repository emits updates reactively`() = runTest {
val repository = FakeMealRepository()
repository.observeMeals("user1", LocalDate.now()).test {
// Initial empty
assertThat(awaitItem()).isEmpty()
// Add meal
repository.addMeal(testMeal)
assertThat(awaitItem()).hasSize(1)
// Add another
repository.addMeal(anotherMeal)
assertThat(awaitItem()).hasSize(2)
cancelAndIgnoreRemainingEvents()
}
}
}
Fakes vs Mocks
// Fake - Real implementation with in-memory state (PREFERRED for complex behavior)
class FakeMealRepository : MealRepository {
private val meals = MutableStateFlow<List<Meal>>(emptyList())
private var shouldFail = false
override fun observeMeals(userId: String, date: LocalDate): Flow<List<Meal>> {
return meals.map { allMeals ->
allMeals.filter { it.date == date }
}
}
override suspend fun createMeal(meal: Meal): Result<Meal> {
if (shouldFail) return Result.Error(AppException.NetworkError("Failed"))
meals.update { it + meal }
return Result.Success(meal)
}
override suspend fun deleteMeal(id: String): Result<Unit> {
if (shouldFail) return Result.Error(AppException.NetworkError("Failed"))
meals.update { list -> list.filter { it.id != id } }
return Result.Success(Unit)
}
// Test helpers
fun addMeal(meal: Meal) {
meals.update { it + meal }
}
fun setMeals(mealList: List<Meal>) {
meals.value = mealList
}
fun setShouldFail(fail: Boolean) {
shouldFail = fail
}
fun clear() {
meals.value = emptyList()
}
}
Common Mistake
For complex dependencies with state, prefer fakes over mocks. Fakes provide more realistic behavior and are easier to maintain.
Testing Room Database
@RunWith(AndroidJUnit4::class)
class MealDaoTest {
private lateinit var database: NutritionDatabase
private lateinit var mealDao: MealDao
@Before
fun setup() {
val context = ApplicationProvider.getApplicationContext<Context>()
database = Room.inMemoryDatabaseBuilder(
context,
NutritionDatabase::class.java
).allowMainThreadQueries().build()
mealDao = database.mealDao()
}
@After
fun tearDown() {
database.close()
}
@Test
fun insertAndRetrieveMeal() = runTest {
val meal = MealEntity(
id = "1",
userId = "user1",
name = "Breakfast",
mealType = MealType.BREAKFAST,
consumedAt = Instant.now(),
totalCalories = 400.0
)
mealDao.insert(meal)
val retrieved = mealDao.getById("1")
assertThat(retrieved).isEqualTo(meal)
}
@Test
fun observeMeals_emitsUpdates() = runTest {
val userId = "user1"
val date = LocalDate.now().toString()
mealDao.observeMealsByDate(userId, date).test {
assertThat(awaitItem()).isEmpty()
mealDao.insert(createMeal("1"))
assertThat(awaitItem()).hasSize(1)
mealDao.insert(createMeal("2"))
assertThat(awaitItem()).hasSize(2)
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun softDelete_excludesFromQueries() = runTest {
val meal = createMeal("1")
mealDao.insert(meal)
mealDao.softDelete("1")
val meals = mealDao.observeMealsByDate(meal.userId, LocalDate.now().toString()).first()
assertThat(meals).isEmpty()
// But still exists in DB
val actual = mealDao.getById("1")
assertThat(actual?.isDeleted).isTrue()
}
}
Compose UI Testing
@RunWith(AndroidJUnit4::class)
class MealCardTest {
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun mealCard_displaysContent() {
val meal = Meal(
id = "1",
name = "Chicken Salad",
mealType = MealType.LUNCH,
totalCalories = 450.0
)
composeTestRule.setContent {
NutritionTheme {
MealCard(meal = meal, onClick = {})
}
}
composeTestRule.onNodeWithText("Chicken Salad").assertIsDisplayed()
composeTestRule.onNodeWithText("450 kcal").assertIsDisplayed()
composeTestRule.onNodeWithText("Lunch").assertIsDisplayed()
}
@Test
fun mealCard_clickTriggersCallback() {
var clicked = false
val meal = Meal(id = "1", name = "Test", totalCalories = 100.0)
composeTestRule.setContent {
NutritionTheme {
MealCard(meal = meal, onClick = { clicked = true })
}
}
composeTestRule.onNodeWithText("Test").performClick()
assertThat(clicked).isTrue()
}
}
@RunWith(AndroidJUnit4::class)
class MealsScreenTest {
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun mealsScreen_showsLoadingThenContent() {
val viewModel = FakeMealsViewModel()
composeTestRule.setContent {
NutritionTheme {
MealsScreen(viewModel = viewModel)
}
}
// Initially loading
composeTestRule.onNodeWithTag("loading_indicator").assertIsDisplayed()
// Simulate data loaded
viewModel.setMeals(listOf(testMeal))
// Content visible
composeTestRule.onNodeWithTag("loading_indicator").assertDoesNotExist()
composeTestRule.onNodeWithText(testMeal.name).assertIsDisplayed()
}
@Test
fun mealsScreen_swipeToDelete() {
val viewModel = FakeMealsViewModel()
viewModel.setMeals(listOf(testMeal))
composeTestRule.setContent {
NutritionTheme {
MealsScreen(viewModel = viewModel)
}
}
// Swipe left on meal
composeTestRule.onNodeWithText(testMeal.name)
.performTouchInput { swipeLeft() }
// Verify delete called
assertThat(viewModel.deletedMeals).contains(testMeal.id)
}
}
MockK Patterns
class MockKExamples {
@Test
fun `mock suspend function`() = runTest {
val api = mockk<NutritionApi>()
coEvery { api.getMeals(any(), any()) } returns listOf(testMealDto)
val result = api.getMeals("2024-01-01", "user1")
assertThat(result).hasSize(1)
coVerify { api.getMeals("2024-01-01", "user1") }
}
@Test
fun `mock flow`() = runTest {
val dao = mockk<MealDao>()
every { dao.observeAll(any()) } returns flowOf(listOf(testEntity))
dao.observeAll("user1").test {
assertThat(awaitItem()).hasSize(1)
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `capture arguments`() = runTest {
val repository = mockk<MealRepository>()
val mealSlot = slot<Meal>()
coEvery { repository.createMeal(capture(mealSlot)) } returns Result.Success(testMeal)
repository.createMeal(testMeal)
assertThat(mealSlot.captured.name).isEqualTo(testMeal.name)
}
@Test
fun `verify call order`() = runTest {
val repo = mockk<MealRepository>(relaxed = true)
repo.syncMeals(LocalDate.now())
repo.createMeal(testMeal)
coVerifyOrder {
repo.syncMeals(any())
repo.createMeal(any())
}
}
@Test
fun `return different values on consecutive calls`() = runTest {
val api = mockk<NutritionApi>()
coEvery { api.getMeals(any(), any()) } returnsMany listOf(
listOf(meal1),
listOf(meal1, meal2),
emptyList()
)
assertThat(api.getMeals("date", "user")).hasSize(1)
assertThat(api.getMeals("date", "user")).hasSize(2)
assertThat(api.getMeals("date", "user")).isEmpty()
}
}
Test Fixtures
object TestData {
fun meal(
id: String = UUID.randomUUID().toString(),
name: String = "Test Meal",
mealType: MealType = MealType.LUNCH,
calories: Double = 500.0,
date: LocalDate = LocalDate.now()
) = Meal(
id = id,
name = name,
mealType = mealType,
totalCalories = calories,
consumedAt = date.atTime(12, 0).atZone(ZoneId.systemDefault()).toInstant()
)
val sampleMeals = listOf(
meal(id = "1", name = "Breakfast", mealType = MealType.BREAKFAST, calories = 400.0),
meal(id = "2", name = "Lunch", mealType = MealType.LUNCH, calories = 600.0),
meal(id = "3", name = "Dinner", mealType = MealType.DINNER, calories = 700.0)
)
}
Interview Questions
Q: How do you test ViewModels that use coroutines?
Use runTest and replace Dispatchers.Main:
@get:Rule
val mainDispatcherRule = MainDispatcherRule()
@Test
fun `test async operation`() = runTest {
// Given
coEvery { repo.getData() } returns data
// When
viewModel.loadData()
// Then - No need for delay(), runTest handles timing
assertThat(viewModel.state.value).isEqualTo(expected)
}
Key points:
runTestfrom kotlinx-coroutines-test- Replace Main dispatcher via TestWatcher rule
coEvery/coVerifyfor suspend functions in MockK
Q: What's the difference between fakes and mocks?
| Aspect | Mocks (MockK) | Fakes |
|---|---|---|
| Setup | Stub individual calls | Real implementation |
| State | Stateless | Maintains state |
| Behavior | Returns canned responses | Real behavior |
| Best for | Simple dependencies | Complex dependencies |
Q: How do you test Compose UI?
Use createComposeRule() and semantic matchers:
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun button_showsLoadingState() {
composeTestRule.setContent {
PrimaryButton(text = "Save", isLoading = true, onClick = {})
}
composeTestRule
.onNodeWithContentDescription("Loading")
.assertIsDisplayed()
composeTestRule
.onNodeWithText("Save")
.assertIsDisplayed()
}
Key APIs:
onNodeWithText(),onNodeWithTag(),onNodeWithContentDescription()assertIsDisplayed(),assertDoesNotExist()performClick(),performTextInput(),performTouchInput { swipeLeft() }
Common Mistakes
1. Testing Implementation Details
// BAD - Tests internal state
@Test
fun test() {
viewModel.loadData()
assertThat(viewModel.internalList).hasSize(3) // Exposes internals
}
// GOOD - Tests observable behavior
@Test
fun test() {
viewModel.loadData()
assertThat(viewModel.uiState.value.items).hasSize(3)
}
2. Not Waiting for Async
// BAD - Race condition
@Test
fun test() = runTest {
viewModel.loadData()
assertThat(viewModel.state.value.isLoading).isFalse() // Might fail!
}
// GOOD - Use advanceUntilIdle or collect Flow
@Test
fun test() = runTest {
viewModel.loadData()
advanceUntilIdle() // Wait for coroutines
assertThat(viewModel.state.value.isLoading).isFalse()
}
3. Flaky Compose Tests
// BAD - Timing issues
composeTestRule.onNodeWithText("Loaded").assertIsDisplayed()
// GOOD - Wait for idle
composeTestRule.waitForIdle()
composeTestRule.onNodeWithText("Loaded").assertIsDisplayed()
// BETTER - Wait until condition
composeTestRule.waitUntil {
composeTestRule.onAllNodesWithText("Loaded").fetchSemanticsNodes().isNotEmpty()
}
4. Not Cleaning Up
// BAD - Tests affect each other
class MyTest {
val repository = FakeRepository() // Shared state!
}
// GOOD - Fresh instance per test
class MyTest {
private lateinit var repository: FakeRepository
@Before
fun setup() {
repository = FakeRepository()
}
@After
fun tearDown() {
repository.clear()
}
}
Summary Table
| Tool | Purpose | Key Points |
|---|---|---|
runTest | Test coroutines | Handles timing |
MainDispatcherRule | Replace Main dispatcher | Required for ViewModels |
| Turbine | Test Flows | flow.test { } |
| MockK | Mock dependencies | coEvery/coVerify |
| Fakes | Complex dependencies | Maintain state |
createComposeRule() | UI testing | Semantic matchers |
| Truth | Assertions | Fluent API |
Next in series: Part 10 - Play Store & Billing covers deployment, subscriptions, and production monitoring.