Skip to main content
Technology & EngineeringAndroid Kotlin395 lines

Testing

Android testing with JUnit5 for unit tests and Espresso/Compose testing for UI verification

Quick Summary18 lines
You are an expert in testing Android applications with JUnit5, Espresso, and Compose Test for Kotlin-based projects.

## Key Points

- Use fakes over mocks for repositories and data sources — fakes are more readable and catch integration issues that mocks hide.
- Set `Dispatchers.setMain(UnconfinedTestDispatcher())` in ViewModel tests to make coroutines execute synchronously.
- Use `runTest` from `kotlinx-coroutines-test` for all suspend-function tests to get virtual time support.
- Use Turbine (`app.cash.turbine`) for testing `Flow` and `StateFlow` emissions with clean assertions.
- Add `testTag` modifiers to Compose elements that lack visible text for reliable test targeting.
- Keep unit tests fast by avoiding Android framework dependencies — use Robolectric only when necessary.
- Organize tests to mirror production code structure: one test class per production class.
- Not calling `Dispatchers.resetMain()` in `@AfterEach`, leaking the test dispatcher into other tests.
- Using `StandardTestDispatcher` without calling `advanceUntilIdle()`, causing suspend functions to never complete.
- Testing implementation details (verifying exact mock call sequences) instead of observable behavior (state changes).
- Not using `allowMainThreadQueries()` in Room in-memory test databases, causing tests to deadlock.
- Forgetting to cancel background `launch` jobs in Flow tests, causing tests to hang.
skilldb get android-kotlin-skills/TestingFull skill: 395 lines
Paste into your CLAUDE.md or agent config

Testing — Android/Kotlin

You are an expert in testing Android applications with JUnit5, Espresso, and Compose Test for Kotlin-based projects.

Core Philosophy

A well-tested Android app separates what it tests from how it tests. Unit tests on the JVM verify business logic, state transitions, and data transformations without an Android device. Instrumented tests on a device or emulator verify that UI components render correctly and respond to interaction. Mixing these layers -- running business logic tests on a device or testing UI behavior with pure unit tests -- makes the test suite slow, flaky, or both. Each layer of the testing pyramid has a specific purpose, and staying disciplined about which tests go where is the key to a fast, reliable test suite.

Fakes are almost always better than mocks for Android testing. A FakeUserRepository that holds an in-memory list behaves like a real repository, catches integration issues between the repository and its consumers, and produces readable tests. A mock that verifies repository.getUsers() was called exactly once with these parameters tests the implementation, not the behavior. When the implementation changes (adding caching, changing call order), mock-based tests break even though the behavior is still correct. Fakes are stable because they test outcomes, not call sequences.

The coroutine test infrastructure (runTest, UnconfinedTestDispatcher, Turbine) exists to make async testing deterministic. In production, coroutines run concurrently on real dispatchers with real timing. In tests, runTest provides virtual time that can be advanced instantly, and UnconfinedTestDispatcher makes coroutines execute synchronously. Without these tools, async tests are either flaky (dependent on real timing) or slow (using Thread.sleep to wait for results). Always use the coroutine test utilities instead of sleeping.

Anti-Patterns

  • Not resetting Dispatchers.Main in @AfterEach: Setting the main dispatcher to a test dispatcher without resetting it after the test leaks the test dispatcher into subsequent tests, causing unpredictable failures in unrelated test classes. Always pair Dispatchers.setMain() with Dispatchers.resetMain() in teardown.

  • Using Thread.sleep instead of coroutine test utilities: Thread.sleep(1000) in a test makes the test slow and still potentially flaky if the operation takes longer than expected. Use runTest with advanceUntilIdle(), advanceTimeBy(), or awaitItem() from Turbine for instant, deterministic results.

  • Testing implementation details instead of observable behavior: Verifying that a ViewModel calls repository.getUsers() exactly once with verify(exactly = 1) tests the implementation, not the behavior. Test that the ViewModel's uiState contains the expected users. When the implementation changes (adding caching, retry), the test should not need to change if the behavior is the same.

  • Forgetting to cancel background launch jobs in Flow tests: A test that launches a coroutine to collect a Flow but never cancels the job will hang indefinitely. Always store the job and cancel it at the end of the test, or use Turbine's test { } block which handles cancellation automatically.

  • Using real implementations instead of fakes in UI tests: If a Compose test uses the production UserRepository that hits the real network, the test depends on external service availability, is slow, and produces different results on different runs. Use @BindValue or @UninstallModules to replace real implementations with fakes.

Overview

A robust Android test suite combines unit tests (JVM-based, fast) with instrumented tests (device/emulator, slower). JUnit5 provides expressive test structure with nested classes and parameterized tests. Espresso handles View-based UI testing, while the Compose Test library provides semantics-based testing for Jetpack Compose UIs. Coroutine and Flow testing use kotlinx-coroutines-test for deterministic execution.

Core Concepts

Unit Test with JUnit5

@ExtendWith(MockKExtension::class)
class UserRepositoryTest {

    @MockK
    private lateinit var apiService: ApiService

    @MockK
    private lateinit var userDao: UserDao

    private lateinit var repository: UserRepository

    @BeforeEach
    fun setup() {
        repository = UserRepositoryImpl(apiService, userDao)
    }

    @Test
    fun `getUsers fetches from API and caches in database`() = runTest {
        val dtos = listOf(UserDto(1, "Alice", "alice@test.com", null, "2024-01-01"))
        val entities = listOf(UserEntity(1, "Alice", "alice@test.com"))

        coEvery { apiService.getUsers() } returns dtos
        coEvery { userDao.upsertAll(any()) } just Runs
        coEvery { userDao.getAll() } returns entities

        val result = repository.getUsers()

        assertThat(result).hasSize(1)
        assertThat(result.first().displayName).isEqualTo("Alice")

        coVerify { userDao.upsertAll(any()) }
    }

    @Test
    fun `getUsers throws when API fails and no cache exists`() = runTest {
        coEvery { apiService.getUsers() } throws IOException("No network")

        assertThrows<IOException> {
            repository.getUsers()
        }
    }
}

ViewModel Testing

@ExtendWith(MockKExtension::class)
class UserListViewModelTest {

    @MockK
    private lateinit var repository: UserRepository

    private lateinit var viewModel: UserListViewModel

    @BeforeEach
    fun setup() {
        Dispatchers.setMain(UnconfinedTestDispatcher())
    }

    @AfterEach
    fun tearDown() {
        Dispatchers.resetMain()
    }

    @Test
    fun `initial state is Loading then transitions to Success`() = runTest {
        val users = listOf(User(1, "Alice", "alice@test.com"))
        coEvery { repository.getUsers() } returns users

        viewModel = UserListViewModel(repository)

        val state = viewModel.uiState.value
        assertThat(state).isInstanceOf(UserListUiState.Success::class.java)
        assertThat((state as UserListUiState.Success).users).isEqualTo(users)
    }

    @Test
    fun `loadUsers emits Error when repository throws`() = runTest {
        coEvery { repository.getUsers() } throws RuntimeException("DB error")

        viewModel = UserListViewModel(repository)

        val state = viewModel.uiState.value
        assertThat(state).isInstanceOf(UserListUiState.Error::class.java)
        assertThat((state as UserListUiState.Error).message).isEqualTo("DB error")
    }
}

Flow Testing

@Test
fun `observeUsers emits updated list when database changes`() = runTest {
    val flow = MutableSharedFlow<List<UserEntity>>()
    every { userDao.observeAll() } returns flow

    val results = mutableListOf<List<User>>()
    val job = launch(UnconfinedTestDispatcher()) {
        repository.observeUsers().toList(results)
    }

    flow.emit(listOf(UserEntity(1, "Alice", "alice@test.com")))
    assertThat(results).hasSize(1)
    assertThat(results.first().first().displayName).isEqualTo("Alice")

    flow.emit(listOf(
        UserEntity(1, "Alice", "alice@test.com"),
        UserEntity(2, "Bob", "bob@test.com")
    ))
    assertThat(results).hasSize(2)
    assertThat(results.last()).hasSize(2)

    job.cancel()
}

@Test
fun `stateFlow emits latest value using Turbine`() = runTest {
    coEvery { repository.getUsers() } returns listOf(User(1, "Alice", "alice@test.com"))

    val viewModel = UserListViewModel(repository)

    viewModel.uiState.test {
        val state = awaitItem()
        assertThat(state).isInstanceOf(UserListUiState.Success::class.java)
        cancelAndIgnoreRemainingEvents()
    }
}

Compose UI Testing

@HiltAndroidTest
class UserListScreenTest {

    @get:Rule(order = 0)
    val hiltRule = HiltAndroidRule(this)

    @get:Rule(order = 1)
    val composeTestRule = createAndroidComposeRule<MainActivity>()

    @BindValue
    val fakeRepository: UserRepository = FakeUserRepository(
        users = listOf(User(1, "Alice", "alice@test.com"))
    )

    @Before
    fun setup() {
        hiltRule.inject()
    }

    @Test
    fun displaysUserList() {
        composeTestRule.onNodeWithText("Alice").assertIsDisplayed()
    }

    @Test
    fun clickUserNavigatesToDetail() {
        composeTestRule.onNodeWithText("Alice").performClick()
        composeTestRule.onNodeWithText("alice@test.com").assertIsDisplayed()
    }

    @Test
    fun showsLoadingIndicator() {
        fakeRepository.setLoading(true)
        composeTestRule.onNode(hasTestTag("loading_spinner")).assertIsDisplayed()
    }

    @Test
    fun showsErrorStateWithRetry() {
        fakeRepository.setError("Network error")
        composeTestRule.onNodeWithText("Network error").assertIsDisplayed()
        composeTestRule.onNodeWithText("Retry").assertIsDisplayed().performClick()
    }
}

Espresso (View-Based UI)

@HiltAndroidTest
@RunWith(AndroidJUnit4::class)
class LoginActivityTest {

    @get:Rule(order = 0)
    val hiltRule = HiltAndroidRule(this)

    @get:Rule(order = 1)
    val activityRule = ActivityScenarioRule(LoginActivity::class.java)

    @Test
    fun emptyEmailShowsValidationError() {
        onView(withId(R.id.btn_login)).perform(click())
        onView(withId(R.id.input_email))
            .check(matches(hasErrorText("Email is required")))
    }

    @Test
    fun successfulLoginNavigatesToHome() {
        onView(withId(R.id.input_email)).perform(typeText("alice@test.com"))
        onView(withId(R.id.input_password)).perform(typeText("password123"), closeSoftKeyboard())
        onView(withId(R.id.btn_login)).perform(click())

        intended(hasComponent(MainActivity::class.java.name))
    }
}

Implementation Patterns

Fake Implementations for Testing

class FakeUserRepository : UserRepository {

    private val _users = MutableStateFlow<List<User>>(emptyList())
    private var shouldThrow: Exception? = null

    fun setUsers(users: List<User>) { _users.value = users }
    fun setError(exception: Exception) { shouldThrow = exception }

    override fun observeUsers(): Flow<List<User>> = _users

    override suspend fun getUsers(): List<User> {
        shouldThrow?.let { throw it }
        return _users.value
    }

    override suspend fun getUser(id: Long): User {
        shouldThrow?.let { throw it }
        return _users.value.first { it.id == id }
    }

    override suspend fun deleteUser(id: Long) {
        _users.update { users -> users.filter { it.id != id } }
    }
}

JUnit5 Parameterized Tests

@ParameterizedTest
@CsvSource(
    "alice@test.com, true",
    "not-an-email, false",
    "'', false",
    "a@b.c, true"
)
fun `validates email format correctly`(email: String, expected: Boolean) {
    assertThat(EmailValidator.isValid(email)).isEqualTo(expected)
}

JUnit5 Nested Tests

@DisplayName("UserRepository")
class UserRepositoryTest {

    @Nested
    @DisplayName("getUsers")
    inner class GetUsers {

        @Test
        fun `returns cached users when available`() = runTest { /* ... */ }

        @Test
        fun `fetches from API when cache is empty`() = runTest { /* ... */ }
    }

    @Nested
    @DisplayName("deleteUser")
    inner class DeleteUser {

        @Test
        fun `removes user from local and remote`() = runTest { /* ... */ }

        @Test
        fun `throws when user does not exist`() = runTest { /* ... */ }
    }
}

Room Database Testing

@RunWith(AndroidJUnit4::class)
class UserDaoTest {

    private lateinit var database: AppDatabase
    private lateinit var dao: UserDao

    @Before
    fun setup() {
        database = Room.inMemoryDatabaseBuilder(
            ApplicationProvider.getApplicationContext(),
            AppDatabase::class.java
        ).allowMainThreadQueries().build()
        dao = database.userDao()
    }

    @After
    fun tearDown() {
        database.close()
    }

    @Test
    fun insertAndRetrieveUser() = runTest {
        val user = UserEntity(displayName = "Alice", email = "alice@test.com")
        val id = dao.upsert(user)

        val retrieved = dao.getById(id)
        assertThat(retrieved).isNotNull()
        assertThat(retrieved!!.displayName).isEqualTo("Alice")
    }

    @Test
    fun observeAllEmitsUpdates() = runTest {
        val results = mutableListOf<List<UserEntity>>()
        val job = launch(UnconfinedTestDispatcher()) {
            dao.observeAll().toList(results)
        }

        dao.upsert(UserEntity(displayName = "Alice", email = "alice@test.com"))
        assertThat(results.last()).hasSize(1)

        dao.upsert(UserEntity(displayName = "Bob", email = "bob@test.com"))
        assertThat(results.last()).hasSize(2)

        job.cancel()
    }
}

Best Practices

  • Use fakes over mocks for repositories and data sources — fakes are more readable and catch integration issues that mocks hide.
  • Set Dispatchers.setMain(UnconfinedTestDispatcher()) in ViewModel tests to make coroutines execute synchronously.
  • Use runTest from kotlinx-coroutines-test for all suspend-function tests to get virtual time support.
  • Use Turbine (app.cash.turbine) for testing Flow and StateFlow emissions with clean assertions.
  • Add testTag modifiers to Compose elements that lack visible text for reliable test targeting.
  • Keep unit tests fast by avoiding Android framework dependencies — use Robolectric only when necessary.
  • Organize tests to mirror production code structure: one test class per production class.

Common Pitfalls

  • Not calling Dispatchers.resetMain() in @AfterEach, leaking the test dispatcher into other tests.
  • Using StandardTestDispatcher without calling advanceUntilIdle(), causing suspend functions to never complete.
  • Testing implementation details (verifying exact mock call sequences) instead of observable behavior (state changes).
  • Not using allowMainThreadQueries() in Room in-memory test databases, causing tests to deadlock.
  • Forgetting to cancel background launch jobs in Flow tests, causing tests to hang.
  • Using Thread.sleep() in tests instead of proper coroutine test utilities or Compose waitUntil.
  • Not isolating tests — shared mutable state between tests causes flaky results. Reset fakes in @BeforeEach.

Install this skill directly: skilldb add android-kotlin-skills

Get CLI access →