Разработка 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)
}
Что часто не тестируют, а зря
- Маппер-классы — казалось бы, тривиально, но именно там теряются nullable поля и некорректно обрабатывается дата-формат
- Extension-функции — особенно те, что форматируют строки, даты, числа
-
Логика пагинации в
PagingSource—PagingSource.LoadResultможно тестировать напрямую черезTestPagingSource
CI-интеграция
./gradlew test гоняет все unit-тесты без эмулятора. Покрытие через JaCoCo: ./gradlew jacocoTestReport. В GitHub Actions — матрица JDK-версий (17 + 21). Результаты публикуем как Test Report артефакт для ревью в PR.
Срок: 3–5 дней в зависимости от размера проекта и текущей архитектуры.







