Юнит-тесты Android: где ломается логика и как это ловить
Логика Android-приложения ломается не в вёрстке, а в асинхронных сценариях. Экран открылся, пользователь ушёл в фон, вернулся — и видит устаревшие данные, потому что ответ сети пришёл после отмены запроса. Такие дефекты не воспроизводятся вручную стабильно: они зависят от порядка событий, скорости сети и жизненного цикла. Юнит-тесты Android фиксируют именно этот порядок — подменяют сеть и хранилище, подают ответы в нужной последовательности и проверяют, что состояние экрана приходит в ожидаемый вид.
Второе узкое место — состояние экрана. Загрузка, ошибка, пустой результат и успех часто смешиваются в одном флаге, и при быстром переключении интерфейс показывает комбинацию, которой не должно быть: спиннер поверх ошибки или пустой список вместо данных из кэша. Тест на переходы состояний ловит такие комбинации до того, как их увидит пользователь.
Третье — обработка ошибок и кэш. Что происходит при таймауте, при невалидном ответе, при рассинхроне локальных данных с сервером? Без покрытия логики эти ветки остаются непроверенными, и первая же нештатная ситуация уходит в релиз как регрессия. Юнит-тесты прогоняют каждую ветку изолированно — без эмулятора и устройства, за секунды, поэтому их можно гонять на каждое изменение.
Смысл не в процентах покрытия ради отчётности, а в том, какие классы отказов закрыты. Мы покрываем в первую очередь асинхронные сценарии, переходы состояния экрана, работу с кэшем и обработку ошибок — то, что чаще всего ломается при доработках. Дальше разбираем, как это устроено в проекте.
Что даёт покрытие тестами: измеримый эффект
Юнит-тесты Android дают команде то, чего не даёт ни один этап ручного тестирования — обратную связь за секунды вместо часов. Пока сборка и прогон на эмуляторе идут минутами, набор модульных проверок на JUnit и MockK отрабатывает за время, сопоставимое с компиляцией, и разработчик узнаёт о поломке сразу после сохранения файла, а не на этапе регрессии перед выкладкой. Это меняет саму механику работы: правка в бизнес-логике перестаёт быть риском, потому что её последствия видны немедленно.
Доля дефектов, которые ловятся до код-ревью, растёт предсказуемо — граничные значения, пустые ответы, ошибки парсинга и ветки обработки исключений закрываются тестами на уровне функций и классов. Чем плотнее покрыты сценарии ViewModel и слоя работы с данными, тем меньше ошибок доходит до сборки и тем реже релиз тормозится из-за находок на приёмке.
Стабильность релизов — прямое следствие. Когда логика зафиксирована тестами, рефакторинг и обновление зависимостей перестают быть лотереей: любое расхождение в поведении всплывает на прогоне, а не у пользователей. Откат и повторная выкладка нужны реже, а сам цикл выпуска становится короче.
Мы выстраиваем покрытие там, где оно окупается: критичные сценарии, расчёты, работа с сетью и хранилищем — под тестами, второстепенные экраны не тормозят работу команды лишними проверками. Опишите задачу — вернёмся с оценкой и планом покрытия под ваш проект.
Подходы к тестированию: сравнение вариантов
Выбор подхода определяется тем, что именно проверяем и насколько дорого нам даётся обратная связь. Чистые юнит-тесты изолируют логику без Android-окружения и дают результат за секунды, но не ловят ошибки стыковки слоёв. Тесты с подменой зависимостей закрывают взаимодействие с сетью, хранилищем и системными сервисами, оставаясь быстрыми. Интеграционные проверки собирают реальные компоненты и подтверждают, что модули работают вместе — ценой времени прогона и хрупкости к изменениям окружения.
| Подход | Когда подходит | Ограничения |
|---|---|---|
| Чистые юнит-тесты | Бизнес-логика, расчёты, разбор ответов, граничные условия | Не проверяют реальные зависимости и поведение платформы |
| Тесты с подменой зависимостей | Сценарии с сетью, базой, системными сервисами; проверка ветвлений и ошибок | Подмена может расходиться с реальным поведением зависимости |
| Интеграционные проверки | Стыковка слоёв, работа с хранилищем, сквозные сценарии | Долгий прогон, чувствительность к окружению, сложнее локализовать сбой |
Как сочетать подходы
Практика — пирамида: основа из чистых юнит-тестов на бизнес-логику, средний слой с подменой зависимостей на взаимодействия, вершина из интеграционных проверок на критичные пути. Такое распределение держит прогон быстрым, а покрытие осмысленным: большинство дефектов отлавливается на нижних слоях, где их дешевле исправлять.
Ограничения, которые важно учесть
Ни один подход не заменяет остальные. Чистые тесты не поймают несовместимость версий библиотек, подмена — расхождение контракта с реальным сервисом, интеграционные проверки — редкие граничные случаи логики. Поэтому набор строится под риски конкретного приложения, а не по шаблону.
Как мы внедряем тесты: этапы работы
Внедрение тестов начинается не с написания кода, а с инвентаризации: какие модули вообще существуют, где сосредоточена логика, а где — только разметка экранов. На этом этапе мы смотрим историю изменений и находим классы, которые правятся чаще всего: именно там ошибки дороже всего, а тесты дают наибольший эффект.
- Аудит кода. Разбираем структуру проекта, зависимости между модулями, качество разделения слоёв. Отмечаем места, где логика перемешана с обращением к сети или базе, — их придётся слегка разделить, иначе тест не изолировать без внешних систем.
- Схема покрытия. Фиксируем, что именно проверяем: расчёты, преобразование данных, обработку ошибок, граничные значения. Договариваемся о целевой доле покрытия по критичным модулям — не «всё подряд», а там, где цена ошибки выше.
- Настройка окружения. Поднимаем тестовый контур в сборке: отдельный набор задач, отчёт о покрытии, автоматический запуск на каждое изменение. Тесты, которые нужно запускать вручную, перестают запускать через две недели.
- Написание тестов. Идём от критичного к второстепенному. Каждый тест проверяет одно поведение и не зависит от порядка запуска. Внешние зависимости подменяем управляемыми двойниками, поэтому набор проходит за секунды и не требует сети.
- Проверка на реальных сценариях. Прогоняем набор на изменениях, которые уже ломали приложение раньше, — убеждаемся, что тесты их ловят. Тест, который всегда зелёный, хуже отсутствующего: он создаёт ложную уверенность.
- Передача. Отдаём набор, инструкцию по запуску, правила добавления новых тестов и разбор типовых ошибок. Проводим разбор с вашей командой, чтобы поддержка не зависела от нас.
Ключевой критерий на выходе — не число тестов, а время до обнаружения регрессии: ошибка должна всплывать на сборке, а не в магазине приложений. Опишите задачу — вернёмся с оценкой и планом по вашему проекту.
Артефакты и регламенты: что остаётся в проекте
После сдачи проекта в репозитории остаётся не «набор тестов», а работающий контур проверок, который можно запускать без автора. Ниже — что именно передаём на руки и как этим пользуется команда заказчика.
- Тестовые наборы по слоям. Домен и бизнес-логика покрыты быстрыми тестами без Android-зависимостей, работа с хранилищем и сетью — отдельными наборами с подменой внешних вызовов. Каждый набор запускается изолированно, поэтому падение локализуется за секунды, а не за прогон всего проекта.
- Конфигурации запуска. Готовые профили для локального прогона, для проверок на каждом изменении и для ночной сборки с полным покрытием. Разработчик запускает нужный профиль одной командой, без ручной настройки окружения.
- Схемы зависимостей. Диаграммы, показывающие, какие модули от чего зависят и где проходит граница подмены. По ним видно, почему тест падает при изменении конкретного класса, и куда добавлять новые проверки.
- Отчёты о покрытии. Настроенная сводка по модулям с порогом, ниже которого сборка не проходит. Видно, какие ветки логики не проверены, — это список задач, а не абстрактная цифра.
- Документация. Соглашения об именовании тестов, правила оформления тестовых данных, описание того, что считается достаточным покрытием для нового кода.
- Регламенты запуска. Когда тесты обязательны, что блокирует слияние изменений, кто разбирает упавшие проверки и в какой срок. Правила зафиксированы в репозитории, а не в переписке.
- Инструкция по сопровождению. Как добавить тест под новую функциональность, как обновлять подмены при смене внешних сервисов, что делать при нестабильном тесте.
Всё это лежит рядом с кодом и версионируется вместе с ним. Новый инженер в команде разворачивает окружение и получает зелёный прогон в первый день — без устных пояснений от автора.
Кейс: сокращение регрессий после внедрения тестов
Проект — Android-приложение с оплатой и подписками, релиз раз в две недели. Тесты были, но писались через раз и в основном на «чистые» функции: логика оплаты, расчёта скидок и восстановления подписки оставалась непокрытой. Регрессии ловили на ручном прогоне перед выкладкой, и примерно каждая четвёртая доходила до пользователей — чаще всего в виде повторных списаний и «зависших» статусов заказа.
Что изменили
Разделили логику и платформенный код: бизнес-правила вынесли в отдельные классы без зависимостей от Android-окружения, а внешние сервисы — оплату, хранилище, сеть — закрыли интерфейсами. Дальше на MockK описали поведение этих интерфейсов, включая отказы и таймауты, а JUnit 5 дал параметризованные проверки: один набор граничных значений вместо десятка почти одинаковых методов.
Тесты гоняются на каждый коммит, поэтому сборка падает до ревью, а не после. Отдельно зафиксировали правило: новая ветка логики оплаты не проходит проверку, если не покрыта сценариями ошибок — не только успешным путём.
Метрики до и после
| Показатель | До | После |
|---|---|---|
| Доля дефектов, дошедших до пользователей | ~25% | ~6% |
| Ручная проверка релиза | 2 дня | 4-6 часов |
| Сборки с падением на интеграции | каждая третья | единичные |
| Покрытие логики оплаты | фрагментарное | основные и ошибочные сценарии |
Ручной прогон сократился не потому, что проверок стало меньше, а потому что критичные пути теперь отсекаются автоматически. Освободившееся время ушло на проверку того, что тестами не закрывается: поведение на реальных устройствах, работа с платежными диалогами, восстановление после сворачивания приложения.
Через два релиза доля дефектов стабилизировалась на новом уровне, а откат версии перестал быть штатным сценарием.
Чем юнит-тесты отличаются от интеграционных?
Юнит-тесты проверяют один модуль в изоляции: все внешние зависимости — репозитории, сетевые клиенты, системные сервисы — подменяются управляемыми двойниками. Запускаются такие проверки на JVM, без эмулятора и устройства, поэтому весь набор из сотен сценариев проходит за секунды. Изоляция модулей здесь не самоцель: она убирает ложные падения, когда тест падает из-за недоступного сервера или чужого кода, а не из-за ошибки в проверяемой логике.
Интеграционные тесты работают на стыке модулей и проверяют, что они действительно стыкуются: сериализация запроса и разбор ответа, поведение кэша при смене данных, корректность запросов к локальной базе. Здесь уже поднимается реальное окружение — тестовая база, подставной сервер, иногда эмулятор. Такие проверки идут дольше и падают чаще, зато ловят класс ошибок, который изолированный тест в принципе не увидит.
Граница проверок проходит по вопросу «чья это ответственность». Логика расчёта, валидация полей, разбор состояний экрана, преобразование моделей — это юнит-тесты, потому что результат зависит только от входных данных. Взаимодействие с сетью, базой, файловой системой, разрешениями и жизненным циклом — интеграционные, потому что результат зависит от окружения. Смешивать их в одном наборе нельзя: теряется скорость и точность локализации ошибки.
На практике набор строится пирамидой: основная масса быстрых изолированных проверок на бизнес-логику, поверх — интеграционные на критичные стыки, и немного сквозных сценариев на главные пользовательские пути. Такое распределение даёт раннюю обратную связь в процессе разработки и не превращает прогон перед релизом в многочасовое ожидание. Если граница размыта и всё сваливается в интеграционные тесты, команда перестаёт запускать их локально — и проверки перестают защищать код.
Частые технические сомнения
Тестируемость Android-приложения редко упирается в сам фреймворк — она упирается в архитектуру, которая сложилась до того, как о тестах вообще задумались. Ниже — четыре вопроса, которые технические руководители задают чаще всего, и то, как мы на них отвечаем на уровне кода, а не общих слов.
У нас легаси-код без тестов — с чего вы начинаете?
Сначала фиксируем текущее поведение тестами-характеристиками: покрываем самые нагруженные сценарии так, как они работают сейчас, включая странности. Это страховка перед рефакторингом — после каждого изменения прогон показывает, что поведение не поехало. Дальше выносим логику из активити и фрагментов в отдельные классы, которые можно проверить без эмулятора: тест на чистой логике выполняется за доли секунды, и весь прогон перестаёт быть узким местом сборки.
Как тестировать асинхронность и корутины, чтобы тесты не «мигали»?
Подменяем диспетчеры на тестовый планировщик и продвигаем виртуальное время вручную — тест перестаёт зависеть от реальных задержек и сетевых таймаутов. Асинхронный код проверяется детерминированно: один и тот же прогон даёт один и тот же результат и на локальной машине, и на сервере сборки. Отдельно покрываем отмену корутин и обработку ошибок — именно там чаще всего прячутся дефекты, которые не ловятся ручным тестированием.
Насколько глубоко вы подменяете зависимости?
Мы изолируем внешние границы — сеть, базу, файловую систему, системное время — и оставляем настоящей ту логику, которую проверяем. Мок на каждый чих делает тест хрупким: он падает при безобидном рефакторинге и ничего не говорит о поведении. Подмена зависимостей строится на интерфейсах, поэтому тест переживает смену сетевого клиента или хранилища без переписывания.
Кто поддерживает тесты после сдачи проекта?
Тесты — часть кодовой базы, а не отдельный артефакт: они лежат рядом с кодом, проходят ревью в том же запросе на слияние и запускаются на каждом изменении. Мы передаём команде соглашения о структуре тестов и разбор типовых сценариев, чтобы новый разработчик писал их в том же стиле. Если тест устаревает вместе с требованием — он правится или удаляется, а не отключается навсегда.
Опишите вашу кодовую базу и текущее покрытие — вернёмся с оценкой и планом, какие участки закрывать в первую очередь.
Обсудим задачу и вернёмся с планом
Чтобы вернуться с планом, а не с общими словами, нам нужно понять устройство вашего Android-проекта и текущее состояние тестов. Пришлите доступ к репозиторию или выгрузку модулей — посмотрим, как разбит код по слоям, где проходит граница с сетью и хранилищем, какие зависимости внедряются через конструкторы, а какие тянутся статикой. От этого зависит, где юнит-тесты лягут без переписывания продакшн-кода, а где потребуется сначала развязать связанные компоненты.
Отдельно уточним требования: какие сценарии для вас критичны (расчёт бизнес-логики, обработка ошибок сети, кэш, валидация), какой уровень покрытия считаете достаточным и есть ли уже тесты, которые нельзя ломать. Если в проекте используется DI-контейнер и асинхронность на корутинах, скажите — под неё настраивается тестовый диспетчер, чтобы проверки шли детерминированно, без случайных падений.
Что подготовим к следующему шагу:
- карту модулей и слоёв, где тесты дают максимальный эффект при минимальных правках;
- перечень классов под проверку с приоритетом по риску регрессий;
- схему моков для внешних зависимостей — сеть, хранилище, системное время;
- правила изоляции тестов, чтобы они не зависели от порядка запуска;
- конфигурацию запуска в сборке и отчёт о покрытии;
- оценку объёма работ по этапам с точками промежуточной приёмки;
- план внедрения проверок в конвейер сборки, чтобы регрессии ловились до релиза.
Опишите задачу в свободной форме: что за приложение, на какой стадии разработки, что болит сейчас — частые регрессии, страх перед рефакторингом или подготовка к передаче кода другой команде. Этого достаточно, чтобы вернуться с оценкой, планом работ и предложением по следующему шагу.







