Testing
Android testing with JUnit5 for unit tests and Espresso/Compose testing for UI verification
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 linesTesting — 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()withDispatchers.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. UserunTestwithadvanceUntilIdle(),advanceTimeBy(), orawaitItem()from Turbine for instant, deterministic results. -
Testing implementation details instead of observable behavior: Verifying that a ViewModel calls
repository.getUsers()exactly once withverify(exactly = 1)tests the implementation, not the behavior. Test that the ViewModel'suiStatecontains 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
UserRepositorythat hits the real network, the test depends on external service availability, is slow, and produces different results on different runs. Use@BindValueor@UninstallModulesto 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
runTestfromkotlinx-coroutines-testfor all suspend-function tests to get virtual time support. - Use Turbine (
app.cash.turbine) for testingFlowandStateFlowemissions with clean assertions. - Add
testTagmodifiers 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
StandardTestDispatcherwithout callingadvanceUntilIdle(), 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
launchjobs in Flow tests, causing tests to hang. - Using
Thread.sleep()in tests instead of proper coroutine test utilities or ComposewaitUntil. - Not isolating tests — shared mutable state between tests causes flaky results. Reset fakes in
@BeforeEach.
Install this skill directly: skilldb add android-kotlin-skills
Related Skills
Architecture
MVVM architecture pattern with ViewModel, LiveData, and StateFlow for scalable Android apps
Coroutines
Kotlin coroutines and Flow for asynchronous programming and reactive streams in Android
Dependency Injection
Hilt and Dagger dependency injection for managing object creation and scoping in Android apps
Jetpack Compose
Jetpack Compose declarative UI toolkit for building native Android interfaces with Kotlin
Navigation
Jetpack Navigation component for type-safe in-app navigation and deep linking in Android
Retrofit
Retrofit HTTP client for type-safe REST API communication in Android with Kotlin coroutines