1. Асинхронность и жизненный цикл UI-элементов: почему стандартные ожидания Selenide не подходят для счетчиков
Асинхронность и жизненный цикл UI-элементов: почему стандартные ожидания Selenide не подходят для счетчиков
Вы пишете конструкцию onAdvertisingModulePage().counter().shouldHave(text("5"), ofSeconds(6));, запускаете тест и своими глазами видите, как на экране появляется цифра 5. Однако тест падает. В логах Selenide сообщает, что по истечении шести секунд фактическое значение элемента оказалось равным «4» или «2». Возникает парадокс: человек видит нужный текст, а автоматизированный скрипт, имея в запасе целых шесть секунд на поиск, умудряется его пропустить. Проблема кроется не в багах фреймворка, а в фундаментальном расхождении между тем, как мы воспринимаем непрерывное изменение интерфейса, и тем, как WebDriver взаимодействует с браузером.
Чтобы понять причину нестабильности тестов на динамических компонентах, необходимо разобрать два скрытых механизма: анатомию асинхронных ожиданий и жизненный цикл элементов в современных frontend-фреймворках.
Иллюзия непрерывного наблюдения
Человеческий глаз воспринимает интерфейс как непрерывный поток света. Когда счетчик меняет значения 5-4-3-2-1 каждую секунду, мы видим весь процесс от начала до конца. Из-за этого возникает ложное интуитивное представление о работе метода shouldHave. Кажется, что Selenide «смотрит» на экран в течение заданных шести секунд и ждет появления нужного текста.
В реальности автоматизация работает по принципу стробоскопа. Selenide не наблюдает за браузером непрерывно. Он использует механизм поллинга (от англ. polling — опрос).
Алгоритм стандартного ожидания выглядит так:
!Сравнение частоты обновления UI и поллинга Selenide
Именно в фазе «уснуть» кроется первая уязвимость. Если нужная цифра появляется на экране ровно в тот момент, когда поток выполнения теста находится в состоянии Thread.sleep(), Selenide физически не может об этом узнать.
Однако сам по себе интервал в 200 миллисекунд кажется достаточно коротким. Если цифра «5» держится на экране целую секунду (1000 миллисекунд), Selenide должен успеть «проснуться» и проверить ее как минимум 4-5 раз за это время. Почему же тогда происходят пропуски? Здесь в игру вступает скрытая задержка архитектуры WebDriver.
Накладные расходы WebDriver и сетевая латентность
Команда counter().shouldHave(text("5")) не выполняется мгновенно. Архитектура взаимодействия между вашим Java-кодом и браузером многослойна.
Каждый раз, когда Selenide просыпается и запрашивает текст элемента, происходит следующая цепочка событий:
Время выполнения одного такого цикла мы назовем (время запроса). В идеальных локальных условиях может составлять 10–20 миллисекунд. Но при запуске в CI/CD пайплайнах, при использовании удаленных ферм (Selenoid, BrowserStack) или при высокой нагрузке на CPU машины, может непредсказуемо возрастать до 300, 500 и даже 800 миллисекунд.
Реальный интервал между проверками — это не просто время сна. Это время сна плюс время выполнения запроса: . Если из-за сетевой задержки или лагов браузера возрастает до 800 миллисекунд, фактический интервал между проверками становится равным 1 секунде. В такой ситуации тест может проверить интерфейс, когда там горит «6», уйти в долгий цикл запроса-ответа, и в следующий раз получить данные, когда счетчик уже переключился на «4». Цифра «5» проскальзывает между тактами опроса.
Жизненный цикл элемента: мутация против пересоздания
Вторая причина нестабильности кроется в том, как именно frontend-приложение обновляет счетчик на уровне DOM-дерева. Современные реактивные фреймворки (React, Vue, Angular) могут обновлять интерфейс двумя принципиально разными путями.
Первый путь: Мутация текстового узла (TextNode mutation).
В этом случае сам HTML-тег (например, <div id="counter">) остается в памяти браузера неизменным. Фреймворк лишь меняет текстовое содержимое внутри него. Для Selenide это самый благоприятный сценарий. Драйвер один раз находит элемент по локатору, кеширует ссылку на него и при каждом такте поллинга просто запрашивает его свойство innerText. Это работает быстро и предсказуемо.
Второй путь: Пересоздание элемента (DOM Re-rendering). Очень часто при обновлении состояния компонента фреймворк полностью удаляет старый HTML-узел из DOM-дерева и вставляет на его место новый, точно такой же узел, но с новой цифрой. Визуально для пользователя ничего не меняется. Но для WebDriver это катастрофа.
!Изменение DOM-дерева при обновлении счетчика
Когда Selenide обращается к элементу, который был удален из DOM, WebDriver выбрасывает StaleElementReferenceException (исключение устаревшей ссылки на элемент). Это означает, что указатель в памяти браузера, по которому драйвер пытался прочитать текст, больше не существует.
Selenide спроектирован так, чтобы автоматически обрабатывать такие ситуации. Получив StaleElementReferenceException, он не роняет тест сразу. Вместо этого он молча перехватывает исключение и пытается найти элемент заново, начиная поиск от корня документа.
Проблема в том, что обработка исключения и повторный поиск элемента по локатору — это чрезвычайно «дорогие» операции с точки зрения времени. Если счетчик пересоздается каждую секунду, Selenide может постоянно натыкаться на устаревшие ссылки. Время из-за постоянных повторных поисков лавинообразно возрастает. Тест тратит драгоценные миллисекунды не на чтение текста, а на попытки зацепиться за постоянно ускользающий из DOM-дерева элемент. В итоге, пока Selenide борется с исключениями, нужная цифра «5» появляется и исчезает.
Концептуальное ограничение Eventual Consistency
Описанные выше проблемы приводят нас к пониманию фундаментального ограничения стандартных инструментов ожидания. Методы вроде shouldHave или waitUntil в Selenide и чистом Selenium построены вокруг парадигмы Eventual Consistency (согласованность в конечном счете).
Эта парадигма предполагает, что интерфейс может находиться в нестабильном состоянии (загрузка, анимация появления), но в конечном итоге он придет к стабильному целевому состоянию и останется в нем. Классический пример — ожидание появления кнопки после отправки формы. Кнопка может появиться через 1, 3 или 5 секунд, но когда она появится, она никуда не исчезнет. Тест может проверять экран с любой задержкой; как только кнопка отрендерится, рано или поздно очередной такт поллинга ее зафиксирует.
Циклический таймер, отсчитывающий 5-4-3-2-1, ломает эту парадигму. Цифра «5» — это не конечное стабильное состояние. Это транзитное (переходное) состояние. Оно имеет жестко ограниченный срок жизни (ровно 1 секунду).
Стандартные ожидания Selenide не предназначены для ловли транзитных состояний. Они спроектированы так, чтобы быть «вежливыми» по отношению к системе: делать паузы между проверками, чтобы не перегружать браузер бесконечным потоком команд. Для стабильных состояний эта вежливость оправдана. Для быстро меняющихся транзитных состояний она фатальна.
Ваш кастомный метод endureRun(), построенный на базе Awaitility (с параметрами вроде pollInterval(Duration.ofMillis(200))), — это шаг в правильном направлении. Библиотека Awaitility изначально создавалась для гибкого управления асинхронными процессами в Java и позволяет отвязаться от жестко зашитых механизмов поллинга WebDriver. Она дает возможность агрессивно опрашивать состояние системы, игнорируя стандартные задержки фреймворка тестирования UI.
Однако, просто заменить Selenide.shouldHave на Awaitility.await недостаточно. Если не учитывать сетевую латентность и пересоздание DOM-узлов, даже Awaitility будет пропускать нужные значения. Чтобы заставить метод endureRun() работать надежно как швейцарские часы, необходимо изменить стратегию извлечения данных из браузера, минимизировав количество HTTP-запросов и исключив влияние StaleElementReferenceException.
Осознание того, что целевое состояние UI является транзитным, а инструменты проверки подвержены скрытым временным задержкам, — это ключ к созданию стабильных тестов. Переход от парадигмы «подождать, пока элемент стабилизируется» к парадигме «поймать элемент в жестком временном окне» требует иных технических подходов, которые строятся на минимизации накладных расходов между тестовым скриптом и браузером.