Unit Test Development for Android Application (JUnit)
Android project without unit tests — project where scary to touch Repository or ViewModel, because unclear what breaks. JUnit 5 + Mockito + coroutines give tools to cover all business logic: fast JVM tests without emulator, isolated, reproducible.
Tool Stack
| Tool | Purpose |
|---|---|
| JUnit 5 | Test runner, assertions |
| Mockito / MockK | Mocks and stubs for dependencies |
| Turbine | Kotlin Flow testing |
| kotlinx-coroutines-test | TestDispatcher, runTest |
| Robolectric | Android-specific code without emulator |
MockK preferable to Mockito for Kotlin code: correctly mocks object, companion object and suspend-functions without runBlocking hacks.
ViewModel Testing with Coroutines
Main difficulty — ViewModel works with coroutines on Dispatchers.Main, which doesn't exist in JVM tests. Solution — TestDispatcher:
@OptIn(ExperimentalCoroutinesApi::class)
class UserViewModelTest {
private val testDispatcher = UnconfinedTestDispatcher()
@Before
fun setup() {
Dispatchers.setMain(testDispatcher)
}
@After
fun tearDown() {
Dispatchers.resetMain()
}
@Test
fun `loadUser emits success state`() = runTest {
val mockRepo = mockk<UserRepository>()
coEvery { mockRepo.getUser("1") } returns User(id = "1", name = "Test")
val viewModel = UserViewModel(mockRepo)
viewModel.loadUser("1")
assertEquals(UiState.Success(User(id = "1", name = "Test")), viewModel.uiState.value)
}
}
UnconfinedTestDispatcher executes coroutines immediately, StandardTestDispatcher — only on advanceUntilIdle(). For timing tests (debounce, delay) use advanceTimeBy(ms).
Testing Kotlin Flow via Turbine
@Test
fun `state flow emits loading then success`() = runTest {
val mockRepo = mockk<UserRepository>()
coEvery { mockRepo.getUser(any()) } coAnswers {
delay(100)
User(id = "1", name = "Test")
}
val viewModel = UserViewModel(mockRepo)
viewModel.uiState.test {
assertEquals(UiState.Loading, awaitItem())
viewModel.loadUser("1")
assertEquals(UiState.Success(User("1", "Test")), awaitItem())
cancelAndIgnoreRemainingEvents()
}
}
Turbine (app.cash.turbine) — most convenient way to check sequence of emissions from StateFlow/SharedFlow without callback mess.
Repository and UseCase
Repository test isolated from ViewModel. Mock DataSource (remote and local), check caching logic, DTO → Entity mapping, error handling:
@Test
fun `getUser returns cached data when network fails`() = runTest {
coEvery { remoteDataSource.getUser(any()) } throws IOException("No network")
coEvery { localDataSource.getUser("1") } returns UserEntity(id = "1", name = "Cached")
val result = repository.getUser("1")
assertTrue(result.isSuccess)
assertEquals("Cached", result.getOrNull()?.name)
}
What Often Not Tested but Should Be
- Mapper classes — seems trivial, but exactly where nullable fields are lost and date-format incorrectly handled
- Extension functions — especially ones formatting strings, dates, numbers
-
Pagination logic in
PagingSource—PagingSource.LoadResultcan be tested directly viaTestPagingSource
CI Integration
./gradlew test runs all unit tests without emulator. Coverage via JaCoCo: ./gradlew jacocoTestReport. In GitHub Actions — matrix of JDK versions (17 + 21). Publish results as Test Report artifact for PR review.
Timeframe: 3–5 days depending on project size and current architecture.







