Почему UI-тесты ломаются при каждом редизайне — и как читать дерево элементов прямо из симулятора
Записанные UI-тесты умирают при первом же редизайне — кто-то сдвинул кнопку, и координаты «340, 712» больше не ведут туда, куда нужно. Open-source-проект tapflow решил проблему, научившись читать дерево доступности (accessibility tree) прямо из headless-симулятора, без WebDriverAgent и без открытого окна.
Лучший способ сделать UI-тест живучим — перестать записывать, где вы тапаете, и начать записывать, по чему вы тапаете. Для этого нужно дерево элементов. Проблема в том, что из headless-симулятора его не так-то просто достать.
Знакомая история: кто-то сдвинул кнопку
Каждый разработчик мобильных приложений сталкивался с этим. Записанный UI-тест говорит: «тапни по координатам (340, 712)». Дизайнер двигает кнопку на одну строку вверх. Тест продолжает тапать — уже в пустоту или в совершенно другой элемент. Он не падает сразу. Через три спринта начинаются странные failures, доверие к тестовому комплекту evaporates, и команда махает рукой.
Корень проблемы — тесты фиксируют координаты, а не смысл. Дизайн меняется, координаты съезжают, и всё ломается. Оказывается, есть способ фиксировать не пиксели, а семантические идентификаторы элементов. Но для этого нужно сначала получить доступ к дереву UI-элементов — а в headless-среде (без окна симулятора на экране) это нетривиальная задача.
Что такое tapflow и зачем он нужен
tapflow — open-source, self-hosted инструмент, который стримит iOS-симуляторы и Android-эмуляторы в браузер. Вся команда может тестировать билды, не устанавливая ничего на свои машины. Раньше инструмент работал только с пикселями в одну сторону и тапами — в другую. Теперь он научился читать дерево элементов из симулятора на обеих платформах.
Стоит сразу оговориться: функционал автоматизации на основе этого дерева (flow runner и MCP-сервер) на данный момент заявлен как экспериментальный. Основной путь использования — ручное QA через браузер.
Ограничение: ни WebDriverAgent, ни окна на экране
tapflow уже умеет инжектить тачи в iOS-симулятор без WebDriverAgent — он загружает CoreSimulator.framework и отправляет HID-события через SimDeviceLegacyHIDClient. Стриминг читает фреймбуфер (IOSurface) напрямую. Никакой из этих путей не требует Simulator.app на экране — и это осознанное решение: агент-машина в серверном шкафу, запускающая четыре симулятора, не должна контролировать четыре окна.
Значит, и для чтения дерева нужен был аналогичный подход.
Первый провал: macOS Accessibility API
macOS предоставляет Accessibility API (AXUIElement), и Simulator.app публикует через неё своё содержимое. Разработчики tapflow написали хелпер — и он прекрасно работал на ноутбуке разработчика. Но на headless-пути возвращал пустоту: мост AX существует только пока отрисовывается окно симулятора. А симулятор, запущенный через simctl boot в актуальном Xcode, вообще не открывает окно.
Получился читатель дерева, который работает только в той ситуации, где он не нужен.
Решение: резидентный XCUITest-раннер внутри симулятора
XCUITest умеет читать дерево любого приложения по bundle id — это его прямое назначение — и он выполняется внутри симулятора, то есть окно не требуется. Проблема в том, что xcodebuild test заточен под модель «запусти тест, выведи результаты, завершись», а нужен процесс, который отвечает на запросы часами.
Решение элегантное: тест-таргет не тестирует ничего. Он поднимает HTTP-сервер и блокируется:
func testServeTree() {
let server = try! TreeServer(port: port)
server.start()
RunLoop.current.run() // блокировка навсегда — процесс живёт и обслуживает запросы
}
Сервер занимает около 100 строк на Network.framework и имеет два эндпоинта:
GET /health— проверка готовностиGET /tree?bundleId=<id>— возвращаетXCUIApplication(bundleIdentifier:).debugDescription— полное поддерево элементов в текстовом виде: роль, лейбл, идентификатор и фрейм каждого элемента на экране
Нюанс с сетевым привязыванием
iOS-симулятор разделяет сетевой стек с хостом. Если не ограничить привязку, NWListener слушает на всех интерфейсах — и каждый экран тестируемого приложения окажется виден из локальной сети без аутентификации. Для self-hosted инструмента это недопустимо. Приходится явно пинить endpoint на 127.0.0.1.
Корректное завершение и обработка ошибок
Одно из ключевых правил: пустой массив элементов никогда не означает «что-то сломалось». Если приложение ушло на задний план, bundle id неверен или тело ответа повреждено — это явная ошибка. «На экране нет элементов» должно означать ровно это и ничего больше. Иначе каждый тест, построенный поверх такого API, становится неоднозначным.
Android оказался простым
uiautomator dump выдаёт XML-иерархию «из коробки», парсер тривиален. Единственный подводный камень — uiautomator ждёт, пока окно перейдёт в idle-состояние перед дампом. Если приложение показывает непрерывную анимацию (спиннер, зацикленный splash-экран), idle не наступает, и дамп зависает.
Таймаут должен работать на устройстве, а не на хосте:
adb exec-out timeout 10 uiautomator dump /dev/tty
Здесь timeout — утилита из Android-шной toybox.
Единая схема и нормализация координат
Оба бэкенда (iOS и Android) парсятся в одинаковую структуру UIElement:
role, label, identifier, frame (x, y, width, height в диапазоне 0–1), enabled
Нормализация role сводит два словаря в один: iOS-шные XCUIElementType и Android-классы (AppCompatButton, AutoCompleteTextView, RecyclerView и т.д.) маппятся в единые типы. Порядок матчинга важен: составные имена (например, ToggleButton) должны резолвиться раньше, чем их подстроки (Button).
Нормализация фреймов в пространство 0–1 — ключевое упрощение. iOS отдаёт координаты в points (делим на размер окна), Android — в пикселях (делим на размер корневого узла). После этого весь пайплайн становится линейным:
запрос дерева → center фрейма элемента → tap(x, y) → тот же HID-путь, что и ручной тап
Никакого второго соединения с устройством, никакого отдельного драйвера, никаких дополнительных конвертаций координат.
Что это даёт на практике
Раньше тест мог описать точку тапа только так:
(340, 712)
Теперь — так:
tapOn: "Sign in"
Система находит элемент по дереву: сначала точное совпадение identifier, потом label, потом частичное совпадение label. Координаты берутся из центра фрейма найденного элемента. Кнопку можно двигать куда угодно — тест найдёт её заново.
Дерево доступно и через REST API, и как MCP-тул для LLM-агентов. Ранее в серии статей автор давал агенту «глаза» через скриншоты. Теперь агент получает ещё и имена того, на что он смотрит.
Ограничения, которые стоит знать
- iOS-дерево строится из
debugDescription— это текстовый формат, а не стабильный API. Парсер покрыт fixture-тестами, так что изменения формата Xcode упадут в unit-тестах, но сам формат может измениться в любой момент. - Поле
enabledна iOS приблизительное —debugDescriptionего не экспонирует, поэтому все элементы по умолчаниюenabled: true. - Один резидентный раннер на устройство — он слушает на фиксированном порте (
xcodebuildне прокидывает переменные окружения хоста в раннер внутри симулятора). Параллельные запросы дерева с нескольких устройств пока отложены.
Почему это важно
Проблема ломающихся UI-тестов — не теоретическая. Она присутствует в каждом мобильном проекте, где есть записанные сценарии. Дизайнер двигает элемент, тест молча тапает в пустоту, никто не заменяет до первого инцидента.
Подход tapflow — фиксировать не координаты, а идентификаторы элементов — радикально повышает живучесть тестов. При этом инструмент остаётся self-hosted и не требует WebDriverAgent, что критично для CI-среды, где симуляторы крутятся без GUI.
Конечно, зависимость от debugDescription — это хрупкий фундамент. Apple может поменять формат вывода в любой версии Xcode. Но с другой стороны, для многих команд это всё равно надёжнее, чем координаты, которые ломаются при каждом редизайне.
Установить tapflow можно прямо сейчас:
npm install -g tapflow
tapflow start
Документация: tapflow.dev, исходники: GitHub.