Один фреймворк для Android и iOS: звучит круто, работает? Нет
Идея одного кроссплатформенного фреймворка для автотестов на Android и iOS звучит красиво: одна кодовая база, одни Page Objects, вдвое меньше работы. На практике это оборачивается merge-конфликтами, падающими из-за чужой платформы тестами и общими абстракциями, которые ломаются на каждом обновлении. Рассказываю, почему в итоге разделил всё на два отдельных проекта, и почему ни разу об этом не пожалел.
Я сделал автотесты на Appium для Android и iOS в одном проекте. Один репозиторий, общий фреймворк, общие Page Objects, общие steps. Красота.
Потом проект вырос, в команду пришли ещё люди, и стало понятно: этот красивый монолит пора разделять.
Это было взвешенное решение. В какой-то момент общий фреймворк превратился в поле мерж-конфликтов, где ты уже не тесты пишешь, а разбираешься, кто чей page object сломал и почему Android отвалился после правки для iOS.
Appium тут ни при чём, как и скорость тестов. Проблема была в архитектуре: один общий слой для двух разных платформ начал мешать сильнее, чем помогать.
Как говорил один мой коллега, перефразируя классическое “вам шашечки или ехать”:
Ты сюда страдать пришёл или тесты писать?
После этого решение разделить Android и iOS на два проекта стало очевидным.
Сейчас у меня два отдельных проекта: mb-android-tests и mb-ios-tests. И знаете что? Это лучшее архитектурное решение за всё время на этом проекте. Серьёзно.
Контекст: что за проект и почему это важно
Финтех. Нативное приложение. Отдельные кодовые базы, Kotlin на Android, Swift на iOS. И UI там не «три кнопки и список», формы с десятками полей, кастомные контролы, WebView-вставки, платежи, валютные операции, бюджетные переводы. Ну вы поняли.
Стек: Java 21, TestNG, Appium 2.x, Selenide, Allure, Maven. CI на GitLab, крутится на Mac Mini. 5 Android-эмуляторов, 4 iOS-симулятора, 9 Appium-серверов — по одному на устройство.
Но это сейчас. А начиналось всё с одного жирного монолита.
Старый проект: 8 модулей и конструктор-монстр
Идея была «как в книжке»: один репозиторий, модульная структура, переиспользование. DRY во все поля. Ну а чё, красиво же.
8 модулей. Один mvn clean install. Все зависят от mobile-automation-framework, в котором живут DriverFactory, BaseTest, общие Page Objects и слой Steps.
А DriverFactory принимал 11 параметров. Одиннадцать, Карл.
Половина нужна только Android, другая только iOS. Но метод один. Потому что так более по программистцки, как написано в каждой второй статье про архитектуру автотестов.
Когда я работал один, было норм. Ты сам знаешь, от куда что идет.
А потом пришли люди.
Почему с людьми всё навернулось
Мерж-конфликты каждый божий день
Вот конкретная ситуация. Один человек правит LoginPage в общем фреймворке, добавляет метод для iOS, меняет локатор. Второй в тот же момент рефакторит этот же LoginPage под новую версию Android-приложения.
Мерж. Конфликт.
И тут начинается самое весёлое: какой локатор правильный? Для Android? Для iOS? Для обоих? Кто будет разбираться? Тот, кто мёрджит? А он вообще контекст понимает?
Это не единичный случай. Это было каждый день. Потому что mobile-automation-framework получился общим модулем, от которого зависят все. И все туда лезут. Постоянно.
Обновление одной библиотеки = блокировка всех
Решил обновить java-client с 7.x на 8.x. Нормальное желание — API поменялся, MobileElement удалили, capabilities переехали на типизированные Options.
Вот что произошло в реальности:
- Меняю версию в parent pom.xml
- MobileElement удалён → compilation error в DriverFactory, во всех DriverManager’ах
- DesiredCapabilities → UiAutomator2Options / XCUITestOptions → переписываю все менеджеры
- selenide-appium 1.x несовместим с java-client 8.x → обновляю до 2.x
- selenide-appium 2.x требует Selenide 6.x → обновляю Selenide
- Selenide 6.x ломает API page objects → переписываю page objects во всех модулях
- А Selenide 6.x ещё и Java 8 не поддерживает → обновляю Java
Одна библиотека.
Задача «обновить одну библиотеку» превратилась в 50+ файлов, 4-5 каскадных обновлений, 2-3 недели работы. И всё это время develop нестабилен. Все заблокированы. Тесты не запускаются.
Две недели.
Из-за одной строчки в pom.xml.
Page Objects с if/else — это два page object в одном файле
Общие Page Objects звучат красиво на бумаге: пишем один раз, используем для обеих платформ. А на практике…
Это не page object. Это два page objects, запиханных в один файл через if/else. И так в каждом методе.
А сверху ещё слой Steps. Обёртки над page objects «для читаемости». И в steps тоже if (platform), потому что логика взаимодействия разная. На Android ты скроллишь через UiScrollable семантически, «прокрути до элемента с текстом X». На iOS координатами, «двигай палец из точки A в точку B». Один метод в steps, а внутри два разных мира.
В какой-то момент ловишь себя на мысли: я же не тесты пишу. Я подгоняю pages и steps под две платформы, чтобы фреймворк не развалился. Это уже не автоматизация тестирования, это поддержка фреймворка ради поддержки фреймворка.
Каждый работает в своём темпе и все друг другу мешают
У одного задача покрыть тестами новый экран на Android. У второго пофиксить падающие тесты на iOS. У третьего добавить тестовые данные.
Все трое лезут в mobile-automation-framework. Все трое меняют pom.xml, BaseTest, page objects. Каждый в своей ветке.
А потом мёрдж.
Технические причины, которые добили окончательно
Ладно, человеческий фактор. Но были и чисто технические штуки, которые окончательно убедили меня, что кроссплатформа тут бессмысленна.
Локаторы: работай с тем, что дают
Сразу скажу то, о чём мало кто пишет в статьях: в мобильной автоматизации ты работаешь с тем, что дают, а не с тем, что хотелось бы.
В теории есть AccessibilityID единый локатор для обеих платформ. Звучит хорошо. А на практике? Попробуй попросить мобильных разработчиков расставить одинаковые accessibility-идентификаторы на сотнях экранов. На Kotlin и Swift. Одновременно. Они тебя вежливо пошлют, но скорее всего не вежливо. И будут правы, у них свои спринты, свои дедлайны, и твои автотесты в их приоритетах где-то между «поправить тень у кнопки» и «никогда».
Так что реальный выбор:
XPath - работает, но на iOS может быть тормозным. На Android через UiSelector быстро, нативно, надёжно. На iOS через NSPredicate тоже ок. А «универсальные» XPath типа //*[@text='Войти' or @label='Войти'] это путь к боли, потому что Page Source на двух платформах два совершенно разных XML-дерева.
Координаты - костыль? Да. Но иногда единственный вариант. Когда у тебя кастомный контрол без accessibility-свойств, а разработчик говорит «это декоративный элемент», ты либо тыкаешь по координатам, либо не тестируешь.
OpenCV - есть ещё вариант с распознаванием элементов по картинке. Для некоторых кейсов реально выручает. Но это отдельная тема на целую статью, может напишу потом.
Суть в том, что «напиши один локатор и работай на двух платформах» это миф. Page Source на Android это android.widget.Button с resource-id. На iOS XCUIElementTypeButton с name и label. Разные типы элементов, разные атрибуты, разная глубина вложенности. Разные миры.
StaleElementReferenceException — «подарок» от iOS
Android рендерит UI через Choreographer синхронно с VSYNC. Элемент появился → стабилен → кликаем.
iOS рендерит через CoreAnimation с неявными анимациями. Элемент есть в дереве, но ещё анимируется. Формально кликабелен, фактически хрен.
Стандартный WebDriverWait с elementToBeClickable это не ловит. Элемент «кликабелен» по мнению Appium wait завершается. А потом click() падает, потому что DOM изменился между вызовами.
На Android этой проблемы нет вообще. Тот же тест, тот же wait, тот же код зелёный на Android, красный на iOS. Один и тот же метод, два разных результата. И это не баг это фундаментальное различие платформ.
Кастомные wait-стратегии для iOS отличаются от Android принципиально. Пихать их в один фреймворк строить leaky abstraction, которая протекает на каждом шаге.
Жесты — два разных мира
Свайп вниз на Android:
Свайп вниз на iOS:
Чувствуете разницу? И даже длительность свайпа важна: на Android 200мс это нормальный скролл. На iOS 200мс это fling, и элемент улетает за экран.
«Кроссплатформенная» обёртка для скролла функция на 40 строк с двумя ветками if (platform), внутри каждой и ещё if для performSwipeDown() vs performSwipeUp(). На 50+ экранах таких обёрток, которых десятки.
И каждая место для бага.
Решение: разделяй и властвуй
Решение было болезненным. Я реально откладывал, потому что казалось, что это шаг назад. Дублирование кода, нарушение DRY, всё такое. Мозг сопротивлялся.
А потом я просто сел и разделил.
Никаких общих page objects. Никаких общих steps. Никакого общего DriverFactory с 11 параметрами. Каждый проект со своим pom.xml, свои зависимости, свой BaseTest, свои локаторы.
Что осталось общего: подход к структуре (Maven + TestNG + Allure), CI-шаблоны, принципы организации. Но не код. Код полностью раздельный.
Что поменялось на практике
В Android-проекте DriverManager типизирован под AndroidDriver. В iOS под IOSDriver. Не AppiumDriver<MobileElement> с кастами и проверками, а конкретный тип для конкретной платформы. IDE подсказывает только релевантные методы. Компилятор ловит ошибки на этапе сборки, а не в рантайме.
BaseTest принимает ровно те параметры, которые нужны. Android: deviceName, platformVersion, udid, appPackage, appActivity, systemPort, serverUrl, appPath — 8 штук, все релевантные. iOS: deviceName, platformVersion, udid, appPath, serverUrl, wdaLocalPort — 6. Никаких @Optional("systemPort") со строкой “systemPort” в качестве дефолта.
А мерж-конфликты?
Когда один человек работает в mb-android-tests, а второй в mb-ios-tests то они вообще не пересекаются. Разные репозитории. Конфликт невозможен физически.
Обновление java-client в Android-проекте не затрагивает iOS. Хочешь обновить, обновляй. iOS продолжает работать на старой версии.
Добавить новый параметр для iOS? Меняешь BaseTest и testng.xml в iOS-проекте. Android даже не узнает.
Это прям кайф.
А как же дублирование кода?!
Конечно, конечно. Первый вопрос, который задают: «Но ведь ты пишешь тесты дважды!»
Нет.
Я пишу каждый тест один раз и без костылей. Без if (platform) в каждом методе. Да, для одного и того же экрана есть два теста, один для Android, один для iOS. Но каждый из них чистый, понятный, заточенный под свою платформу.
В старом варианте я тоже «писал один раз», а потом тратил столько же времени на поддержку if/else, мерж-конфликты и каскадные обновления. «Экономия» на создании оборачивалась переплатой на поддержке. С процентами.
Когда кроссплатформа всё-таки ок
Я не топлю за то, что кроссплатформенный фреймворк абсолютное зло. Есть ситуации, где он работает:
Приложение простое, пара экранов, стандартные контролы, минимум кастомного UI.
UI реально идентичен на обеих платформах. Бывает, наверное.
Команда из одиного человека. Сам пишешь, сам мёрджишь, сам разруливаешь. Голова справляется.
Минимум жестов. Нет сложных свайпов, drag-and-drop, pinch-to-zoom.
Если у тебя финтех, банк, enterprise-приложение с сотнями экранов, кастомными контролами и тремя+ QA, то два проекта дешевле. Не в строках кода, а во времени. В нервах. В часах, потраченных на мерж-конфликты вместо написания тестов.
Итого
Два проекта это не шаг назад и не двойная работа. Со стороны может выглядеть именно так, но на практике ты пишешь каждый тест один раз, под конкретную платформу, без лишних компромиссов. Потом поддерживаешь его без постоянных мерж конфликтов, каскадных обновлений и if/else в каждом втором методе.
Кроссплатформенный фреймворк на Appium для сложного нативного приложения часто оказывается иллюзией экономии.
В следующей статье покажу, как у нас устроена инфраструктура для 9 параллельных устройств на одном Mac Mini: порты, bash скрипты, Appium серверы и workaround’ы, которых нет в документации. Спойлер: там тоже всё не совсем как в гайдах.

