Вход на сайт

Просмотр новости

Найдите то, что Вас интересует

API быстрее UI — не закон: замеры на 400+ Android UI-тестах

Дата публикации: 23-10-2026 14:49:22

Почти каждый наш UI-тест начинался одинаково: старт, email, OTP, пароль, создать пасскод, повторить пасскод. Шесть экранов, около 26 секунд, и ни один из этих тестов не проверял логин. Он был просто предусловием.Совет «делай предусловия через API, а не через UI» знает каждый, кто писал автотесты. Мы проверили его на трёх кандидатах в Android-сьюте на реальных устройствах. В одном месте API-вариант оказался на 29% медленнее UI. В двух других вышло заметно быстрее: логин ускорился с ~26 до ~7 секунд, а суммарное время 400+ тестов сократилось на 27%.Дальше расскажу, почему так получилось, как устроен программный логин внутри процесса приложения, как мы мерили (и где ошиблись в замере сами) и по какому правилу теперь решаем, что переводить с UI. Читать далее

Основное содержимое страницы с новостью.

Уровень сложностиСредний

Время на прочтение10 мин

Охват и читатели1.4K

Кейс

Почти каждый наш UI-тест начинался одинаково: старт, email, OTP, пароль, создать пасскод, повторить пасскод. Шесть экранов, около 26 секунд, и ни один из этих тестов не проверял логин. Он был просто предусловием.

Совет «делай предусловия через API, а не через UI» знает каждый, кто писал автотесты. Мы проверили его на трёх кандидатах в Android-сьюте на реальных устройствах. В одном месте API-вариант оказался на 29% медленнее UI. В двух других вышло заметно быстрее: логин ускорился с ~26 до ~7 секунд, а суммарное время 400+ тестов сократилось на 27%.

Дальше расскажу, почему так получилось, как устроен программный логин внутри процесса приложения, как мы мерили (и где ошиблись в замере сами) и по какому правилу теперь решаем, что переводить с UI.

Итоги трёх экспериментов: +29%, −23%, −27…28%

Итоги трёх экспериментов: +29%, −23%, −27…28%

Контекст

У нас Android-приложение финансового сервиса и около 540 UI-тестов на Kaspresso (Espresso + Compose) от нескольких продуктовых команд. Тесты ходят в настоящий dev-бэкенд, часть ответов подменяется стабами на уровне OkHttp-перехватчика приложения. Гоняем их на локальном эмуляторе и в Marathon Cloud (Pixel 5, Android 16). На всё описанное ниже, от идеи до последнего замера, ушло около трёх дней.

Хотелось заменить программными вызовами те шаги предусловий, которые не являются сутью теста. Кандидатов было три:

Эксперимент

Тестов

Что заменили

Результат

A. Автопродление продукта

5

3 тапа переключателя → PATCH

медленнее на 29%, откатили

B. Создание продукта

8

5 экранов → POST

быстрее на 23%

C. Логин

418

6 экранов → auth-слой приложения + 1 экран пасскода

логин −71…73%, тесты −27…28%

Начну с провала, потому что он самый поучительный.

Эксперимент A: как API проиграл трём тапам

Пять тестов проверяют экраны включения и выключения автопродления у продукта (условно, вклада). Перед проверкой переключатель нужно привести в известное состояние.

Через UI тест логинится, открывает экран продукта и переключает тумблер: три тапа и подтверждение. В API-варианте тест ещё до UI-логина логинится через API, получает список продуктов пользователя, находит нужный и отправляет PATCH на смену режима.

Выглядело как очевидное ускорение. Замер (по одному прогону на вариант, локальный эмулятор), секунды:

Тест

Шаг «выставить состояние», UI

То же, API

Весь тест, UI

Весь тест, API

1

1,71

22,96

53,08

71,66

2

1,61

27,07

52,07

73,27

3

1,59

20,75

56,88

70,50

4

1,72

14,90

57,22

69,40

5

1,69

12,61

48,14

59,83

Итого

8,31

98,29

267,39

344,66 (+29%)

Остальная часть предусловия, UI-логин и навигация, в обоих вариантах заняла примерно одинаково: 48,9 и 46,5 с. Вся разница сидит в одном шаге, 1,7 с против ~20 с. Сильно я не удивился, скорее стало интересно найти объяснение.

Из чего состоят эти ~20 секунд:

  • API-логин: 1–3 с;

  • GET «список продуктов пользователя»: 9–18 с;

  • PATCH: около 0,6 с.

Список тормозит из-за тестовых данных. Пагинации у ручки нет, а у тестовых пользователей за сотни прогонов накопились сотни и тысячи записей, потому что каждый прогон создаёт новые. У реальных пользователей такого объёма нет.

При этом UI-шаг стоит 1,7 с, а не «20 с плюс тапы», хотя приложение делает тот же самый GET списка при открытии экрана продуктов. В UI-прогонах он тоже шёл 10–17 с, но оплачивался один раз, во время навигации, ещё до шага переключения. Когда тест добирается до тумблера, данные уже на экране и остаются три тапа. API-вариант платил за список дважды: сначала сам, чтобы найти ID продукта, потом приложение при открытии экрана.

Эксперимент A: API-вариант загружает список дважды

Эксперимент A: API-вариант загружает список дважды

Гипотезу «приложение грузит список частями, а тест целиком» мы тоже проверили. Нет, в клиенте у этого запроса нет параметров страниц, приложение тоже грузит всё.

По дороге наступили на грабли дважды. Первая версия меняла режим у всех подходящих продуктов пользователя, и в 2 тестах из 5 бэкенд отказал: в определённом состоянии продукта режим менять нельзя, а один из продуктов как раз был в нём. Пришлось менять только тот продукт, который откроет экран, а для этого повторить в тесте сортировку списка, которую делает приложение. Вторая проблема вылезла под нагрузкой, когда в облаке работало много параллельных устройств. Тот же запрос иногда не укладывался и в 90 с, экран висел в загрузке, и тесты падали на ожидании.

Можно было пойти дальше: сделать пагинацию или фильтр на бэкенде (это ускорило бы и приложение), почистить историю тестовых пользователей или запускать API-шаг параллельно с UI-логином. Последний вариант дал бы паритет, но не выигрыш. Эксперимент мы откатили.

Оговорка: здесь по одному прогону на вариант. Но разница 8 с против 98 с на шаге и понятный механизм перевешивают слабую статистику.

Эксперимент B: где API сработал

Восемь тестов создают продукт только затем, чтобы потом его вывести, пополнить или посмотреть в истории. Через UI это пять экранов: список, карточка оффера, ввод суммы, подтверждение, результат.

Теперь тест до логина отправляет POST создания. Ожидание, пока продукт появится на экране (pull-to-refresh), осталось UI-шагом.

Медленный список продуктов тут не участвует. API-шагу нужен только логин, «список офферов» (~0,2 с) и сам POST, и выходит 4–7 с против 14,5–39 с UI-шагов, в зависимости от типа продукта. Один тест прогнали по 3 раза на вариант: UI 79,9 / 84,6 / 87,9 с, API 67,8 / 72,1 / 73,5 с, диапазоны не пересекаются. По всем 8 тестам предусловия сократились с 462 до 347 с (−25%), тесты целиком с 586 до 452 с (−23%).

Тело запроса из теста должно совпадать с тем, что шлёт приложение. Мы сверяли его с запросами из логов UI-прогонов: тот же оффер, те же поля, флаг автопродления по умолчанию включён, как на экране. Если тело отличается, предусловие создаёт не то состояние, что UI, и тест проверяет не то.

Модели мы не дублировали, тест переиспользует продакшн-интерфейс Retrofit и DTO приложения. Токен подставляет маленький перехватчик по той же аннотации, что и в приложении:

// Тест переиспользует Retrofit-интерфейс и DTO приложения, а не копирует их.
// Приложение добавляет токен для методов с аннотацией @HeaderAccessToken —
// тестовый клиент делает то же самое с токеном тестовой сессии.
class AccessTokenInterceptor(private val accessToken: String) : Interceptor {
    override fun intercept(chain: Interceptor.Chain): Response {
        val request = chain.request().addHeader(        // тот же хелпер, что в приложении
            annotationClass = HeaderAccessToken::class,
            headerName = "Auth-Token",
            valueProvider = { accessToken },
        )
        return chain.proceed(request)
    }
}

fun createProduct(user: TestUser, type: ProductType, amount: String) = runBlocking {
    val session = loginViaApi(user)
    val api = apiFactory.getAppApi(session.accessToken)              // Retrofit-интерфейс приложения
    val offer = api.getOffers(session.profileId).items
        .first { it.currency == type.currency && it.kind == type.kind } // как выбирает экран
    api.createProduct(
        CreateProductBody(                                          // DTO приложения
            offerId = offer.id,
            amount = amount.toDouble(),
            autoRenewal = AutoRenewal.ON,                           // дефолт экрана
            period = offer.period.takeIf { offer.kind == Kind.FIXED },
            requestId = uuid(),
            profileId = session.profileId,
        )
    )
}

После выкатки в облако на параллельные устройства начались падения. Тесты ждали свой продукт строго на позиции 0 в списке, а параллельные тесты на общих тестовых пользователях создавали продукты друг другу, и первым вставал чужой. Пришлось ослабить проверку с «позиции 0» до «среди первых трёх», с историей транзакций так же. Похоже, API-создание сделало коллизии чаще: все копии теста создают продукт почти одновременно, примерно через 13 с после старта, а через UI создание было размазано во времени. Отдельно мы эту гипотезу не проверяли.

Эксперимент C: логин

Это главный кейс и по масштабу (418 тестов), и по технике.

Не REST из теста, а auth-слой приложения

Логиниться «через API» можно по-разному. Очевидный путь — отправить из теста REST-запросы, получить токены и как-то положить их в приложение. Нам он не понравился, потому что тогда тест должен знать, где и как приложение хранит сессию, и повторять это руками.

Мы сделали иначе. Тест вызывает собственный auth-слой приложения внутри того же процесса, те же методы, что вызывают экраны логина, и в том же порядке. Сессию тогда сохраняет продакшн-код, ровно так же, как после настоящего UI-логина.

Добраться до него из теста удалось, потому что androidTest лежит в том же Gradle-модуле, что и приложение, и видит internal-объекты Kotlin. У приложения есть синглтон DI-графа (Dagger), его компонент отдаёт auth-медиатор, тот самый объект, через который ходят экраны логина. Тестовый Application-класс поднимает DI в режиме UI-тестов.

Код в упрощённом виде:

/**
 * Открывает приложение как вернувшийся залогиненный пользователь:
 * экран «Welcome back» с пасскодом, затем дашборд.
 * Заменяет UI-логин в тестах, где логин — только предусловие.
 */
fun loginViaWelcomeBack(user: TestUser) {
    step("Login via Welcome back: ${user.label}") {
        val auth = AppGraph.app.provide().authMediator()  // тот же объект, что у экранов логина
        runBlocking {
            auth.startSession(user.email)
            auth.confirmOtp(user.otpCode())   // на тестовом стенде код подставляет тестовая инфраструктура
            auth.checkPassword(user.password) // здесь продакшн-код сохраняет сессию
            auth.setPasscode(user.passcode)
            // ...остальные шаги — в том же порядке, что на экранах логина
        }

        restartFromWelcomeScreen()

        screen.passcode
            .verifyTitleText("WELCOME BACK")
            .enterPasscode(user.passcode)
    }
}

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

Холодный старт, а не прыжок на экран пасскода

Первая версия после программного логина сразу открывала экран пасскода, и часть тестов повела себя не так, как с UI-логином. Старая активити продолжала маршрутизировать, а шаги, которые приложение делает на старте, пропускались. Помог перезапуск с приветственного экрана с теми же флагами, которыми приложение перезапускает себя само:

/**
 * Заменяет текущую активити новым приветственным экраном — так, как приложение
 * перезапускает себя само. Следующий экран приложение выбирает по сохранённой сессии,
 * и никакой старый экран больше не маршрутизирует.
 */
private fun restartFromWelcomeScreen() {
    InstrumentationRegistry.getInstrumentation().runOnMainSync {
        val activity = ActivityLifecycleMonitorRegistry.getInstance()
            .getActivitiesInStage(Stage.RESUMED)
            .first()
        Intent(activity, WelcomeActivity::class.java)
            .addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
            .addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK)
            .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
            .let(activity::startActivity)
        activity.finish()
    }
}

По сохранённой сессии приложение само решает, куда идти, и показывает «Welcome back». Тест вводит пасскод, то есть проходит реальный путь вернувшегося пользователя.

Если у вас Compose, правило createAndroidComposeRule<WelcomeActivity>() находит Compose-корни во всех активити процесса, так что после перезапуска поиск элементов продолжает работать.

Почему метод не называется loginViaApi

Внутри не только API, там есть запись локального состояния, перезапуск и ввод пасскода через UI. По сути метод приводит приложение в состояние «существующий пользователь снова открыл приложение». Для большинства реальных пользователей это и есть обычная точка входа, поэтому метод назвали loginViaWelcomeBack, по тому, что получается на выходе.

В самом тесте при переходе меняется одна строка:

@Test
fun withdrawScreenIsDisplayed() = run {
    val user = TestUsers.userWithBankAccount()
    step("Precondition: open the Withdraw screen") {
        loginViaWelcomeBack(user)                 // было: login(user)
        scenario(GoToScreen(TargetScreens.WITHDRAW))
    }
    step("Test case") {
        screen.withdraw
            .verifyToolbarTitle("Withdraw")
            .verifyAmountFieldIsEmpty()
            .verifyContinueButtonIsDisabled()
    }
}
Где логин остался UI

Если после логина тест сам работает с авторизацией (экраны пароля или пасскода, восстановление пароля, выход и повторный вход), логин входит в сценарий и остаётся UI. Так остались весь auth-пакет, смена пароля и пасскода, выход, часть тестов закрытия аккаунта и тест с сутью «логин → дашборд». В таких тестах шорткат выбросил бы саму проверку. Всего 52 вызова остались UI, 412 переведены.

Как мы мерили

Сборка и тесты в обоих вариантах были одни и те же. На время замера новый метод можно было переключить обратно на UI-логин аргументом инструментации, после замера переключатель удалили:

fun loginViaWelcomeBack(user: TestUser) {
    // ВРЕМЕННО, для A/B: `-e loginMode ui` запускает старый UI-логин
    if (InstrumentationRegistry.getArguments().getString("loginMode") == "ui") {
        login(user)
        return
    }
    val startedAt = SystemClock.elapsedRealtime()
    /* ... новый логин ... */
    Log.i("LoginTiming", "API login took ${SystemClock.elapsedRealtime() - startedAt} ms")
}
// В UI-логине — такая же строка "UI login took N ms".

Время логина каждый метод пишет в лог отдельной строкой, время теста берём из JUnit-отчётов Marathon. Сравнивали только тесты, прошедшие в обоих вариантах: упавший тест может оказаться и быстрее, и медленнее, к скорости это отношения не имеет.

Прогон в облаке дольше часа падает, поэтому тесты разбили на пачки по 17–25 и гоняли последовательно. Для каждой пачки сначала шёл UI-вариант, сразу за ним новый, чтобы оба попали на похожую нагрузку на стенд. Одновременно работало не больше 6 устройств, упавшему тесту давали до 2 повторов.

Наборов было два, независимых: 185 тестов одной команды и 233 теста остальных. Цифры почти совпали, и это аргумент в пользу того, что результат не случайный.

Где мы сами ошиблись в замере

Сначала мы решили сделать умнее и запустили A/B параллельно: оба варианта по 185 тестов одновременно, каждый тест на своём устройстве. Это до 370 сессий к одному dev-окружению и общему пулу тестовых пользователей, и стенд лёг. Упало 144 и 158 тестов из 185: таймауты, «слишком много попыток OTP», дашборд не загрузился. После перехода на последовательный запуск и не больше 6 устройств падения ушли. Мы просто не учли, что сам замер нагружает стенд.

Вторая ошибка была в нашем же скрипте подсчёта. Он искал тег <failure> на той же строке, что <testcase>, а в XML-отчёте тег стоит на следующей. Из-за этого три теста выглядели так, будто падают только с новым логином. На деле они падали и со старым: тесты устарели и нажимали кнопку, которую убрали из UI. На поиск несуществующей регрессии ушла пара часов.

Результаты

Набор 1 (185 тестов)

Набор 2 (233 теста)

Логин, медиана

26,2 → 7,6 с (−71%)

25,5 → 6,9 с (−73%)

Тест, медиана (прошли в обоих)

65,9 → 46,9 с

58,0 → 40,5 с

Сумма времени тестов

11 558 → 8 430 с (−27%)

12 372 → 8 921 с (−28%)

Стали быстрее

163 из 163

189 из 196

Упали только на новом логине

4

2

Упали только на старом

1

0

Время логина: ~26 с через UI против ~7 с через auth-слой

Время логина: ~26 с через UI против ~7 с через auth-слой

У нового логина и разброс меньше: σ 1,6–1,8 с против 3,8–3,9 с у UI. Экранов меньше, поэтому и мест, где что-то может подтормозить, тоже меньше.

Разница длительности по каждому тесту: быстрее стали 352 из 359

Разница длительности по каждому тесту: быстрее стали 352 из 359

Медианная экономия около 18 секунд на тест. На 418 тестах это примерно 2 часа времени устройств за полный прогон, а при шести параллельных устройствах около 20 минут реального времени.

Стабильность

Часть тестов в обоих наборах падала в обоих вариантах по причинам, не связанным с логином: устаревшие тесты и нестабильность стенда. Из сравнения скорости мы их исключили. Интереснее тесты, которые упали только в одном варианте.

В первом наборе таких было 5, и 4 из них падали на одном и том же нестабильном экране. В другом варианте они на нём тоже падали, просто в другой попытке. Там медленно отвечает бэкенд, логин ни при чём.

Во втором наборе 2 теста падали только на новом логине, во всех трёх попытках. Оба работают с только что зарегистрированным пользователем, и в обоих на дашборде не появляется один из элементов. Гипотеза такая: UI-логин после создания пасскода запускает логику «первого входа», которую новый путь пропускает. Это не подтверждено. Этим двум тестам временно вернули UI-логин и вынесли их на отдельный разбор, а не спрятали за зелёным. Шорткат пропускает экраны вместе со всем, что на них происходит, и об этом лучше знать заранее.

Побочный эффект: нашли реальный баг

Пока внедряли новый логин, всплыл баг приложения, который проявлялся только в режиме UI-тестов: один из экранов входа зависал из-за неверно обработанного события. Нашли его по логам, в тестах обошли и передали разработчикам. Пользователей он не касался, но без перехода на новый логин мы бы его не заметили.

Правило выбора кандидатов

Выигрыш дал не сам переход с UI на API, а то, что из теста ушла длинная UI-цепочка, которая не лежит на его пути к целевому экрану. Если программный вызов добавляет медленный запрос, а приложение потом всё равно делает его само, тест становится медленнее.

Теперь кандидата проверяем по пяти пунктам:

  1. Заменяем только длинные UI-цепочки, которые тест не проверяет. Шаг, который и так лежит на пути к целевому экрану, уже дешёвый.

  2. До переноса замеряем ручки. Один список без пагинации может съесть весь выигрыш.

  3. Смотрим, не сделает ли приложение тот же запрос ещё раз. Если сделает, платить придётся дважды.

  4. Запрос из теста должен совпадать с запросом приложения поле в поле, иначе предусловие создаёт другое состояние.

  5. A/B мерим на одной сборке и одних тестах, последовательно и на ограниченном числе устройств, а сравниваем только тесты, прошедшие в обоих вариантах.

Тесты, где шорткат убивает смысл, не трогаем. Если логин и есть предмет теста, он остаётся через UI.


А у вас какой самый жирный кусок «времени на подготовку» прячется в сьюте, который ни один тест по сути не проверяет? И пробовали ли вы мерить, что он стоит на самом деле?

Схожие новости

#Наименование новостиТональностьИнформативностьДата публикации
1Можно ли спроектировать интерфейс для более ответственных решений?09.0114-09-2026
2Как мы тестируем интерфейсы в полной удалёнке: 5 рабочих способов без лабораторий и больших бюджетов07.6204-09-2026
3[Перевод] Понятие о конечных автоматах: руководство разработчика по предсказуемой логике приложений09.6829-05-2026
4Сколько стоит контроль над ИИ-агентом? Считаем экономику09.9823-07-2026
5Оптимизация без AI: как я автоматизировал API-ручки и типы-2529-06-2026
6SecretAuth – UX-решение для авторизации посредством приватного ключа09.7103-07-2026
7Мобильная разработка за неделю #636 (22 — 28 июня)5728-06-2026
8Ошибки в UX у Хабр07.8417-07-2026
9Сколько Shadow API спрятано в ваших продуктах и как вывести их из тени06.810-07-2026

Классификация: . Схожих патентов: 0. Схожих новостей: 9. Тональность: 0. Информативность: 13.39. Источник: habr.com.