Розробка Unit-тестів для Android-додатку (JUnit)
Android-проект без юнит-тестів — це проект, де страшно трогати Repository або ViewModel, бо неясно, що ломатися. JUnit 5 + Mockito + корутини дають інструментарій для покриття всієї бізнес-логіки: швидкі тесты на JVM без емулятора, ізольовані, відтворювані.
Стек інструментів
| Інструмент | Призначення |
|---|---|
| JUnit 5 | Test runner, assertions |
| Mockito / MockK | Моки та стабаи для залежностей |
| Turbine | Тестування Kotlin Flow |
| kotlinx-coroutines-test | TestDispatcher, runTest |
| Robolectric | Android-специфичний код без емулятора |
MockK перевагливається за Mockito для Kotlin-коду: коректно мокирует object, companion object та suspend-функції без runBlocking-хаків.
Тестування ViewModel з корутинами
Головна складність — ViewModel працює з корутинами на Dispatchers.Main, якого нема в JVM-тестах. Рішення — 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 виконує корутини одразу, StandardTestDispatcher — лише при advanceUntilIdle(). Для тестування тайміну (debounce, delay) використовуємо advanceTimeBy(ms).
Тестування Kotlin Flow через 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) — найзручніший спосіб перевірити послідовність emissions з StateFlow/SharedFlow без колбек-ада.
Repository та UseCase
Repository тестуємо ізольовано від ViewModel. Мокуємо DataSource (remote та local), перевіряємо логіку кешування, маппінгу DTO → Entity, обробку помилок:
@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)
}
Що часто не тестують, а зря
- Mapper-класи — здавалось би, тривіально, але саме там теряються nullable поля та некоректно обробляється дата-формат
- Extension-функції — особливо ті, що форматують рядки, дати, числа
-
Логіка пагінації у
PagingSource—PagingSource.LoadResultможна тестувати прямо черезTestPagingSource
CI-інтеграція
./gradlew test гоняє всі unit-тесты без емулятора. Покриття через JaCoCo: ./gradlew jacocoTestReport. У GitHub Actions — матриця JDK-версій (17 + 21). Результати публікуємо як Test Report артефакт для ревю в PR.
Срок: 3–5 днів залежно від розміру проекту та поточної архітектури.







