Меня зовут Даниил Смирнов, я эксперт по автоматизации мобильного тестирования в Ozon Банке. Мы пишем UI-тесты для Ozon Банка, и примерно каждый второй экран — с WebView. Оферты, платёжные формы, веб-флоу — всё это рендерится внутри WKWebView.

И здесь XCTest оказывается почти бессилен.


С чем столкнулись

XCTest из коробки работает только с accessibility-деревом iOS. Система обходит экран и собирает все элементы, попавшие в это дерево: их видит и VoiceOver, и XCTest. Элементы попадают туда автоматически, если это стандартные UIKit-компоненты, или если разработчик явно задал accessibilityLabel / accessibilityIdentifier для кастомной вёрстки.

WKWebView — не исключение: он тоже может экспортировать элементы в accessibility-дерево, но делает это выборочно и только для определённых HTML-атрибутов. Движок WebKit маппит их в нативные accessibility-свойства iOS:

HTML-атрибут

iOS accessibility-свойство

aria-label

accessibilityLabel

role

accessibilityTraits (например, role="button" → trait кнопки)

alt (на <img>)

accessibilityLabel

aria-labelledby

привязка к лейблу через ссылку на ID другого элемента

title

accessibilityLabel (если нет aria-label)

Именно эти атрибуты может найти 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 этот лейбл уже подхватит.

Так появился скрипт, который:

  1. Находит все интерактивные элементы с data-testid

  2. Формирует для них aria-label по единому шаблону

  3. Отслеживает динамические изменения через MutationObserver

  4. Внедряется в 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-инъекции, при тех же результатах. Поэтому мы остановились на более простом и предсказуемом варианте.