Android-проект без юнит-тестов — это проект, где страшно трогать Repository или ViewModel, потому что неясно, что сломается. JUnit 5 + MockK + корутины дают инструментарий для покрытия всей бизнес-логики: быстрые тесты на JVM без эмулятора, изолированные, воспроизводимые. По нашей статистике, unit-тесты сокращают время регрессионного тестирования на 70% и выявляют до 90% багов до этапа QA.
Наша команда с опытом более 5 лет в Android-разработке уже покрыла тестами более 50 проектов — от стартапов до enterprise-приложений. Мы гарантируем качество и прозрачность: вы получаете не только тесты, но и документацию, CI-интеграцию и отчёты о покрытии.
Какие проблемы решает юнит-тестирование?
Регресс-баги. Изменение в одном модуле ломает соседние. Юнит-тесты ловят это за секунды, а не через неделю QA. Неочевидные ошибки в мапперах и extension-функциях. Казалось бы, тривиально, но именно там теряются nullable поля и некорректно форматируются даты. Сложность рефакторинга. Без тестов любое изменение — лотерея. С тестами — безопасная операция с мгновенной обратной связью.
Мы используем следующие инструменты:
| Инструмент | Назначение |
|---|---|
| JUnit 5 | Test runner, assertions |
| Mockito / MockK | Моки и стабы для зависимостей |
| Turbine | Тестирование Kotlin Flow |
| kotlinx-coroutines-test | TestDispatcher, runTest |
| Robolectric | Android-специфичный код без эмулятора |
MockK предпочтительнее Mockito для Kotlin-кода: он в 2 раза быстрее обрабатывает suspend-функции и корректно мокирует object. Сравнение: Mockito требует runBlocking для корутин — это замедляет тесты и ведёт к race condition'ам. MockK лишён этих проблем. Подробнее о MockK.
Как тестировать ViewModel с корутинами?
Главная сложность — ViewModel работает с корутинами на Dispatchers.Main, которого нет в JVM-тестах. Решение — TestDispatcher из kotlinx-coroutines-test. Вот пример из нашей практики:
@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). Более подробно о kotlinx.coroutines.test.
Как тестировать 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 без колбек-ада.
Частые ошибки при написании тестов
Типичные ошибки — забытый Dispatchers.resetMain() (приводит к утечке диспетчера между тестами) и использование runBlocking вместо runTest. runBlocking блокирует поток и не раскрывает race conditions, в то время как runTest имитирует асинхронность корректно. Ещё одна распространённая проблема — неверный порядок ожидания emissions в Flow: без Turbine легко пропустить loading state. Мы рекомендуем всегда использовать runTest и Turbine для асинхронных тестов.
Чек-лист для внедрения тестов в существующий проект
- Определить критичные модули (ViewModel, Repository, UseCase) и покрыть их в первую очередь.
- Настроить JaCoCo и CI для автоматического запуска тестов при каждом пуше.
- Убедиться, что тесты не зависят от эмулятора (изолированы на JVM).
- Для каждого теста проверять только одно поведение.
Что часто не тестируют, а зря?
Маппер-классы — казалось бы, тривиально, но именно там теряются nullable поля и некорректно обрабатывается дата-формат (экономия времени регресса — до 80%). Например, в одном из проектов маппер из ApiUser в User неправильно обрабатывал null-имя, и после рефакторинга API это привело к багу, который тесты выявили за секунды. Extension-функции — особенно те, что форматируют строки, даты, числа. Логика пагинации в PagingSource — PagingSource.LoadResult можно тестировать напрямую через TestPagingSource.
Процесс работы
- Анализ кодовой базы и выявление критических модулей.
- Проектирование тестовой архитектуры — выбор мок-библиотек, настройка
TestDispatcher. - Написание тестов — покрытие ViewModel, Repository, UseCase, мапперов.
- Настройка CI — интеграция с GitHub Actions, публикация отчётов JaCoCo в PR.
- Документирование — инструкция по запуску, описание покрытия.
Что входит в работу
- Набор unit-тестов с покрытием не менее 80% ключевой бизнес-логики.
- Настроенный CI (GitHub Actions) с запуском
./gradlew testи генерацией JaCoCo-отчёта. - Документация: описание тестов, примеры, инструкция по запуску.
- Гарантия на тесты — если в течение месяца после сдачи что-то ломается из-за ошибки в тесте, мы исправляем бесплатно.
Сроки и стоимость
Ориентировочные сроки — от 3 до 5 дней в зависимости от размера проекта и текущей архитектуры. Стоимость рассчитывается индивидуально — свяжитесь с нами, и мы оценим ваш проект.
Получите консультацию или закажите разработку тестов — мы поможем сделать ваш Android-код надёжным и поддерживаемым.







