Тестирование мобильного: юнит, UI, устройства, фермы, бета-каналы
На бэкенде сломанный релиз живёт до следующего деплоя — минуты. На вебе — до следующего
Ctrl+F5 пользователя. На мобильном сломанный релиз живёт месяцами, потому что откатить его
нельзя: в сторе нет кнопки «вернуть предыдущую версию» для тех, кто уже обновился. Единственный
способ починить — собрать новую версию, пройти ревью и дождаться, пока каждый пользователь
скачает обновление. Часть аудитории не обновится никогда: у кого-то выключено автообновление,
у кого-то кончилось место, кто-то остался на устройстве, которое ваша новая версия уже не
поддерживает.
Отсюда простая арифметика, из которой вытекает всё остальное в этой статье. Стоимость пропущенного дефекта на мобильном равна доле затронутых пользователей, умноженной на время до исправления, а время до исправления — это не «время до мержа», а «время до мержа плюс сборка плюс ревью в сторе плюс раскатка плюс инерция обновлений». В худшем случае — недели. Именно поэтому мобильные команды, которые в вебе спокойно жили с «потестим на проде», на мобильном внезапно обнаруживают у себя фиче-флаги, поэтапную раскатку и три эшелона автотестов.
Вторая причина, по которой эту дисциплину нельзя списать с веба: окружение враждебное и неопределённое. В браузере вы боретесь с четырьмя движками. На мобильном перед вами тысячи моделей, десяток живых версий ОС, вендорские прошивки, которые убивают фоновые процессы по своим правилам, память от 2 до 16 ГБ, экраны от 4,7 до 8 дюймов со складными переходами посередине, крупный системный шрифт, тёмная тема, RTL-локали, отозванные разрешения, сеть, которая есть, но не работает, и операционная система, которая вправе убить ваш процесс в любой момент между двумя строчками кода.
Эта статья — про то, как из этого хаоса собрать управляемый набор проверок, который ловит дефекты дёшево и рано, а не в отзывах в сторе. Разбор начнём с того, чем мобильные дефекты отличаются от серверных, потом пройдём пирамиду снизу вверх, потом — устройства, фермы, флаки, бета-каналы и честная цена нативного и кроссплатформенного подхода к тестированию.
Общая теория тестирования — уровни, оракулы, техники тест-дизайна, метрики покрытия — живёт в отдельном треке: Тестирование: обзор и особенно Мобильное тестирование и совместимость. Здесь мы смотрим глазами разработчика: что писать в коде, что запускать в CI, за что платить.
Что именно ломается на мобильном
Прежде чем выбирать инструменты, полезно один раз честно составить таксономию дефектов, которые вы реально ловите в проде. Почти в любой мобильной команде она выглядит так — и, что важно, доля «чистой бизнес-логики» в ней меньше, чем ожидают пришедшие с бэкенда.
Три вывода из этой картинки. Первый: больше половины веток вообще не проверяется юнит-тестами, потому что это не про функции, а про среду. Второй: почти каждая ветка воспроизводима детерминированно, если приложить усилие, — а значит, автоматизируема. Третий: ветки «Вёрстка» и «Жизненный цикл» — самые массовые и одновременно самые дешёвые для автоматизации, если выбрать правильный уровень (снапшоты и инструментальные тесты соответственно). Именно так и деформируется классическая пирамида.
Пирамида, деформированная мобильным контекстом
Классическая пирамида говорит: много юнитов, меньше интеграционных, совсем мало E2E. На мобильном к этому добавляется слой, которого в вебе почти нет как отдельной практики, — снапшот-тесты вёрстки, исполняемые на хосте без эмулятора. Он широкий, потому что вёрстка ломается чаще логики, а проверять её на устройстве в десятки раз дороже.
Ключевая величина, вокруг которой строится вся конструкция, — время до обратной связи. Юнит-тест на JVM или в хост-процессе Swift даёт ответ за миллисекунды. Тот же сценарий, поднятый как инструментальный тест на эмуляторе, стоит секунды на тест и минуты на прогон пачки: нужно поднять эмулятор, установить APK, дождаться загрузки процесса. Он же на физическом устройстве в ферме — это ещё и очередь, и сетевые задержки, и деньги за минуту устройства. Разница между низом и верхом пирамиды — три-четыре порядка по цене одной проверки. Поэтому правило простое: проверку нужно делать на самом низком уровне, на котором она вообще возможна, а на верхние уровни поднимать только то, что физически не воспроизводится ниже.
Уровень 1: юнит-тесты, и что на мобильном значит «изолировать»
Юнит-тесты на мобильном не отличаются от юнит-тестов где угодно ещё — отличается то, что
именно вы можете изолировать. Мобильный SDK агрессивно тянет вас к синглтонам и статике:
UIApplication.shared, Context, NSUserDefaults, System.currentTimeMillis(). Каждое
такое обращение из бизнес-кода — это тест, который придётся поднимать на устройстве.
Поэтому тестируемость мобильного приложения на 90 процентов определяется архитектурой:
подробный разбор слоёв, состояния и внедрения зависимостей — в
Архитектуре мобильного приложения.
Практический критерий: вьюмодель должна собираться и исполняться в тесте без единого
объекта платформы. Если для создания вьюмодели нужен Context или UIViewController —
это архитектурный дефект, а не «специфика мобилки».
Swift, современный swift-testing (пришёл на смену XCTest для юнитов, начиная с Xcode 16):
import Testing
@testable import Wallet
// Фейк вместо сети: протокол объявлен в слое домена, реализация подставляется в тесте
struct FakeBalanceRepo: BalanceRepository {
var result: Result<Balance, RepoError>
func fetch() async throws -> Balance { try result.get() }
}
@Suite("Экран баланса")
struct BalanceViewModelTests {
@Test("Нет сети и пустой кэш — показываем оффлайн-состояние, а не бесконечный спиннер")
func offlineWithEmptyCache() async {
let vm = BalanceViewModel(repo: FakeBalanceRepo(result: .failure(.offline)),
cache: EmptyCache(),
clock: TestClock()) // время — тоже зависимость
await vm.load()
#expect(vm.state == .offline)
}
// Параметризованный тест: одна проверка, много входов — дешевле, чем пять копий
@Test("Просроченность кэша считается по монотонным часам",
arguments: [(0, false), (59, false), (61, true)])
func staleness(ageSeconds: Int, expectedStale: Bool) {
let clock = TestClock()
let cache = Cache(storedAt: clock.now)
clock.advance(by: .seconds(ageSeconds))
#expect(cache.isStale(now: clock.now, ttl: .seconds(60)) == expectedStale)
}
}
Kotlin, JUnit плюс kotlinx-coroutines-test плюс Turbine для проверки потока состояний:
class BalanceViewModelTest {
@get:Rule val dispatcherRule = MainDispatcherRule() // подменяет Dispatchers.Main
@Test
fun `при обрыве обновления остаёмся на кэше и помечаем данные устаревшими`() = runTest {
val repo = FakeBalanceRepo()
val vm = BalanceViewModel(repo)
vm.state.test { // Turbine: проверяем последовательность
assertEquals(Ui.Loading, awaitItem())
repo.emitCached(Balance(1200))
assertEquals(Ui.Data(Balance(1200), stale = false), awaitItem())
repo.failRefresh(IOException("no route to host"))
// ключевое требование продукта: данные не пропадают, появляется только пометка
assertEquals(Ui.Data(Balance(1200), stale = true), awaitItem())
cancelAndIgnoreRemainingEvents()
}
}
}
Здесь работают две вещи, которые стоит забрать в любой проект. Первая — runTest даёт
виртуальное время: delay(30.seconds) внутри корутины исполняется мгновенно, и тест
на экспоненциальный бэкофф с пятью повторами и суммарной задержкой в десять минут проходит
за миллисекунды. Вторая — состояние экрана проверяется как последовательность значений,
а не как «дёрнули метод, посмотрели поле»: именно так ловятся лишние промежуточные
перерисовки и мигание спиннера.
Аналог виртуального времени на iOS — swift-clocks от Point-Free или собственная обёртка
над ContinuousClock. Главное правило одно: никогда не пишите Thread.sleep или delay
с реальным временем в тестах. Такой тест либо медленный, либо флакующий, обычно и то и другое.
Уровень 2: данные, сеть и оффлайн — самый недооценённый слой
Слой данных на мобильном хранит источник истины (см. Данные и оффлайн), и ошибка в нём необратима: испорченную локальную базу пользователя нельзя починить деплоем. При этом он весь тестируется на хосте, за секунды.
Подмена HTTP. На iOS канонический приём — свой URLProtocol, вставленный в
URLSessionConfiguration: никаких локальных серверов и портов, полный контроль над телом,
кодом, заголовками и задержкой.
final class StubURLProtocol: URLProtocol {
static let handler = Locked<((URLRequest) throws -> (HTTPURLResponse, Data))?>(nil)
override class func canInit(with request: URLRequest) -> Bool { true }
override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
override func startLoading() {
guard let handler = Self.handler.value else { return }
do {
let (response, data) = try handler(request)
client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
client?.urlProtocol(self, didLoad: data)
client?.urlProtocolDidFinishLoading(self)
} catch {
client?.urlProtocol(self, didFailWithError: error) // имитируем обрыв соединения
}
}
override func stopLoading() {}
}
// В тесте: config.protocolClasses = [StubURLProtocol.self]
На Android то же самое делает MockWebServer из OkHttp: он поднимает настоящий сокет,
поэтому проверяет и сериализацию, и заголовки, и таймауты, и умеет обрывать соединение
посреди тела ответа (SocketPolicy.DISCONNECT_DURING_RESPONSE_BODY) — сценарий, который
на мобильном происходит постоянно, а в тестах не проверяет почти никто.
Миграции локальной схемы. Это то место, где отсутствие теста стоит дороже всего: битая миграция означает потерю данных у всех, кто обновился. Room даёт для этого готовый инструмент, и правило простое — на каждую пару соседних версий схемы должен быть тест с реальными данными.
@get:Rule
val helper = MigrationTestHelper(
InstrumentationRegistry.getInstrumentation(),
AppDatabase::class.java
)
@Test
fun migration7to8_переносит_черновики_и_ставит_статус() {
// создаём базу СТАРОЙ версии и кладём туда реальные данные пользователя
helper.createDatabase(TEST_DB, 7).use { db ->
db.execSQL("INSERT INTO draft (id, body) VALUES ('d1', 'привет')")
}
// прогоняем миграцию и заодно валидируем, что итоговая схема совпала с ожидаемой
val db = helper.runMigrationsAndValidate(TEST_DB, 8, true, MIGRATION_7_8)
db.query("SELECT body, status FROM draft WHERE id = 'd1'").use { c ->
assertTrue(c.moveToFirst())
assertEquals("привет", c.getString(0))
assertEquals("PENDING", c.getString(1)) // новая колонка получила дефолт
}
}
Очередь оффлайна и идемпотентность. Проверяется на хосте детерминированным симулятором
сети: «первые три попытки — таймаут, четвёртая — 500, пятая — успех» и утверждение, что
сервер увидел один и тот же ключ идемпотентности, а локальная запись прошла путь
pending → sending → confirmed без дублей. Такой тест ловит целый класс дефектов,
которые в проде выглядят как «у пользователя дважды списались деньги в метро».
Про сами гарантии доставки — Идемпотентность и доставка.
Уровень 3: снапшот-тесты вёрстки — лучшее соотношение цены и пользы
Снапшот-тест рендерит компонент в фиксированных условиях и сравнивает получившееся изображение с эталоном в репозитории. На мобильном это непропорционально выгодно, потому что матрица условий вёрстки огромна, а проверка на устройстве стоит дорого. Один тест умножается на набор конфигураций: светлая и тёмная тема, три размера системного шрифта, узкий и широкий экран, LTR и RTL, длинная локаль (немецкий ломает вёрстку лучше любого фаззера).
Инструменты: Paparazzi (Cash App) рендерит Android View и Compose прямо на JVM через layoutlib — без эмулятора, десятки миллисекунд на снимок; Roborazzi делает то же поверх Robolectric; на iOS — swift-snapshot-testing от Point-Free; во Flutter снапшоты встроены в фреймворк как golden-тесты.
class BalanceCardSnapshotTest {
@get:Rule val paparazzi = Paparazzi(deviceConfig = PIXEL_5)
@Test fun `карточка баланса во всех критичных конфигурациях`() {
listOf(
"light_normal" to Config(dark = false, fontScale = 1.0f, locale = "ru"),
"dark_normal" to Config(dark = true, fontScale = 1.0f, locale = "ru"),
"light_xxl" to Config(dark = false, fontScale = 2.0f, locale = "ru"), // крупный шрифт
"rtl_long" to Config(dark = false, fontScale = 1.0f, locale = "ar"), // RTL + длинные строки
).forEach { (name, cfg) ->
paparazzi.snapshot(name) { BalanceCard(state = LONG_NAME_STATE, config = cfg) }
}
}
}
testWidgets('карточка баланса: тёмная тема и увеличенный шрифт', (tester) async {
tester.platformDispatcher.textScaleFactorTestValue = 1.8;
addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue);
await tester.pumpWidget(const App(theme: AppTheme.dark, state: longNameState));
await expectLater(
find.byType(BalanceCard),
matchesGoldenFile('goldens/balance_card_dark_xl.png'),
);
});
Честные подводные камни, о которых обычно узнают уже после внедрения:
- Эталоны различаются между платформами разработчика и CI. Разные версии рендерера, сглаживание шрифтов, GPU. Лечится только тем, что эталоны генерирует одна среда: либо контейнер с фиксированной версией, либо CI, а разработчик обновляет их командой, которая запускает ровно тот же образ. Иначе каждый пул-реквест переписывает все PNG.
- Репозиторий пухнет. Сотни PNG по 30–200 КБ — это десятки мегабайт и постоянные бинарные диффы. Git LFS или отдельное хранилище эталонов обязательны с самого начала.
- Легко замылить глаз. Разработчик видит «упало 40 снимков», нажимает «обновить эталоны» и не замечает, что вместе с намеренным изменением отступа уехал текст в арабской локали. Противоядие — ревью картинок-диффов как части пул-реквеста, а не как приложения к нему.
- Снапшот не проверяет поведение. Он проверяет, что нарисовано, а не что произойдёт по нажатию. Это дополнение к юнит- и инструментальным тестам, а не замена.
Отдельный бонус: снапшот-тесты естественно ловят регрессии доступности, если рядом прогонять автоматические проверки контраста и размеров зон нажатия — см. Тестирование доступности.
Уровень 4: инструментальные UI-тесты на эмуляторе и устройстве
Здесь тест исполняется вместе с приложением на настоящей ОС. Это единственный уровень, где проверяются вещи, ради которых мобильное приложение вообще существует как приложение: навигация, разрешения, смерть процесса, диплинки, прерывания.
Стек по платформам: XCUITest на iOS, Espresso плюс androidx.compose.ui.test и
UIAutomator на Android (UIAutomator нужен, когда надо выйти за пределы своего приложения —
системный диалог разрешений, шторка уведомлений, настройки). Кроссплатформенные варианты —
Maestro, Appium, Detox (React Native), integration_test (Flutter).
func testОплатаБезСетиПоказываетБаннерИНеТеряетСумму() {
let app = XCUIApplication()
// Детерминированный стенд задаётся снаружи: тест не должен зависеть от бэкенда
app.launchArguments += ["-uiTesting", "1"]
app.launchEnvironment["STUB_SCENARIO"] = "offline_after_first_load"
app.launch()
app.textFields["amount_field"].tap()
app.textFields["amount_field"].typeText("1500")
app.buttons["pay_button"].tap()
// Явное ожидание условия, а не sleep: XCUITest сам опрашивает дерево доступности
XCTAssertTrue(app.staticTexts["offline_banner"].waitForExistence(timeout: 5))
XCTAssertEqual(app.textFields["amount_field"].value as? String, "1500")
}
@Test fun восстановление_состояния_после_смерти_процесса() {
val restorationTester = StateRestorationTester(composeRule)
restorationTester.setContent { PaymentScreen() }
composeRule.onNodeWithTag("amount_field").performTextInput("1500")
// эмулирует сохранение и восстановление состояния так, как это делает система
restorationTester.emulateSavedInstanceStateRestore()
composeRule.onNodeWithTag("amount_field").assertTextEquals("1500")
}
Три технических правила этого уровня, нарушение которых превращает набор в источник боли.
Правило первое: никаких sleep. Espresso синхронизируется с очередью главного потока
автоматически, но не знает про ваши фоновые корутины и анимации; Compose-тесты умеют
waitUntil { ... } и управляемые часы mainClock; XCUITest ждёт появления элемента.
Любое фиксированное ожидание либо слишком короткое (флак), либо слишком длинное
(медленный набор). Если ждать нечего — значит, приложение не сообщает о готовности, и
это тоже дефект: добавьте idling-ресурс или тестовый сигнал.
Правило второе: локаторы — это контракт, и лучше, если он совпадает с доступностью.
accessibilityIdentifier на iOS и testTag/contentDescription на Android дают стабильные
селекторы и одновременно улучшают работу VoiceOver и TalkBack. Поиск по видимому тексту
ломается при первой же смене формулировки или локали.
Правило третье: стенд, а не прод. UI-тест, ходящий в реальный бэкенд, тестирует бэкенд, сеть и данные в базе стенда. Подменяйте сетевой слой при старте (флагом запуска, как выше) либо поднимайте локальный стаб внутри процесса приложения.
Флаки: главный налог на верхние уровни пирамиды
Флак — тест, который на одном и том же коде иногда падает. На мобильном флаки неизбежны: эмулятор конкурирует за CPU с другими джобами, анимация занимает на 16 мс больше, сборщик мусора решил сработать между двумя проверками, ферма выдала устройство с зависшим системным диалогом. Вопрос не «как избавиться», а «как удержать долю на уровне, при котором красный CI ещё что-то значит».
Практический порог: если больше 1 процента прогонов падает без причины, команда перестаёт верить красному цвету и начинает перезапускать всё подряд — с этого момента автотесты не защищают ничего. Поэтому flake rate измеряют как отдельную метрику и относятся к нему как к дефекту продукта.
на том же коммите?"} B -- "да" --> C["Настоящий дефект или сломанный тест.
Чинить сейчас, релиз стоит"] B -- "нет" --> D{"Перезапуск того же коммита
проходит зелёным?"} D -- "нет" --> E["Инфраструктура: образ эмулятора,
очередь фермы, сеть CI, кончилось место"] D -- "да" --> F["Флак. Помечаем и заводим задачу,
а не молча перезапускаем"] F --> G{"Есть ли в тесте sleep
или ожидание без условия?"} G -- "да" --> H["Заменить на ожидание условия
или idling-ресурс"] G -- "нет" --> I{"Тест зависит от общего состояния:
кэш, база, аккаунт, время?"} I -- "да" --> J["Изолировать: чистый запуск,
свой тестовый аккаунт, фиксированные часы"] I -- "нет" --> K{"Воспроизводится при
100 прогонах подряд?"} K -- "да" --> L["Гонка в приложении.
Это дефект продукта, не теста"] K -- "нет" --> M["Карантин на срок,
владелец и дедлайн"] M --> N["Не починили к дедлайну — удалить.
Вечный карантин хуже отсутствия теста"]
Что работает на практике:
- Автоматический ретрай ровно один раз и только на верхних уровнях. Юнит-тест не имеет
права флакать — если флакает, там гонка. Для инструментальных тестов один повтор гасит
инфраструктурный шум, но результат обязан быть виден в отчёте: «прошёл со второй попытки»
— это не «прошёл». Firebase Test Lab умеет
--num-flaky-test-attempts, но пользоваться им нужно с открытыми глазами. - Карантин с владельцем и дедлайном. Тест выводится из блокирующего набора, но продолжает гоняться ночью, и его нестабильность видна на дашборде.
- Стрессовый прогон.
--num-runs 50на подозрительном тесте ночью даёт статистику быстрее, чем недели наблюдений. - Фиксация всего недетерминированного: часы, генератор случайных чисел, порядок тестов,
анимации (
adb shell settings put global window_animation_scale 0), сетевые ответы.
Где запускать: симулятор, эмулятор, физическое устройство, ферма
| Среда | Время до результата | Цена | Что ловит | Чего не поймает никогда |
|---|---|---|---|---|
| Хост-процесс (JVM, Swift на macOS) | миллисекунды | почти ноль | логику, данные, вёрстку через снапшоты | всё, что связано с ОС и железом |
| Симулятор iOS | секунды | ноль сверх CI | навигацию, разрешения, диплинки, вёрстку | реальную производительность, камеру, биометрию по-настоящему, память |
| Эмулятор Android | секунды–минуты | CPU-минуты CI | то же плюс смерть процесса, Doze, системные диалоги | вендорские прошивки, GPU-специфику, троттлинг |
| Физическое устройство у разработчика | минуты | цена устройства | всё, кроме масштаба | воспроизводимость и параллелизм |
| Облачная ферма | минуты + очередь | 1–5 USD за час устройства | зоопарк моделей, реальные ОС, реальный GPU | экзотику, которой нет в каталоге фермы |
| Своя ферма устройств | минуты | железо + человек на поддержку | ровно ваш парк устройств | покой инженера, который её чинит |
Ключевая мысль таблицы: эмулятор и симулятор — не «дешёвая замена устройству», а другой инструмент. Симулятор iOS исполняет код, скомпилированный под x86-64 или arm64 для macOS, использует ресурсы вашего Mac и ничего не говорит о производительности; он отлично проверяет логику взаимодействия и вёрстку. Эмулятор Android честнее (это настоящий Android-образ в виртуальной машине), но всё равно не воспроизводит вендорский «менеджер батареи», который на конкретном бренде убивает ваш фоновый джоб через 30 секунд после сворачивания.
Про фермы. Firebase Test Lab встроен в экосистему Google, дёшев и умеет один трюк, о котором
многие не знают: pre-launch report — при загрузке сборки в Google Play Google сам прогоняет
её на реальных устройствах роботом-обходчиком и присылает крэши, ANR, скриншоты во всех локалях
и замечания по доступности. Это бесплатный дополнительный слой тестирования, который включается
одной галочкой. BrowserStack, Sauce Labs и AWS Device Farm дают более широкий каталог и обе
платформы. Своя ферма (например, на базе OpenSTF/DeviceFarmer) окупается только при очень
больших объёмах прогонов и наличии человека, который занимается ею как продуктом: устройства
разряжаются, теряют Wi-Fi, обновляют ОС и требуют физической перезагрузки.
# Прогон инструментальных тестов на нескольких конфигурациях сразу
gcloud firebase test android run \
--type instrumentation \
--app app/build/outputs/apk/debug/app-debug.apk \
--test app/build/outputs/apk/androidTest/debug/app-debug-androidTest.apk \
--device model=MediumPhone.arm,version=34,locale=ru,orientation=portrait \
--device model=redfin,version=30,locale=ru,orientation=portrait \
--device model=a10,version=29,locale=ru,orientation=portrait \
--timeout 20m \
--num-flaky-test-attempts 1 \
--environment-variables clearPackageData=true # чистое состояние между тестами
Как выбрать шесть устройств из тысячи
Выбор устройств — не вопрос вкуса, а задача покрытия. У вас есть сегменты аудитории (пара «класс устройства + версия ОС», иногда с добавлением плотности экрана и объёма памяти), у каждого известна доля пользователей и известны характерные риски. И есть бюджет: сколько конфигураций вы готовы гонять на каждый пул-реквест и сколько — ночью.
Формально это задача максимизации покрытия при ограничении на число элементов
(budgeted maximum coverage) — родственник классической задачи о покрытии множества.
Точное решение NP-трудно, но жадный алгоритм даёт гарантированное приближение не хуже
1 − 1/e (примерно 63 процента) от оптимума, и на практике этого более чем достаточно.
Псевдокод:
непокрытые ← все сегменты со своими долями
выбранные ← пусто
пока выбранных меньше бюджета и есть непокрытые:
для каждого устройства d посчитать прирост = сумма долей непокрытых сегментов, которые d закрывает
взять устройство с максимальным приростом
если прирост = 0: остановиться
добавить его в выбранные, вычеркнуть закрытые им сегменты из непокрытых
вернуть выбранные и остаток непокрытой доли
Реализация:
def choose_devices(segments: dict[str, float],
devices: dict[str, set[str]],
budget: int) -> tuple[list[tuple[str, float]], float]:
"""
segments: сегмент аудитории -> доля пользователей (например, "mid/android-13" -> 0.17)
devices: модель -> множество сегментов, которые она закрывает
budget: сколько конфигураций мы готовы оплачивать в прогоне
Жадное покрытие: на каждом шаге берём устройство с максимальным приростом
ещё не покрытой доли аудитории.
"""
uncovered = dict(segments)
pool = {d: set(s) for d, s in devices.items()}
chosen: list[tuple[str, float]] = []
while uncovered and len(chosen) < budget:
best = max(pool, key=lambda d: sum(uncovered.get(s, 0.0) for s in pool[d]),
default=None)
if best is None:
break
gain = sum(uncovered.get(s, 0.0) for s in pool[best])
if gain <= 0.0: # дальше только дубликаты уже покрытого
break
chosen.append((best, round(gain, 4)))
for s in pool[best]:
uncovered.pop(s, None)
del pool[best]
return chosen, round(sum(uncovered.values()), 4)
Сложность: на каждом из k = budget шагов мы пересчитываем прирост для всех оставшихся
устройств, то есть O(k · |D| · s) по времени, где |D| — число моделей в каталоге,
а s — среднее число сегментов на модель. Память — O(|D| · s + |S|). Для реальных
размеров (сотни моделей, десятки сегментов) это доли секунды, оптимизировать нечего.
Практическая интерпретация результата — на картинке выше. Первые две конфигурации закрывают почти половину аудитории, следующие четыре — ещё треть, а дальше начинается длинный хвост, где каждая новая модель приносит доли процента, но стоит те же деньги за час. Отсюда рабочее правило: 2 конфигурации на каждый пул-реквест, 6–8 на ночной прогон, хвост закрывается краш-аналитикой, а не тестами. И отдельно, вне логики покрытия, в набор всегда добавляют две «злые» конфигурации: самое слабое поддерживаемое железо (там всплывают ANR и OOM) и самую свежую бету ОС (там всплывают изменения поведения API).
Мобильное, чего нет в веб-тестах
Это раздел, ради которого статья и написана: перечисленное ниже не встречается в вебе вообще либо встречается в неузнаваемо мягкой форме. Каждый пункт — воспроизводимый сценарий, а не «поиграйтесь с телефоном».
Смерть процесса и восстановление состояния
Система вправе убить ваш процесс в фоне в любой момент, а потом восстановить экран с того же места. Пользователь этого не заметит — он просто вернётся в приложение и увидит либо свою заполненную форму, либо пустой экран и потерянные 20 минут работы.
Типичная ошибка: считать, что ActivityScenario.recreate() или поворот экрана проверяют
этот сценарий. Не проверяют: при пересоздании Activity процесс жив, и статические поля,
синглтоны и незаписанные SavedStateHandle остаются на месте. Настоящая проверка требует
либо StateRestorationTester в Compose (он честно сериализует и десериализует состояние),
либо убийства процесса снаружи.
# Android: приложение свернули, система убила процесс
adb shell am kill com.example.wallet # работает только для фоновых процессов
# Дешёвый способ ловить потерю состояния в ручном тестировании:
adb shell settings put global always_finish_activities 1 # «Не сохранять действия»
На iOS аналог мягче: приложение переходит в suspended и может быть выгружено. Проверяется
переводом в фон, simctl terminate и повторным запуском с ожиданием восстановления через
NSUserActivity/State Restoration. Разбор самих жизненных циклов — в
Нативной iOS-разработке и
Нативной Android-разработке.
Разрешения
Разрешение можно не дать, дать один раз, дать и отозвать в настройках, отозвать пока приложение работает (Android убьёт процесс), а на Android 11+ система ещё и автоматически сбрасывает разрешения у неиспользуемых приложений. Нужны тесты как минимум на три состояния: не спрашивали, отказали, отказали навсегда.
# Android
adb shell pm grant com.example.wallet android.permission.ACCESS_FINE_LOCATION
adb shell pm revoke com.example.wallet android.permission.ACCESS_FINE_LOCATION
adb shell pm reset-permissions # состояние «ещё не спрашивали»
# iOS-симулятор
xcrun simctl privacy booted revoke photos com.example.wallet
xcrun simctl privacy booted grant location com.example.wallet
xcrun simctl privacy booted reset all com.example.wallet
Проверять надо не «показался ли диалог», а что приложение остаётся полезным при отказе. Экран, который при отсутствии геолокации показывает бесконечный спиннер, — самый частый дефект этой категории и стабильная причина отклонения на ревью.
Сеть с провалами
«Оффлайн» — не булев флаг. Реальные состояния: нет сигнала; сигнал есть, но пакеты не ходят; 2G с задержкой 2 секунды; Wi-Fi с captive portal, который отвечает 200 на любой запрос; обрыв ровно посередине загрузки тела ответа; переключение Wi-Fi на сотовую сеть в момент отправки платежа. Все они воспроизводимы.
# Android: жёсткий оффлайн и обратно
adb shell svc data disable && adb shell svc wifi disable
adb shell svc data enable && adb shell svc wifi enable
# iOS-симулятор: рисуем «ищет сеть» в статус-баре и деградируем канал
xcrun simctl status_bar booted override --cellularMode searching --wifiMode failed
# Медленный и рвущийся канал на реальном устройстве — Network Link Conditioner
# (Developer settings на устройстве, профили Edge / 3G / 100% Loss)
Тесты нижнего уровня закрывают логику ретраев и очереди; инструментальные — что интерфейс честно сообщает состояние; ручные на реальной сети — что приложение не «залипает» при переключении между сетями. Подробности про сам оффлайн — в Данных и оффлайне.
Фон, батарея и пуши
Фоновая работа на мобильном не гарантирована (см. Фоновая работа и пуши), и тестировать надо не «джоб выполнился», а «джоб выполнился, когда система соизволила».
# Android: имитируем ночь без зарядки — Doze закрывает сеть и откладывает будильники
adb shell dumpsys battery unplug
adb shell dumpsys deviceidle force-idle
adb shell dumpsys deviceidle step # шагаем по фазам Doze
adb shell cmd jobscheduler run -f com.example.wallet 1001 # принудительно запустить джоб
# iOS: доставка тестового пуша в симулятор без бэкенда
xcrun simctl push booted com.example.wallet payload.apns
xcrun simctl openurl booted "wallet://payment/42" # диплинк
Для WorkManager есть WorkManagerTestInitHelper с TestDriver, который позволяет
программно «выполнить» ограничения (setAllConstraintsMet) и проверить цепочку джобов
без реального ожидания. Энергопотребление в CI напрямую не измеряется; практический подход —
следить за прокси-метриками (число пробуждений, объём трафика, время удержания wake lock)
и раз в цикл делать ручное измерение на устройстве через Battery Historian или
Instruments Energy Log.
Обновление поверх предыдущей версии
Свежая установка и обновление — два разных сценария, и второй проверяют реже, хотя ломается
именно он: миграции базы, изменившийся формат SharedPreferences/UserDefaults, ключи в
Keychain/Keystore, которые не пережили смену схемы шифрования. Минимальный обязательный тест
релизного цикла: установить предыдущую продовую сборку, залогиниться, создать данные,
установить кандидата поверх, убедиться, что сессия и данные на месте.
adb install -r previous-release.apk # ставим прод
# ... наполняем данными вручную или скриптом ...
adb install -r release-candidate.apk # обновляемся поверх, БЕЗ удаления данных
Прерывания и «чужие» окна
Входящий звонок, шторка уведомлений, системный диалог, split screen, picture-in-picture,
скриншот-шейка, смена ориентации в момент анимации перехода. UIAutomator и XCUITest умеют
взаимодействовать с системным интерфейсом; Maestro умеет openNotifications. Проверяется
одно: приложение не падает и не теряет введённые данные.
Производительность как тест, а не как ощущение
Старт, плавность и трафик — измеримые бюджеты, которые можно защищать в CI (детальный разбор метрик — в Производительности мобильного).
@get:Rule val benchmarkRule = MacrobenchmarkRule()
@Test fun холодный_старт() = benchmarkRule.measureRepeated(
packageName = "com.example.wallet",
metrics = listOf(StartupTimingMetric(), FrameTimingMetric()),
iterations = 15,
startupMode = StartupMode.COLD,
compilationMode = CompilationMode.Partial() // как у пользователя с Baseline Profile
) {
pressHome()
startActivityAndWait()
}
func testХолодныйСтартУкладываетсяВБюджет() {
measure(metrics: [XCTApplicationLaunchMetric(), XCTMemoryMetric()]) {
XCUIApplication().launch()
}
}
Важная оговорка: перф-тесты нельзя гонять на общих CI-раннерах и делать блокирующими по абсолютному порогу — шум виртуализации больше измеряемого эффекта. Их место — ночной прогон на выделенных физических устройствах, а критерий — не абсолютное значение, а относительная деградация к предыдущей сборке по медиане и 90-му перцентилю.
CI для мобильного: что и когда запускать
блокирует мерж H-->>CI: зелёно + отчёт по диффам снапшотов CI->>F: smoke-набор, 2 конфигурации Note over F: 8–12 минут, блокирует мерж F-->>CI: зелёно CI-->>D: можно мержить D->>CI: мерж в main CI->>H: сборка релизного артефакта, подпись CI->>F: ночной регресс, 6–8 конфигураций + перф Note over F: 40–90 минут, не блокирует мерж,
заводит задачи на падения CI->>B: автозаливка в internal-канал B-->>D: сборка у команды через минуты
Разделение принципиальное. На пул-реквесте висит только то, что укладывается примерно в 10 минут и почти не флакает: весь низ пирамиды плюс узкий smoke на двух конфигурациях. Ночью гоняется полный регресс на матрице устройств, перф-бенчмарки и стрессовые прогоны подозрительных тестов. Если полный набор повесить на пул-реквест, команда через месяц научится его игнорировать.
name: mobile-ci
on: [pull_request]
jobs:
fast: # блокирующий: всё, что идёт без эмулятора
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with: { distribution: temurin, java-version: '17' }
- name: Юнит, интеграционные и снапшот-тесты
run: ./gradlew testDebugUnitTest verifyPaparazziDebug --stacktrace
- name: Отчёт с картинками-диффами
if: failure()
uses: actions/upload-artifact@v4
with: { name: snapshot-diffs, path: '**/build/reports/paparazzi/**' }
smoke: # блокирующий: узкий набор на двух конфигурациях
runs-on: ubuntu-latest
needs: fast
steps:
- uses: actions/checkout@v4
- run: ./gradlew assembleDebug assembleDebugAndroidTest
- uses: google-github-actions/auth@v2
with: { credentials_json: '${{ secrets.GCP_SA_KEY }}' }
- name: Firebase Test Lab
run: |
gcloud firebase test android run \
--type instrumentation \
--app app/build/outputs/apk/debug/app-debug.apk \
--test app/build/outputs/apk/androidTest/debug/app-debug-androidTest.apk \
--device model=MediumPhone.arm,version=34,locale=ru \
--device model=a10,version=29,locale=ru \
--test-targets "annotation com.example.SmokeTest" \
--timeout 15m
Мобильная специфика CI, о которой стоит знать заранее: сборки iOS требуют macOS-раннеров,
а они в облаках стоят в разы дороже Linux; кеш Gradle и DerivedData — главный рычаг
времени сборки; подписи и профили должны жить в секретах и обновляться автоматически
(fastlane match или App Store Connect API), иначе однажды релиз встанет из-за истёкшего
сертификата. Общие практики CI — в Основах CI
и Тестах в CI.
Бета-каналы: последний эшелон перед пользователями
Бета — не замена тестам. Автотесты проверяют то, что вы предвидели; бета ловит то, чего никто не догадался закодировать: конкретную прошивку, экзотическую локаль, неожиданный сценарий использования, реакцию живого человека на непонятный экран.
crash-free сессий не ниже 99,5 Internal --> PullRequest: найден блокер Beta --> StoreReview: 3–5 дней на реальных людях Beta --> PullRequest: краши или жалобы бета-тестеров StoreReview --> Rollout: ревью пройдено StoreReview --> PullRequest: отклонено, правим замечания Rollout --> Production: поэтапно 1, 10, 50, 100 процентов Rollout --> Halted: всплеск крашей или ANR Halted --> PullRequest: хотфикс с новым номером сборки Production --> [*]
iOS, TestFlight. Два принципиально разных режима. Внутренние тестеры — до 100 человек из вашей команды в App Store Connect, сборка становится доступна сразу после обработки, без Beta App Review. Внешние тестеры — до 10 000 человек, первая сборка каждой версии проходит Beta App Review (обычно быстрее, чем полное ревью, но это ожидание), доступна публичная ссылка на вступление. Сборки живут 90 дней и затем истекают. Обратная связь встроена: тестер делает скриншот и пишет комментарий прямо из системного диалога, всё приходит в App Store Connect; краши видны в Xcode Organizer с уже символизированными стеками.
Android, Google Play. Четыре трека: internal (до 100 тестеров, доступен за минуты, проходит только базовые автоматические проверки), closed (по спискам или группам, полноценное ревью), open (публичная бета), production. Плюс internal app sharing — мгновенная ссылка на конкретный AAB/APK без версионирования и без трека: незаменимая штука, чтобы дать тестировщику сборку из ветки. Отдельно помните про политику Google для новых аккаунтов персональных разработчиков: перед публикацией требуется закрытое тестирование на минимум 12 тестерах в течение 14 дней подряд — это влияет на планирование запуска.
Firebase App Distribution решает промежуточную задачу: раздача сборок обеих платформ до сторов, по группам, с автоматическим приглашением тестеров из CI. Удобно, когда команда распределена, а ждать обработки TestFlight не хочется.
Что должно быть в сборке, уходящей в бету, иначе бета бесполезна:
- Уникальный номер сборки и загруженные символы отладки (dSYM на iOS, mapping-файл на Android), иначе краши придут в виде нечитаемых адресов.
- Включённый крэш-репортер и понятный канал обратной связи; отдельный проект/тег для бета-сборок, чтобы бета-краши не смешивались с продовыми в метриках.
- Фиче-флаги на новую функциональность: возможность выключить сломанное без релиза — это единственный «откат», который вообще существует на мобильном.
- Критерий продвижения, зафиксированный числом: не «вроде норм», а «crash-free сессий не ниже 99,5 процента и не ниже прошлой версии, ANR-rate в пределах порога Google Play, ключевая воронка не просела». Метрики и раскатка — тема следующей статьи трека.
Нативно или кроссплатформа: что меняется в цене тестирования
Раздел, который в спорах о фреймворках обычно опускают. Считать надо не «сколько кода переиспользуется», а сколько стоит держать качество в каждом подходе.
Нативно. Два независимых набора тестов, два CI-пайплайна, два пула устройств. Юнит-тесты и вьюмодели дублируются почти один в один — это чистые потери, обычно 30–50 процентов трудозатрат на тестирование. Взамен вы получаете первоклассные инструменты (XCTest, Espresso, Compose testing развиваются вместе с платформой), минимум прослоек, а значит, минимум источников флака, и полный доступ к платформенным профайлерам. Найм: тестировщик-автоматизатор под конкретную платформу ищется предсказуемо, но нужны двое.
Flutter. Самый дешёвый низ пирамиды в индустрии: виджет-тесты исполняются на Dart VM,
рендерят настоящее дерево виджетов и проходят за миллисекунды; golden-тесты встроены в
фреймворк; тесты одни на обе платформы. Экономия реальна и велика. Но верх пирамиды не
экономится совсем: integration_test всё равно гоняется на устройствах обеих платформ,
а всё, что идёт через платформенные каналы (разрешения, пуши, диплинки, покупки, биометрия),
тестируется на каждой платформе отдельно и ломается на каждой по-своему.
React Native. Jest плюс React Native Testing Library дают быстрый юнит- и компонентный слой, знакомый веб-разработчикам; Detox — самый стабильный из кроссплатформенных E2E-раннеров именно для RN, потому что синхронизируется с JS-потоком, а не опрашивает экран. Риск — нативные модули: каждый из них требует нативных тестов, и профиль дефектов зависит от версии архитектуры (старый мост против нового) и движка JS. Найм: широкий рынок веб-инженеров, но за ними всё равно нужен человек, понимающий обе нативные платформы.
KMP (Kotlin Multiplatform). Самая честная экономия из всех: commonTest покрывает
бизнес-логику, слой данных, синхронизацию и вьюмодели одним набором тестов, который
исполняется на JVM и на нативной цели. Дублируется ровно то, что и должно дублироваться, —
UI-слой. При этом UI остаётся нативным, а значит, UI-тесты пишутся штатными платформенными
инструментами и не флакают из-за прослоек.
Итог без иллюзий. Кроссплатформа экономит на нижних уровнях пирамиды — там, где один час инженера покрывает много проверок и где прогон почти бесплатен. Она не экономит на верхних: ферма устройств, релизный цикл, крэш-триаж, ревью в сторах и обновление поверх старой версии остаются двойными в любом подходе. А верхние уровни — это как раз то, что стоит дороже всего в час. Практическое следствие: если основная боль вашей команды — дублирование бизнес-логики, кроссплатформа (особенно KMP) окупится быстро; если основная боль — зоопарк устройств, странные краши и релизный цикл, смена фреймворка не изменит почти ничего. Полный разбор компромиссов — в Кроссплатформе.
Что автоматизировать, а что оставить людям
Правый верхний угол автоматизируется в первую очередь и составляет ядро набора. Нижний правый — область, где честнее признать: покупки в сторе, биометрия, камера и вендорские прошивки автоматизируются дорого и нестабильно, а сломаться могут. Их закрывают ручным чек-листом перед релизом плюс тестовыми окружениями платформ (StoreKit Testing на iOS, лицензионное тестирование Google Play Billing). Левый нижний угол — территория исследовательского тестирования: живой человек с реальным устройством находит там то, что не описано ни в одном сценарии.
Отдельно про ручное тестирование: на мобильном оно не «пережиток», а обязательная практика. Ощущение плавности, попадание пальцем, поведение на морозе и при 3 процентах батареи, адекватность текстов — всё это не измеряется автотестом. Дешёвая и эффективная форма — догфудинг: команда пользуется бета-каналом как обычным приложением каждый день.
Типичные ошибки
- Пытаться проверять логику через UI-тесты. Набор из 400 E2E-сценариев, гоняющийся два часа и флакающий на 5 процентах, — классическая мобильная катастрофа. Логика уходит вниз, наверху остаётся навигация и интеграция.
- Считать поворот экрана тестом на смерть процесса. Проверяет разные вещи; настоящую смерть процесса надо эмулировать явно.
Thread.sleep(2000)вместо ожидания условия. Даёт медленный и флакающий набор одновременно.- UI-тесты, ходящие в реальный бэкенд. Красный CI при падении стенда, недетерминированные данные, невозможность проверить ошибки сервера.
- Эталоны снапшотов, сгенерированные на машине разработчика. Вечные диффы в CI и привычка обновлять эталоны не глядя.
- Только свежий флагман в наборе устройств. Все дефекты производительности, ANR и OOM живут на слабом железе и старых ОС.
- Автоматический ретрай, спрятанный в отчёте. Флак перестаёт быть виден, гонка в приложении живёт годами и однажды выстреливает в проде.
- Тестировать только чистую установку. Обновление поверх предыдущей версии ломается чаще и стоит дороже.
- Бета как единственный эшелон. «Отдадим тестерам, они найдут» работает, пока приложение маленькое, и перестаёт ровно в тот момент, когда цена ошибки становится заметной.
- Вечный карантин. Тест, который год лежит в карантине, не тестирует ничего, но создаёт ощущение покрытия.
Мини-итог
Мобильное тестирование — это дисциплина управления ценой обратной связи в условиях, когда откат релиза невозможен. Из этого следует всё остальное. Проверка делается на самом низком уровне, на котором она возможна: логика и работа с временем — в хост-процессе за миллисекунды, данные, миграции и очередь оффлайна — там же с подменённым HTTP и in-memory базой, вёрстка во всех темах, шрифтах и локалях — снапшотами без эмулятора, и только жизненный цикл, разрешения, навигация и интеграция с ОС поднимаются на устройства.
Матрица устройств выбирается арифметикой покрытия, а не вкусом: две конфигурации на пул-реквест, шесть-восемь ночью, плюс намеренно злые «самое слабое железо» и «самая свежая бета ОС»; длинный хвост закрывается краш-аналитикой. Флаки измеряются как метрика и лечатся как дефекты, а не глушатся ретраями. CI делится на быстрый блокирующий контур и ночной полный регресс. Бета-каналы — TestFlight и треки Google Play — это последний эшелон, который ловит непредвиденное, а не замена автотестам, и работает он только при уникальных номерах сборок, загруженных символах, включённом крэш-репортере и числовом критерии продвижения. И, наконец, кроссплатформа честно удешевляет низ пирамиды и почти не трогает верх — а верх и есть самая дорогая часть мобильного качества.
Источники
- Apple. Testing overview и Swift Testing — современный фреймворк юнит-тестов.
- Apple. User interface tests с XCUITest и XCTMetric для перф-тестов.
- Apple. TestFlight и Beta testing в App Store Connect.
- Android Developers. Fundamentals of testing — уровни, окружения, правила.
- Android Developers. Testing Compose и Espresso.
- Android Developers. Testing Room migrations — обязательное чтение перед первой миграцией.
- Android Developers. Macrobenchmark и Baseline Profiles.
- Android Developers. Test your app on Doze and App Standby.
- Firebase Test Lab и Pre-launch reports — фермa и бесплатный робот-обходчик.
- Google Play Console. Настройка открытого, закрытого и внутреннего тестирования.
- Cash App. Paparazzi — снапшоты Android-вёрстки на JVM без эмулятора.
- Point-Free. swift-snapshot-testing и swift-clocks.
- Turbine — тестирование Flow как последовательности значений.
- MockWebServer — стаб HTTP с честными обрывами соединения.
- Flutter. Testing Flutter apps и golden-тесты.
- Maestro и Detox — кроссплатформенные E2E-раннеры.
- Micco J. Flaky Tests at Google and How We Mitigate Them — базовый текст про флаки и их экономику.
- Hoffmann V., Nemhauser G., Wolsey L. Классические результаты по жадному приближению задачи максимального покрытия — обоснование границы
1 − 1/eдля выбора устройств.
Что дальше
Релиз и сторы: подписи, ревью, поэтапная раскатка, аналитика и крэши — как сборка превращается в подписанный артефакт, что именно проверяет ревью и как его проходить с первого раза, зачем нужна поэтапная раскатка и как по крэш-аналитике и метрикам вовремя понять, что её пора останавливать.