Меня зовут Даниил Смирнов, я эксперт по автоматизации мобильного тестирования в Ozon Банке. Мы пишем UI-тесты для Ozon Банка, и примерно каждый второй экран — с WebView. Оферты, платёжные формы, веб-флоу — всё это рендерится внутри WKWebView.
И здесь XCTest оказывается почти бессилен.
С чем столкнулись
XCTest из коробки работает только с accessibility-деревом iOS. Система обходит экран и собирает все элементы, попавшие в это дерево: их видит и VoiceOver, и XCTest. Элементы попадают туда автоматически, если это стандартные UIKit-компоненты, или если разработчик явно задал accessibilityLabel / accessibilityIdentifier для кастомной вёрстки.
WKWebView — не исключение: он тоже может экспортировать элементы в accessibility-дерево, но делает это выборочно и только для определённых HTML-атрибутов. Движок WebKit маппит их в нативные accessibility-свойства iOS:
HTML-атрибут | iOS accessibility-свойство |
|---|---|
|
|
|
|
|
|
| привязка к лейблу через ссылку на ID другого элемента |
|
|
Именно эти атрибуты может найти XCTest внутри WebView. А data-testid — это кастомный data-атрибут, для которого нет соответствия в accessibility-спецификации. WebKit его игнорирует, в дерево accessibility он не попадает, и XCTest его не видит.
Посмотрим на конкретном примере. Допустим, есть платёжная форма с такими элементами:
<!-- DOM-дерево (всё, что реально в HTML) --> <form> <button class="btn-primary" data-testid="pay-button">Оплатить</button> <a href="/doc" data-testid="doc-link">Договор оферты</a> <input type="text" data-testid="amount-input" placeholder="Сумма"> <div class="info-block">Баланс: 1 000 ₽</div> </form>
А вот что из этого увидит XCTest через accessibility-дерево:
Button: "Оплатить" Link: "Договор оферты" TextField: <нет лейбла>
Текстовое поле в дерево попало (WKWebView экспортирует <input> как TextField), но без accessibilityLabel — у него нет aria-label, а placeholder в лейбл не маппится. Найти такой элемент можно только по индексу или порядковому номеру, что ненадёжно. Информационный блок (<div>) в дерево не попал — это статичный неинтерактивный элемент. А data-testid внутри этих тегов WebKit и вовсе игнорирует — XCTest их не найдёт, хотя в DOM они есть.
Написать app.staticTexts["submit-button"] не получится, даже если у элемента есть data-testid="submit-button".
Мы рассмотрели несколько вариантов.
Вариант 1 — aria-label. Фронтенд их проставляет, но они заточены под VoiceOver, а не под тесты. Лейблы бывают неуникальными, перегруженными текстом и зависят от локализации. Поменяли «Оплатить» на «Внести платёж» — тест упал.
Вариант 2 — поиск по координатам. Хрупкий и медленный подход. Небольшое изменение вёрстки — и тест приходится переписывать.
Вариант 3 — скриншотные сравнения. Тяжеловесные, чувствительные к рендеру. На двух-трёх экранах ещё ничего, но для целого приложения — не вариант.
Параллельно мы заметили, что команда фронтенда уже использует в своих тестах data-testid. Разработчики проставляют этот атрибут прямо в вёрстке: элемент получает уникальный идентификатор, который не зависит от текста и не меняется от локализации. Идеальный кандидат для автоматизации.
Оставался вопрос: как заставить XCTest увидеть data-testid внутри WebView?
Почему на iOS нельзя как на Android
Коллеги с Android в этом месте обычно удивляются: «У нас всё просто — включил setWebContentsDebuggingEnabled(true) и работаешь с WebView через UiAutomator».
В iOS так не получится.
WKWebView рендерит веб-контент в отдельном процессе — WebContent Process. У него своё адресное пространство, свой XPC-сервис, он изолирован от приложения. XCTest, в свою очередь, работает в третьем процессе — тестовый раннер запускается отдельно.
Получается цепочка: тест → приложение → WebView. Три изолированных процесса. Прямой доступ к DOM из теста невозможен.
Единственный легальный способ повлиять на WebView — выполнить JavaScript из самого приложения через webView.evaluateJavaScript(...). Но XCTest находится в другом процессе и не может вызвать этот метод. Следовательно, код для инъекции необходимо заранее включить в приложение и активировать только в тестовом режиме.
Если на Android достаточно флага отладки, то на iOS приходится менять архитектуру приложения.
Как дошли до решения
Сначала мы писали тесты на тех aria-label, что были во фронтенде. Работало нестабильно.
Первая проблема — неуникальность. Один и тот же aria-label мог быть на нескольких элементах, и тест не понимал, какой из них нашёл. На страницах со списками это превращалось в рулетку.
Вторая — локализация. Приложение на русском — кнопка «Оплатить». Переключили на казахский — тест упал. С точки зрения accessibility это корректное поведение, но автоматизации от этого не легче.
Третья — aria-label проставлялись для VoiceOver. Их структура не учитывала нужды тестов, да и не должна была — задачи у них разные.
Тем временем фронтендеры активно применяли data-testid — атрибут, который элемент получает на этапе разработки. Он уникален, стабилен, не зависит от текста. Всё, что нужно для автоматизации.
Оставалась одна проблема: XCTest видит accessibility-дерево, но не видит DOM. WKWebView — чёрный ящик.
Решение подсказали возможности самого WebView. WKWebView поддерживает выполнение JavaScript через evaluateJavaScript. Значит, можно написать скрипт, который обойдёт DOM, найдёт элементы с data-testid и на лету проставит им aria-label. XCTest этот лейбл уже подхватит.
Так появился скрипт, который:
Находит все интерактивные элементы с
data-testidФормирует для них
aria-labelпо единому шаблонуОтслеживает динамические изменения через
MutationObserverВнедряется в WebView при загрузке страницы
Важный момент: скрипт работает только в DEBUG-сборке на симуляторе. В релизную сборку он не попадает.
Как это работает
Шаг 1. Отбираем нужные элементы
Не каждый элемент на странице требует aria-label. Скрипт проверяет через isInteractive — интерактивен ли элемент:
function isInteractive(element) { const tag = element.tagName; if (tag === 'BUTTON') return true; if (tag === 'A' && element.hasAttribute('href')) return true; if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return true; const role = element.getAttribute('role'); if (role && [ 'button', 'link', 'checkbox', 'radio', 'switch', 'tab', 'combobox', 'textbox', 'slider' ].includes(role)) return true; return false; }
Кнопки, ссылки, поля ввода — всё, с чем пользователь может взаимодействовать. Статичные блоки мы не трогаем, в них нет смысла.
Шаг 2. Исключаем дублирование
Функция shouldSkip отсеивает элементы, которые и так доступны:
Элемент с
aria-labelledby— уже привязан к лейблуИзображение с
alt-текстом — уже читаетсяИнпут-кнопка с
value— скринридер считываетЭлемент, ассоциированный с
<label>— тоже доступен
Скрипт не перезаписывает существующие лейблы, если они информативны.
Шаг 3. Формируем лейбл
Формула: текст элемента | data-testid
Например:
Кнопка «Оплатить» с
data-testid="pay-button"→aria-label="Оплатить | pay-button"Ссылка без текста, но с
data-testid="doc-link"→aria-label="doc-link"Если
data-testidнеуникален — используется атрибутid
Искать элементы можно и по тексту, и по идентификатору, и по комбинации — все варианты работают.
Шаг 4. Отслеживаем изменения (SPA)
Современные веб-страницы часто меняют содержимое динамически: контент подгружается, элементы появляются и исчезают без полной перезагрузки. Одного прохода при загрузке недостаточно.
Используем MutationObserver:
let observer = new MutationObserver(onPossibleRender); observer.observe(document.body, { childList: true, subtree: true, attributes: true, });
При каждом изменении DOM скрипт через requestAnimationFrame заново проходит по дереву. Динамически подгруженные элементы гарантированно получают лейблы.
Итоговый скрипт
(function() { function isInteractive(element) { /* см. выше */ } function shouldSkip(element) { /* см. выше */ } function buildAriaLabel(text, testId) { if (text && testId) return `${text} | ${testId}`; return text || testId || ''; } function processElements() { const elements = document.querySelectorAll('[data-testid]'); elements.forEach(element => { if (shouldSkip(element)) return; const testId = element.getAttribute('data-testid'); const text = element.textContent.trim(); const currentLabel = element.getAttribute('aria-label') || ''; const newLabel = buildAriaLabel(text, testId); if (newLabel && newLabel !== currentLabel) { element.setAttribute('aria-label', newLabel); } }); } processElements(); const observer = new MutationObserver(processElements); observer.observe(document.body, { childList: true, subtree: true, attributes: true, }); })();
Как скрипт попадает в WebView
В iOS-приложении реализован обработчик, который срабатывает при завершении загрузки страницы в WebView. Он берёт JS-скрипт из конфигурации приложения и выполняет через webView.evaluateJavaScript(...).
Весь этот код присутствует только в отладочной сборке на симуляторе и только при запущенных UI-тестах. На этапе компиляции релиза он отсекается. Пользователи на него никак не влияют.
Что изменилось в accessibility-дереве
Вернёмся к той же платёжной форме и посмотрим, что произошло после работы скрипта. DOM-дерево теперь выглядит так:
<form> <button class="btn-primary" data-testid="pay-button" aria-label="Оплатить | pay-button">Оплатить</button> <a href="/doc" data-testid="doc-link" aria-label="Договор оферты | doc-link">Договор оферты</a> <input type="text" data-testid="amount-input" aria-label="Сумма | amount-input" placeholder="Сумма"> <div class="info-block">Баланс: 1 000 ₽</div> </form>
Скрипт добавил aria-label всем интерактивным элементам с data-testid. И теперь accessibility-дерево выглядит иначе:
Button: "Оплатить | pay-button" Link: "Договор оферты | doc-link" TextField: "Сумма | amount-input"
Текстовое поле появилось — у него теперь есть aria-label, который WebKit замаппил в accessibilityLabel. Информационный блок по-прежнему невидим, и это правильно: с ним не нужно взаимодействовать.
Благодаря единому формату лейблов в XCTest можно искать элементы по тексту или полному лейблу. Например:
// Поиск по полному лейблу app.buttons["Оплатить | pay-button"] // Поиск по тексту (части лейбла до разделителя) app.buttons["Оплатить"] // Для поиска по части лейбла используем NSPredicate app.buttons.element(matching: NSPredicate(format: "label CONTAINS 'pay-button'"))
Что это дало на практике
Унификация идентификаторов. Теперь и фронтенд, и iOS-тесты используют одни и те же data-testid. Больше не возникает ситуаций, когда фронтендер проставляет идентификатор для своих тестов, а iOS-команда запрашивает отдельный для мобильных. Один идентификатор — для всех платформ.
Меньше ложных падений. Тест перестал зависеть от текстового содержимого элементов. Изменение «Оплатить» на «Оплатить сейчас» не влияет на тест, поскольку поиск выполняется по data-testid="pay-button". Количество flaky-падений при текстовых правках заметно сократилось.
Ускорение разработки тестов. Раньше перед каждым тестом требовалось согласовывать с фронтендом уникальные aria-label. Сейчас процесс проще: если у элемента есть data-testid — тест готов. Если нет — фронтендер проставляет один идентификатор для всех платформ сразу.
Единый формат. Раньше aria-label могли выглядеть как угодно: «Кнопка оплаты», «payButton», «оплатить». Теперь формат един — "Текст | data-testid". Отладка и чтение тестов стали проще.
Безопасность. Скрипт присутствует только в DEBUG-сборке. Релизная сборка его не содержит.
Итог
JS-инъекция для тестовых локаторов — простой приём на стыке веба и натива. Он не требует сложной инфраструктуры, не замедляет тесты и решает проблему, с которой сталкивается практически любой, кто автоматизирует WebView в iOS.
Несколько выводов, которые мы для себя сделали:
evaluateJavaScript— легальный инструмент. WKWebView даёт доступ к DOM через JS, и это не хак, а штатная возможность.MutationObserverобязателен. На SPA-страницах без отслеживания изменений DOM тесты будут падать при первой же навигации.Один идентификатор — на все платформы. Когда фронтенд и мобильные тесты используют общие
data-testid, кросс-платформенная отладка существенно упрощается.Безопасность — на первом месте. JS-инъекция допускается только в DEBUG-сборке с обязательной проверкой окружения.
Наш подход не является универсальным — для отдельных сценариев могут потребоваться альтернативные решения. Но для нашей команды он оказался оптимальным по соотношению сложности поддержки и стабильности тестов.
Теоретически существует и другой путь: вместо JS-инъекции изнутри приложения можно подключиться к WebView напрямую через системный демон com.apple.webinspector, который встроен в iOS и занимается отладкой WebKit-контента. Именно так работает Appium: он подключается к этому демону через USB (на реальном устройстве) или Unix domain socket (на симуляторе) и общается с WebView по протоколу WebKit Remote Debugger — тому же, что использует Safari для Web Inspector. Однако реализация подобного подхода с нуля — это фактически написать свой Appium. Сложность разработки и поддержки такого решения существенно выше, чем у JS-инъекции, при тех же результатах. Поэтому мы остановились на более простом и предсказуемом варианте.

