Мобильная разработка Тестирование мобильного: юнит, UI, устройства, фермы, бета-каналы
0%

Тестирование мобильного: юнит, UI, устройства, фермы, бета-каналы

Тестирование мобильного: юнит, 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 измеряют как отдельную метрику и относятся к нему как к дефекту продукта.

Что работает на практике:

  • Автоматический ретрай ровно один раз и только на верхних уровнях. Юнит-тест не имеет права флакать — если флакает, там гонка. Для инструментальных тестов один повтор гасит инфраструктурный шум, но результат обязан быть виден в отчёте: «прошёл со второй попытки» — это не «прошёл». 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 для мобильного: что и когда запускать

Разделение принципиальное. На пул-реквесте висит только то, что укладывается примерно в 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.

Бета-каналы: последний эшелон перед пользователями

Бета — не замена тестам. Автотесты проверяют то, что вы предвидели; бета ловит то, чего никто не догадался закодировать: конкретную прошивку, экзотическую локаль, неожиданный сценарий использования, реакцию живого человека на непонятный экран.

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 — это последний эшелон, который ловит непредвиденное, а не замена автотестам, и работает он только при уникальных номерах сборок, загруженных символах, включённом крэш-репортере и числовом критерии продвижения. И, наконец, кроссплатформа честно удешевляет низ пирамиды и почти не трогает верх — а верх и есть самая дорогая часть мобильного качества.

Источники

Что дальше

Релиз и сторы: подписи, ревью, поэтапная раскатка, аналитика и крэши — как сборка превращается в подписанный артефакт, что именно проверяет ревью и как его проходить с первого раза, зачем нужна поэтапная раскатка и как по крэш-аналитике и метрикам вовремя понять, что её пора останавливать.

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов