02.08.2026 129 материалов

Почему UI-тесты ломаются при каждом редизайне — и как читать дерево элементов прямо из симулятора

Записанные UI-тесты умирают при первом же редизайне — кто-то сдвинул кнопку, и координаты «340, 712» больше не ведут туда, куда нужно. Open-source-проект tapflow решил проблему, научившись читать дерево доступности (accessibility tree) прямо из headless-симулятора, без WebDriverAgent и без открытого окна.

Почему UI-тесты ломаются при каждом редизайне — и как читать дерево элементов прямо из симулятора

Лучший способ сделать 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 в диапазоне 01), 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.