Bobby Encoded
PostsAbout
PostsAbout

© 2026 Bobby Jose

← Back to Blog

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:

  • runTest from kotlinx-coroutines-test
  • Replace Main dispatcher via TestWatcher rule
  • coEvery/coVerify for suspend functions in MockK

Q: What's the difference between fakes and mocks?

AspectMocks (MockK)Fakes
SetupStub individual callsReal implementation
StateStatelessMaintains state
BehaviorReturns canned responsesReal behavior
Best forSimple dependenciesComplex 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

ToolPurposeKey Points
runTestTest coroutinesHandles timing
MainDispatcherRuleReplace Main dispatcherRequired for ViewModels
TurbineTest Flowsflow.test { }
MockKMock dependenciescoEvery/coVerify
FakesComplex dependenciesMaintain state
createComposeRule()UI testingSemantic matchers
TruthAssertionsFluent API

Next in series: Part 10 - Play Store & Billing covers deployment, subscriptions, and production monitoring.

← Previous

Android Play Store Deployment and Billing

Next →

Android System Integrations: Health Connect, WorkManager, and More