Unit-тесты в src/test выполняются на JVM вашего компьютера за миллисекунды; инструментальные тесты из src/androidTest собираются в отдельный APK и запускаются на устройстве или эмуляторе, где есть настоящий фреймворк. UI-тесты обычно представляют собой инструментальные тесты, которые управляют экраном.
**src/test**: запускается задачей testDebugUnitTest, зависимости в testImplementation. android.jar здесь лишь заглушка, поэтому вызовы фреймворка падают. Место для бизнес-логики, ViewModel, мапперов и репозиториев поверх фейков.
**src/androidTest**: запускается connectedDebugAndroidTest через AndroidJUnitRunner, зависимости в androidTestImplementation. Настоящие Context, ресурсы, SQLite и главный Looper. Нужен, когда предметом теста служит само устройство: реальный UI, диалог разрешений, миграция Room на настоящем файле.
Robolectric: посередине: API в стиле инструментальных тестов (ActivityScenario, правила Compose-тестов), которые работают в src/test на симулированном фреймворке.
Пирамида тестов: в основании много быстрых unit-тестов, в середине меньше интеграционных и Robolectric-тестов, наверху несколько end-to-end UI-сценариев. Чем выше уровень, тем тест медленнее и нестабильнее, поэтому основная масса проверок должна быть внизу.
AndroidMiddle
Тестирование в Android
JUnit, MockK, Turbine, Robolectric, Espresso, тесты Compose UI, правила Hilt, скриншот-тесты
Закрыто вопросов
0 / 20
- 0Повторить
- 2Часто спрашивают
Вопросы
Тестовый дублёр подменяет реальную зависимость в тесте; виды различаются объёмом поведения и тем, проверяет ли тест вызовы. Для своих зависимостей с состоянием лучше fake; mock нужен, только чтобы проверить, что вызов произошёл.
Dummy: заполняет параметр и никогда не используется.
Stub: возвращает заготовленные ответы (getUser() всегда отдаёт User(1)), без логики.
Fake: рабочая упрощённая реализация: репозиторий в памяти поверх MutableStateFlow, in-memory база Room.
Mock: запрограммирован ожиданиями; тест проверяет вызовы (verify { api.send(any()) }).
Spy: оборачивает настоящий объект, записывает вызовы и позволяет переопределить отдельные методы.
Fake ведёт себя как настоящий объект (сохранили, прочитали, строка на месте), поэтому тесты проверяют результат, а не последовательность вызовов, и один fake переиспользуется всеми тестами. Тесты на моках обычно ломаются при рефакторинге, не меняющем поведение, и остаются зелёными, когда ломается поведение. Моки по-прежнему уместны, чтобы проверить побочный эффект на границе (отправлено событие аналитики, вызван navigate()) или подменить сторонний класс, который дорого реализовать.
Один тест проверяет одно поведение: подготовить входные данные, выполнить одно действие и проверить значения, чтобы ошибка показывала ожидаемое и фактическое. Общая подготовка размещается в @Before, который выполняется перед каждым тестом; @BeforeClass выполняется один раз на класс и должен быть статическим.
Называйте поведение: emptyCartTotalIsZero или имя в обратных кавычках Kotlin, а не testGetTotal. Имена с пробелами работают в src/test; на устройстве им нужен API 30+, поэтому инструментальные тесты остаются в camelCase.
Given / When / Then, где When занимает одну строку. Два действия означают два теста: первая упавшая проверка останавливает тест и скрывает вторую половину.
Проверяйте значения, а не boolean: assertTrue(total == 50) падает с голым AssertionError; assertEquals(50, total) печатает expected:<50> but was:<100>. JUnit принимает аргументы в порядке (expected, actual); в Truth assertThat(actual).isEqualTo(expected) перепутать нельзя.
Никакой логики в тестах: ни if, ни циклов; для таблицы случаев есть параметризованный тест.
Жизненный цикл: JUnit 4 создаёт новый экземпляр тестового класса на каждый тест, а @Before/@After выполняются вокруг каждого. @BeforeClass/@AfterClass живут в companion object с @JvmStatic и подходят для дорогой подготовки вроде запуска MockWebServer. Переиспользуемая подготовка и очистка выносятся в правило (@get:Rule), например MainDispatcherRule.
TDD (разработка через тестирование) работает как цикл: написать падающий тест, написать минимум кода, чтобы он прошёл, затем отрефакторить под защитой теста (red, green, refactor). Главная польза проявляется в дизайне: код, написанный от теста, получается с внедряемыми зависимостями и маленькими модулями.
Лучше всего TDD работает на чистой логике (ViewModel, use case, валидация, парсеры, мапперы). Для вёрстки и анимаций он редко окупается; это потом закрывают screenshot-тесты. Правила, которые стоит ввести в команде:
Тест к каждому багфиксу: сначала тест, воспроизводящий баг, потом исправление, чтобы баг не вернулся незаметно.
Тесты гоняются на каждом pull request, красная сборка блокирует мерж. У отключённого теста есть тикет и владелец.
Зависимости внедряются (конструктор, Hilt), а бизнес-логика не принимает Context, поэтому всё ниже UI работает на JVM.
Fake вместо mock для своих интерфейсов, общие через source set testFixtures или модуль :core:testing.
**Никаких Thread.sleep, реальной сети и реальных часов**: тестовые диспетчеры, MockWebServer или фейки, внедрённый Clock.
Держите тесты быстрыми: JVM-тесты на каждый коммит, тесты на устройстве при мерже.
В src/test android.jar содержит только сигнатуры методов, а тело каждого бросает RuntimeException("Method ... not mocked"), поэтому первый же вызов фреймворка (Log.d, Uri.parse, TextUtils) падает. Самое чистое решение: убрать вызовы фреймворка из тестируемой логики.
Вынесите фреймворк на край: спрячьте Log, Uri или Resources за интерфейс, который класс получает снаружи, и передайте в тесте no-op или fake.
Robolectric: @RunWith(AndroidJUnit4::class) в src/test подгружает настоящий фреймворк, и Log.d с Uri.parse работают ценой нескольких секунд старта.
Замокать статику: mockkStatic(Log::class) в MockK с every { Log.d(any(), any()) } returns 0 и очисткой через unmockkAll(). Годится для одного упрямого вызова, но не как привычка.
**unitTests.isReturnDefaultValues = true** заставляет все заглушки возвращать 0, null или false вместо исключения. Log замолкает, но Uri.parse(...) теперь возвращает null, и тесты могут проходить по неверной причине, так что это крайняя мера для легаси-кода.
Используйте RecyclerViewActions из espresso-contrib: actionOnItemAtPosition<VH>(3, click()) или actionOnItem<VH>(hasDescendant(withText("Order 42")), click()). Обычный onView(withText(...)) не находит строки за пределами экрана, потому что RecyclerView создаёт view только для видимых.
Прокрутить, потом проверить: RecyclerViewActions.scrollTo<VH>(matcher) выводит строку на экран, и обычный onView(...).check(...) её видит.
Дубликаты: две строки с одинаковым текстом заставляют onView(withText(...)) бросить AmbiguousViewMatcherException; поиск через список этого избегает.
Кнопка внутри строки: передайте в actionOnItemAtPosition свой ViewAction, который находит дочернюю view по id и кликает.
**ListView или Spinner** (то есть AdapterView): через onData(...), который сопоставляет данные адаптера, а не view.
**Compose LazyColumn**: performScrollToNode(hasText(...)), затем обычные finder-ы.
UI Automator управляет всем устройством через слой accessibility, поэтому может нажимать системные диалоги, уведомления, лаунчер и другие приложения; Espresso видит только view вашего приложения. Внутри приложения используйте Espresso, а для всего, что снаружи, UI Automator.
Типичные случаи: диалог runtime-разрешения (если разрешение нужно просто выдать, GrantPermissionRule надёжнее, чем нажимать его), шторка уведомлений, share sheet, нажатие Home и возврат. Сценарии Macrobenchmark тоже управляются через UI Automator.
Нет автоматической синхронизации: он не знает, когда приложение idle, поэтому ждать нужно явно: device.wait(Until.findObject(...), timeout).
MockK создан для Kotlin: он из коробки мокает final-классы, object и функции-расширения, а suspend-функции стабит через coEvery и coVerify. relaxed = true заставляет каждый незастабленный вызов возвращать значение по умолчанию (0, "", пустой список, вложенный мок) вместо исключения.
Строгий по умолчанию: mockk<OrderRepo>() бросает MockKException: no answer found на незастабленный вызов. Это ловит неожиданные вызовы; relaxed = true их прячет, поэтому лучше relaxUnitFun = true, который ослабляет только функции, возвращающие Unit.
**every и coEvery**: блок every { } не suspend-лямбда, поэтому suspend-вызов внутри него не компилируется; нужны coEvery, coVerify и coAnswers.
Глобальные моки: mockkObject, mockkStatic и mockkConstructor меняют глобальное состояние; откатывайте их через unmockkAll() в @After, иначе они протекут в другие тесты.
Аргументы: slot<Order>() с capture(slot), когда проверяется то, что передали.
Mockito сегодня: начиная с Mockito 5 inline mock maker включён по умолчанию, так что final-классы Kotlin мокаются без доп. настройки, а mockito-kotlin добавляет whenever и onBlocking для suspend-стабов. MockK естественнее читается в Kotlin; Mockito распространён в смешанных Java/Kotlin-проектах.
Ни тот, ни другой не заменяет fake для своей зависимости с состоянием: мок лишь повторяет ответы, которые вы прописали.
StandardTestDispatcher ставит корутины в очередь, пока тест не продвинет планировщик; UnconfinedTestDispatcher запускает их сразу на вызывающем потоке. viewModelScope работает на Dispatchers.Main, которого на JVM нет, поэтому тесты подменяют его через Dispatchers.setMain(testDispatcher).
Виртуальное время: оба диспетчера используют общий TestCoroutineScheduler, поэтому delay(30_000) возвращается мгновенно. runTest берёт планировщик тестового Main-диспетчера, если тот установлен; любой дополнительный диспетчер нужно создавать как StandardTestDispatcher(testScheduler), чтобы часы были общими.
Управление: runCurrent() выполняет то, что готово сейчас, advanceTimeBy(ms) сдвигает часы (задача, запланированная ровно на целевой момент, ждёт следующего runCurrent()), advanceUntilIdle() выполняет всё.
Standard по умолчанию: он позволяет увидеть промежуточные состояния, например Loading, который пользователь видит первым. Unconfined подходит для коллекторов в backgroundScope и тестов, которым важно только итоговое значение. Если тест проходит только на Unconfined, проверьте, что он проверяет результат уже после выполнения работы.
**Без setMain** тест падает с IllegalStateException: Module with the Main dispatcher had failed to initialize. Оберните setMain/resetMain в MainDispatcherRule.
**Захардкоженный Dispatchers.IO** уходит из-под виртуальных часов; внедряйте диспетчеры.
toList() собирает значения до завершения flow, а StateFlow никогда не завершается, поэтому тест висит, пока runTest не сдастся (по умолчанию через 60 секунд). Горячий flow собирают через test { } из Turbine или в backgroundScope и проверяют по одному значению.
Ожидаемый список тоже неверен: StateFlow повторяет текущее значение, поэтому первым элементом приходит начальное состояние, а хранит он только последнее значение, так что начинайте сбор до search(), иначе промежуточные состояния могут склеиться (conflation).
Turbine: flow.test { } собирает в фоновой корутине. awaitItem() ждёт следующее значение, expectNoEvents() проверяет тишину, cancelAndIgnoreRemainingEvents() завершает раньше. Непрочитанные события валят тест, а значение, которое не пришло, падает по таймауту (по умолчанию 3 секунды), а не вешает прогон.
Без Turbine: запустите vm.state.toList(list) в backgroundScope с UnconfinedTestDispatcher(testScheduler); backgroundScope отменяется в конце теста.
toList() годится для холодных flow, которые завершаются, например flowOf(...).
withContext(Dispatchers.IO) уносит работу в настоящий пул потоков, которым тестовый планировщик не управляет, поэтому advanceUntilIdle() может вернуться раньше, чем придёт результат, и проверка соревнуется с IO-потоком. Диспетчер нужно внедрять и подставлять в тесте тестовый.
Почему возникает гонка: advanceUntilIdle() выполняет только задачи из очереди TestCoroutineScheduler. Когда корутина доходит до withContext(Dispatchers.IO), очередь пуста, и вызов возвращается; продолжение публикуется обратно в Main позже, из IO-потока. Успеет ли оно до assertTrue, зависит от планирования потоков: на ноутбуке тест зелёный, а на загруженном CI-раннере красный.
Виртуальное время тоже теряется: delay() внутри IO-блока ждёт по-настоящему, а runTest не ждёт viewModelScope, потому что это не дочерний скоуп теста.
Исправление: принимать CoroutineDispatcher в конструкторе (по умолчанию Dispatchers.IO или квалифицированный диспетчер из Hilt) и передавать в тесте StandardTestDispatcher(testScheduler). Теперь IO-работа идёт на том же планировщике, advanceUntilIdle() выполняет её до конца, и тест становится детерминированным.
Как поймать в следующий раз: правило Detekt InjectDispatcher помечает захардкоженные Dispatchers.IO/Default в продакшен-коде; подозрительный тест стоит повторять до первого падения в конфигурации запуска, чтобы воспроизвести гонку локально.
Проверка стоит внутри collect, поэтому выполняется, только если событие пришло: когда pay() ничего не отправляет, не срабатывает ни одна проверка, и тест проходит впустую. События нужно собирать в список (или использовать Turbine) и проверять после действия, и тогда пропуск события роняет тест.
Почему тест зелёный: тест падает, только когда что-то бросает исключение. collect у SharedFlow никогда не завершается; коллектор живёт в backgroundScope, который runTest молча отменяет в конце. «События нет» и «пришло нужное событие» заканчиваются одинаково.
Исправление: записывать каждую эмиссию и проверять весь список после advanceUntilIdle(). Пропущенное событие теперь роняет проверку (ожидался список с OpenReceipt, получен пустой), как и повторная эмиссия.
С Turbine: vm.events.test { vm.pay(); assertEquals(Event.OpenReceipt, awaitItem()) }. awaitItem() падает по таймауту (по умолчанию 3 секунды), если ничего не пришло, а необработанные события роняют тест в конце test { }.
Та же ловушка в других местах: проверки внутри колбэков, наблюдателей observeForever или блоков onEach ничего не доказывают, если колбэк так и не вызвался.
Как поймать в следующий раз: каждый новый тест должен хотя бы раз упасть: сломайте строку в продакшен-коде и убедитесь, что тест покраснел (красный шаг TDD). Мутационное тестирование (PIT) автоматизирует эту проверку для всего набора.
Соберите каждую часть сами: у SavedStateHandle(mapOf(...)) публичный конструктор, строки приходят через внедрённый интерфейс вместо Context, а TestLifecycleOwner позволяет тесту двигать жизненный цикл. Устройство для этого не нужно.
**SavedStateHandle**: SavedStateHandle(mapOf("orderId" to 42L)) задаёт аргументы навигации или восстановленное состояние. Прочитайте handle["query"] обратно, чтобы доказать, что ViewModel записала то, что должно пережить смерть процесса.
Смерть процесса: выживает только бандл сохранённого состояния, поэтому создайте ViewModel из той же map и проверьте, что она восстановилась; ActivityScenario.recreate() сохраняет ViewModel и ничего здесь не доказывает.
**Context**: оберните получение ресурсов в интерфейс StringProvider. Если ресурсы действительно нужны, берите ApplicationProvider.getApplicationContext() под Robolectric; замоканный Context возвращает null на каждый незастабленный вызов.
Жизненный цикл: TestLifecycleOwner из lifecycle-runtime-testing: выставьте currentState = STARTED, и колбэки DefaultLifecycleObserver и блоки repeatOnLifecycle выполнятся. Без него в unit-тесте жизненный цикл никто не двигает.
Robolectric выполняет настоящий код Android-фреймворка на JVM, заменяя нативные части и системные сервисы «тенями» (shadows), и тесты с Context, ресурсами или Activity работают в src/test за секунды. Это не эмулятор: он не доказывает отрисовку, поведение прошивок OEM и работу системных сервисов.
Что вы получаете: настоящие Context, загрузку ресурсов и инфлейт разметки, SharedPreferences, Room поверх SQLite и API инструментальных тестов (ActivityScenario, Espresso, правила Compose) под @RunWith(AndroidJUnit4::class).
**Главный Looper на паузе** (по умолчанию с версии 4.4): отложенная работа выполняется, только когда вы вызываете shadowOf(Looper.getMainLooper()).idle(), и порядок остаётся детерминированным.
Нет настоящей отрисовки, если не включён @GraphicsMode(NATIVE), поэтому измеренные размеры и раскладка текста ничего не доказывают.
Shadows лишь приближения: ShadowConnectivityManager возвращает то, что вы выставили, а не ведёт себя как радиомодуль, теряющий запрос.
Только AOSP: нет особенностей разрешений у OEM, фоновых ограничений, настоящей смерти процесса и цифр производительности.
Цена: он скачивает jar android-all на каждый уровень SDK и стартует медленнее обычного JVM-теста; каждый дополнительный уровень в @Config(sdk = [...]) прогоняет класс заново.
Правило: Robolectric подходит для логики, задевающей фреймворк; устройство нужно для всего, что вы бы снимали скриншотом, измеряли или списывали на ОС.
Перед каждым perform и check Espresso ждёт, пока опустеет очередь сообщений главного потока и все зарегистрированные IdlingResource станут idle. Работу на других потоках (OkHttp, Dispatchers.IO, свой executor) он не видит, поэтому нужен IdlingResource или фейк вместо этой работы.
Покрыто бесплатно: отправленные в очередь сообщения, проходы layout, LiveData и flow, собираемые на Dispatchers.Main.
Не покрыто: фоновые потоки, WorkManager, postDelayed далеко в будущем. Симптомом служит NoMatchingViewException на экране, которому до правильного состояния оставался один кадр.
**CountingIdlingResource**: increment() перед работой, decrement() в finally, регистрация через IdlingRegistry в @Before и снятие в @After. Если пропустить finally на пути ошибки, каждый следующий тест упадёт с IdlingResourceTimeoutException по таймауту ожидания idle.
Бесконечные анимации, например неопределённый ProgressBar, держат очередь занятой: testOptions.animationsDisabled = true отключает системные анимации, свои зацикленные отключайте в тестах сами.
**Thread.sleep** замедляет каждый прогон и всё равно остаётся флаки.
Часто лучше всего обойтись без idling resource: внедрите фейковый репозиторий или синхронный executor, чтобы работа завершилась до возврата из perform().
Finder-ы Compose по умолчанию ищут в объединённом (merged) дереве семантики, а Button сливает своих детей в один узел. testTag дочернего элемента при слиянии теряется, поэтому ни один узел не несёт "price". Используйте useUnmergedTree = true или проверяйте объединённый узел кнопки.
Слияние: Button, Modifier.clickable и Modifier.semantics(mergeDescendants = true) схлопывают поддерево в один узел, текстом которого становится список текстов всех детей. Именно этот узел читает TalkBack.
Исправление 1: onNodeWithTag("price", useUnmergedTree = true) находит дочерний элемент.
Исправление 2: повесьте тег на кнопку и проверяйте узел целиком: assertTextEquals("Socks", "12.00 €"), или просто onNodeWithText("12.00 €"), который совпадает с объединённым узлом.
Отладка: onRoot(useUnmergedTree = true).printToLog("TREE") печатает каждый узел со свойствами; сравните с объединённым деревом.
Логику воркера тестируйте напрямую через TestListenableWorkerBuilder и вызов doWork(); планирование проверяйте через WorkManagerTestInitHelper, SynchronousExecutor и TestDriver, который позволяет объявить ограничения (constraints) и задержки выполненными.
Логика: TestListenableWorkerBuilder<UploadWorker>(context).setInputData(...).build(), затем doWork() и проверка Result (success, retry, failure). Планировщик не участвует; setWorkerFactory(...) передаёт зависимости, например в @HiltWorker.
Связка: initializeTestWorkManager(context, config) с SynchronousExecutor, поставить запрос в очередь и убедиться, что он ENQUEUED, затем getTestDriver(context)!!.setAllConstraintsMet(id). Работа выполняется синхронно, поэтому состояние уже SUCCEEDED. setInitialDelayMet и setPeriodDelayMet делают то же для задержек и периодической работы.
Где запускать: обоим нужен Context, поэтому под Robolectric или как инструментальные тесты, с артефактом work-testing.
Держите воркер тонким: пусть он делегирует внедрённому классу; этот класс тестируйте на JVM, а обвязку планировщика проверяйте, только чтобы доказать связку.
Screenshot-тест отрисовывает экран, попиксельно сравнивает его с утверждённым эталоном (golden) и падает при расхождении. Инструменты различаются тем, что рисует пиксели (layoutlib на JVM, нативная графика Robolectric на JVM или настоящее устройство), и от этого выбора зависит стабильность.
Варианты
Paparazzi рисует через layoutlib, тот же движок, что у превью в Android Studio. Без эмулятора, очень быстро, настройки устройства задаются просто данными (DeviceConfig, fontScale, локаль, тёмная тема). Но настоящего фреймворка нет: перед снимком нельзя взаимодействовать с экраном, а текст отрисовывается layoutlib, а не устройством.
Roborazzi снимает из Robolectric с @GraphicsMode(NATIVE), поэтому сначала можно докликать до нужного состояния через API Compose или Espresso. Медленнее Paparazzi, но тоже без эмулятора; recordRoborazziDebug записывает эталоны, verifyRoborazziDebug валит сборку.
Compose Preview Screenshot Testing: официальный плагин AGP, который отрисовывает ваши @Preview-функции без тестового кода; меньше всего работы, если фиксировать нужно именно состояния из превью.
На устройстве: максимальная достоверность и худшая стабильность: эталон зависит от конкретного системного образа.
Что держит эталон стабильным
Зафиксируйте рендерер: одна версия библиотеки, один API level, одна JVM; записывайте эталоны в том же CI-контейнере, который их проверяет, а не на ноутбуке.
Зафиксируйте входные данные: локаль, часовой пояс, fontScale, плотность, тёмную тему, встроенный шрифт, фиксированный Clock и заданные данные, чтобы «2 минуты назад» не менялось.
Заморозьте движение и I/O: никаких бесконечных анимаций и мигающего курсора; картинки из ресурсов или из фейкового загрузчика изображений.
Допуск держите около нуля: порог, достаточный, чтобы скрыть шум сглаживания, скроет и настоящие регрессии.
Эталоны ловят то, что не поймают проверки семантики: отступы, обрезку текста, контраст, RTL. Ревьюйте диффы изображений как код.
Чаще всего флаки-тесты возникают из четырёх источников: тайминг (анимации, sleep, несинхронизированная фоновая работа), реальные внешние зависимости (сеть, часы), состояние, общее для тестов, и незафиксированное устройство. Сначала измерьте частоту падений каждого теста, и картина подскажет источник.
Найти причину
Записывайте каждый прогон по каждому тесту: имя, результат, длительность, устройство. Один тест, падающий в разных прогонах, указывает на гонку в этой фиче; много тестов, упавших в одном прогоне, говорят об окружении; тест, падающий только после другого, выдаёт общее состояние.
Гоняйте подозреваемый тест отдельно много раз: фильтр через -Pandroid.testInstrumentationRunnerArguments.class=.... Если один он никогда не падает, а в наборе падает, значит, дело в порядке и состоянии, а не в тайминге.
Включите Android Test Orchestrator с clearPackageData: каждый тест получает свежий процесс и очищенные данные приложения. Если флак исчез, значит, было общее состояние.
Исправлять по причине
Тайминг: testOptions.animationsDisabled = true, свои зацикленные анимации выключены в тестах, каждый Thread.sleep заменён на IdlingResource или waitUntil в Compose.
Внешние зависимости: MockWebServer или фейки вместо staging, внедрённый Clock.
Общее состояние: Orchestrator, in-memory базы, свежий аккаунт или заданные данные на каждый тест.
Устройство: Gradle Managed Devices с зафиксированным системным образом (ATD-образы для headless CI) вместо того, что окажется на раннере.
Сделать падения воспроизводимыми
TestWatcher, который при падении сохраняет скриншот, иерархию view или семантики и свежий logcat. Многие «не воспроизводится» оказываются системным диалогом, экраном блокировки или ANR.
Ретраи допустимы только со списком карантина и тикетом на каждую запись; автоматически перезапускаемый тест без владельца перестаёт что-либо проверять.
Покрытие строк фиксирует, какие строки выполнились, а не какие поведения проверены, поэтому набор тестов без единой проверки может набрать 80%. Ставьте порог на покрытие строк, которые меняет PR, не давайте общему числу падать и доказывайте реальность проверок мутационным тестированием.
Чем это число не является
Тест, который вызывает метод и ничего не проверяет, даёт тот же процент, что и тест, фиксирующий каждый случай. Надёжно покрытие говорит только о том, что не протестировано.
На Android оно ещё и завышено: сгенерированный код учитывается, если его не исключить (компоненты Hilt, *_Factory, сериализаторы, методы data class), а одно нажатие в инструментальном тесте выполняет тысячи строк по всему стеку.
Инструменты: Kover (org.jetbrains.kotlinx.kover) лучше «сырого» JaCoCo понимает синтетический код Kotlin; в AGP это переключатели enableUnitTestCoverage и enableAndroidTestCoverage для типа сборки.
Пороги, которые стоит иметь
Покрытие изменений плюс запрет на падение (ratchet): требуйте покрытия строк, изменённых в PR, и запрещайте числу по модулю падать (koverVerify). Давление приходится туда, где меняется поведение.
Регрессионный тест к каждому багфиксу: дёшево, и обмануть невозможно.
Мутационное тестирование там, где баги стоят денег: Pitest меняет > на >=, удаляет вызов, инвертирует условие и перезапускает тесты; выживший мутант указывает на строку, которая выполняется, но не проверяется. Для каждого PR слишком медленно, поэтому гоняйте его ночью на чистых Kotlin-модулях домена.
Форма набора тестов: много JVM-тестов, плотная середина из Robolectric и Compose-тестов, несколько end-to-end сценариев как smoke-тесты.
Честный ответ на «80% везде»: жёсткие пороги для модулей, где баг стоит реальных денег (платежи, синхронизация, авторизация), а презентационные модули закрывать screenshot-тестами вместо процента.