|
Professor Seleznov
|
У каждого автотестера, наверное, есть такая идея, которая крутится в голове годами. Ты понимаешь, что она должна работать, но всё время не до неё: текущие задачи, дедлайны, «и так сойдёт». У меня такой идеей было реализовать вменяемое ожидание готовности страницы. Не конкретного элемента, а страницы целиком. Годами я это откладывал. А потом сложились обстоятельства, и идея наконец получила и повод, и площадку. Внедрить её удалось на моём прошлом месте работы, где специфика инфраструктуры и нагрузки на тестовые стенды сделали этот вопрос критичным. Сразу обозначу важный момент: все описанные далее архитектурные ограничения, проблемы с окружением и технические вызовы относятся исключительно к тому периоду и тому проекту. Но вот что меня беспокоит. Я потратил немало времени на поиск аналогов и не нашёл ничего похожего. Это может означать что угодно. Может, подход слишком нишевый. Может, все и так это делают, просто не пишут. А может, в нём есть фундаментальная проблема, которую я не вижу. Именно поэтому я пишу эту статью. Не как руководство, а как приглашение к разбору. Если у подхода есть слабое место — напишите, я хочу о нём узнать. Обслуживаемая система В обслуживании у меня была система с перевёрнутой пирамидой тестирования. 600 e2e тест-методов, разворачивающихся в 4000+ e2e‑тестов на Селениумес Хромом фиксированнойверсии.
 Селениумы запускаются в Selenoid-контейнерах на десктопных и серверных машинах, маршрутизируются черезGGR.
Использование такой архитектуры стало следствием недостатка серверных вычислительных мощностей для нормального распараллеливания браузеров в тестах. Были свободные десктопы — решено было перенести часть тестирования на них.
Инфраструктурные проблемы Изначально тестовые стенды и инфраструктура автотестов имели достаточно серверных ресурсов. Прогоны были относительно стабильными, а редкие флаки можно было списать на некачественные тесты. Однако со временем из-за сокращения финансирования часть ресурсов была выведена из эксплуатации. При этом требования к инфраструктуре не изменились: на тех же мощностях продолжали крутиться стенды разработчиков, ручных тестировщиков и автотесты. Таким образом, все три типа активности конкурировали за одни и те же ресурсы. В рабочие часы это приводило к сильной деградации производительности тестовой среды. Наиболее заметно это проявлялось в сетевых запросах. В нормальных условиях многие API-вызовы выполнялись за десятки или сотни миллисекунд. При высокой нагрузке те же запросы могли выполняться несколько секунд. В отдельных случаях задержки доходили примерно до 40 (Карл!) секунд. Такая нестабильность напрямую отражалась на результатах UI-тестов. Днём доля флаков могла доходить примерно до 20%. Ночью, когда активность разработчиков и ручного тестирования снижалась, те же тесты проходили значительно стабильнее. Задача: «Иди и стабилизируй автотесты» Локализация проблемы Сама проблема банальна для мира UI-тестирования. Большинство современных веб-интерфейсов формируют страницу не одним запросом, а несколькими независимыми API-вызовами. Каждый из них отвечает за свою часть данных, после чего фронтенд обновляет соответствующие участки интерфейса. Типичный сценарий загрузки страницы выглядит примерно так:
- Загружается базовая разметка страницы.
- Фронтенд отправляет несколько API-запросов.
- По мере получения ответов обновляются соответствующие части интерфейса.
- После обновления DOM могут инициироваться дополнительные запросы, зависящие от уже полученных данных.
В нормальных условиях эти запросы выполняются быстро и влияние задержек почти не заметно. Пользователь видит страницу, которая «почти сразу» приходит в финальное состояние. Но при деградации инфраструктуры время выполнения отдельных запросов начинает сильно отличаться: один ответ приходит через 200 мс, другой — через 15 секунд. В этот момент интерфейс часто оказывается в промежуточном состоянии:
- часть элементов уже обновилась по пришедшим данным;
- часть всё ещё ждёт ответа и показывает либо пустое место, либо устаревшие значения.
Снаружи такая страница выглядит вполне корректно: нужные контейнеры компонентов уже в DOM, но содержимое ещё не соответствует финальному состоянию. Именно здесь начинались проблемы для тестов.
Таким образом источник флаков: проверка состояния страницы выполнялась раньше, чем завершались запросы, формирующие это состояние.
Поиск решения Процесс поиска решения я скрыл под спойлер.
Три подхода
Добавить признак «страница готова»
Самый очевидный вариант — попросить фронтенд добавить некоторый признак того, что страница полностью готова к взаимодействию. Это мог быть:
- специальный DOM-атрибут;
- глобальный JS-флаг;
- служебный элемент в разметке.
Тесты могли бы просто ждать этот сигнал перед началом работы со страницей.
Идея выглядела разумной, но на практике оказалась трудно реализуемой. Фронтенд представлял собой большое SPA, которое развивала команда из 25+ разработчиков. Постоянно появлялись новые компоненты, старые переписывались, менялись цепочки загрузки данных.
В таких условиях ввести универсальный признак «страница полностью готова» означало бы фактически договориться, что все компоненты фронтенда должны сигнализировать о завершении своей асинхронной работы и делать это единообразно.
Для системы, которую активно развивает несколько десятков разработчиков, это означало бы постоянный контроль за тем, чтобы новые компоненты не нарушали этот контракт. Очевидно, что ради нужд автотестов никто бы такую дисциплину поддерживать не стал.
Если убрать все формальности, эти несколько абзацев можно сократить до одной фразы: разработка меня послала 
Ожидания перед каждым действием
Далее я начал решать проблему в лоб. Перед кликами, вводом текста и проверками состояния элементов добавлялись ожидания появления или доступности нужного элемента. Для Selenium это стандартная практика, и в большинстве проектов именно так и решается проблема асинхронности интерфейса.
Однако довольно быстро стало понятно, что ожидание самого элемента не всегда решает проблему.
Типичная ситуация выглядела примерно так: появился контейнер компонента↓тест нашёл элемент↓клик / проверка состояния↓данные компонента ещё не загрузились
С точки зрения Selenium всё корректно: элемент найден, взаимодействие выполнено. С точки зрения интерфейса — состояние страницы ещё промежуточное.
Кто-то может справедливо заметить: наличие контейнера ещё не означает, что компонент действительно отрисовался. Контейнер может появиться в DOM значительно раньше, чем в него будут подставлены данные.
Поэтому следующим шагом стала проверка внутреннего состояния компонента. То есть ожидание не просто контейнера, а конкретных элементов внутри него: текста, значений полей, кнопок, индикаторов и т.п.
Но и здесь возникла другая проблема — структура содержимого часто зависела от типа отображаемого объекта.
Например, часть интерфейса могла отображаться в двух режимах:
- полный режим
- упрощённый режим (например, объект имеет флаг isSimple)
В упрощённом варианте часть элементов просто отсутствовала, часть могла содержать пустые значения, а часть отображалась в другой структуре.
В итоге для корректного ожидания приходилось учитывать:
- тип объекта;
- допустимые отсутствующие элементы;
- поля, которые могут быть пустыми;
- поля, которые обязаны быть заполнены.
Фактически каждая такая проверка становилась небольшой конфигурацией правил для конкретного компонента. Со временем это бы превратилось в монструозную систему условий. Любое изменение во фронтенде требовало обновлять соответствующие проверки в тестах. Добавлялись новые типы объектов, менялась структура компонентов, появлялись дополнительные состояния.
Проще говоря, попытка описать «готовность» каждого элемента отдельно превратилась бы в бесконечную настройку частных случаев.
Смена фреймворка
Ещё один вариант — перейти на другой фреймворк, в котором механизмы ожиданий и синхронизации устроены лучше.
Сам по себе такой переход не выглядел невозможным. Основная работа с браузером была сосредоточена в центральном классе MyBrowser, так что базовый рефакторинг выглядел вполне подъёмным. Но проблема была не только в переписывании центрального класса.
Текущий запуск тестов уже был завязан на существующую инфраструктуру браузерных сессий, включая GGR и распределение части браузеров на десктопные машины. При смене фреймворка этот контур пришлось бы адаптировать заново: где-то перенастроить, где-то дописать недостающий слой маршрутизации и запуска. То есть переход затрагивал не только тестовый код, но и способ исполнения всего набора.
Но даже это не было главным риском. Главный вопрос был в другом: что именно мы получаем взамен?
Наши флаки возникали не из-за отсутствия очередного вейта, а из-за промежуточных состояний страницы. Экран собирался из нескольких независимых запросов, и в условиях перегруженной среды любой из них мог заметно задержаться. В результате элемент уже существовал в DOM, мог быть видимым и формально доступным для взаимодействия, но его конечное состояние ещё зависело от не-завершившейся асинхронной загрузки.
Именно здесь у нового фреймворка не было гарантированного преимущества. Да, инструменты вроде Playwright заявляютболее сильные встроенные механизмы синхронизации. Но я не нашёл оснований считать, что этого достаточно для нашей ситуации. Автоматические ожидания хорошо закрывают готовность элемента с точки зрения браузера, но не гарантируют, что страница уже перешла в нужное бизнес-состояние и все данные, влияющие на конкретный компонент, уже загружены.
То есть смена фреймворка требовала бы:
- рефакторинга текущего браузерного слоя;
- адаптации существующей инфраструктуры запуска;
- перепроверки большого набора тестов после миграции.
При этом она не давала ответа на главный вопрос: исчезнет ли именно тот тип флаков, который был связан с частично загруженной страницей. Поэтому этот вариант не выглядел решением с предсказуемым результатом.
Поиск решения: итог Все очевидные варианты либо не решали проблему полностью, либо требовали слишком серьёзных изменений инфраструктуры или тестовой базы. Поэтому оставался единственный путь: сделать собственный механизм определения «готовности» страницы, который:
- не требует изменений во фронтенде;
- не привязан к конкретным элементам;
- не требует описывать кастомные ожидания для каждого действия;
- работает поверх существующей Selenium-инфраструктуры.
Дальше речь пойдёт о том, как именно этот механизм был реализован.

НТР — Костыль и велосипед Что такое «готовность» После того как отпали варианты с фронтенд-ключом, точечными ожиданиями и сменой фреймворка, нужно было сформулировать саму цель точнее. Нужно понять, что именно должно считаться признаком готовности страницы. Если смотреть на это с практической стороны, тесту перед действием нужно получить ответ всего на два вопроса:
- продолжает ли страница получать данные, которые могут изменить интерфейс;
- продолжает ли интерфейс перестраиваться в браузере.
Это разбиение основывается на идее, что почти любое промежуточное состояние страницы возникает из одной из двух причин (см. предыдущий пункт). Первая — в систему ещё приезжают данные. Экран может быть уже частично отрисован, контейнеры компонентов могут существовать, часть элементов может даже быть доступна для взаимодействия. Но если один из запросов ещё выполняется, состояние страницы нельзя считать финальным: вместе с ответом может приехать новый текст, измениться набор полей, активироваться или деактивироваться кнопка, появиться или исчезнуть блок. Вторая — данные уже приехали, но фронтенд ещё не закончил применять их к интерфейсу. Даже если сеть в этот момент «молчит», это не означает готовность страницы. Компоненты могут всё ещё домонтироваться, менять атрибуты, обновлять внутреннее состояние, перестраивать вложенные узлы, запускать дополнительную отрисовку. Для Selenium это особенно неприятный сценарий: элемент уже найден, но UI ещё не дошёл до устойчивого состояния. Именно отсюда и появилась идея, что механизм готовности нужно строить не вокруг конкретных элементов, а вокруг двух более общих признаков:
- Network Idle — страница больше не тянет данные, которые могут изменить текущий экран;
- DOM Idle — интерфейс перестал меняться и находится в устойчивом состоянии.
Поэтому итоговая цель сформулировалась так: перед любым значимым действием тест должен убеждаться, что страница одновременно перестала получать данные и перестала менять DOM. Только в этом случае состояние можно считать достаточно стабильным для продолжения сценария.
Я осознаю, что такой подход применим не ко всем типам интерфейсов. Он кажется логичным для большинства SPA, где экран проходит фазу загрузки и затем приходит в относительно стабильное состояние. Но для систем с непрерывным обновлением — например, экранов котировок, live-дашбордов или мониторинговых панелей — сама идея ожидания Network Idle + DOM Idle может быть неприменима: состояние полной «тишины» там либо не наступает, либо слишком кратковременно, чтобы использовать его как надёжный критерий готовности.
Реализация CDP для Network Idle и MutationObserver для DOM Idle После того как критерий готовности страницы был сведён к двум независимым признакам — следующая задача стала сугубо инженерной: нужно было выбрать такие механизмы наблюдения, которые дают достаточно низкоуровневый доступ к этим процессам и при этом встраиваются в уже существующую Selenium-инфраструктуру без модификации приложения. Network Idle через CDP Для сетевого канала первым кандидатом стал Chrome DevTools Protocol. Это решение не было случайным. К этому моменту CDP уже использовался в проекте для более узкой задачи: стабилизации отдельных сценариев через ожидание конкретных HTTP-запросов на уровне некоторых Page Object. Chrome DevTools Protocol — это тот же протокол, через который работает панель разработчика в Chrome. Когда вы открываете вкладку Network и видите список запросов, браузер внутри себя генерирует события по этому протоколу: «запрос отправлен», «ответ получен», «загрузка завершена» и т.д. CDP позволяет подключиться к браузеру извне и подписаться на эти же событияпрограммно. Selenium умеет открывать такое соединение через DevTools-сессию. Это означает, что тестовый код может получать уведомления о сетевых событиях браузера в «реальном» времени — без каких-либо изменений в самом веб-приложении.
Описание принципа реализации
Идея была простой: завести счётчик активных запросов.
При получении события requestWillBeSent — инкрементировать счётчик.
При получении loadingFinished или loadingFailed — декрементировать.
Если в какой-то момент счётчик равен нулю — значит, все отправленные запросы завершились и браузер не ждёт данных.
Схематично это выглядело примерно так:
CDP-событие: requestWillBeSent → activeRequests++ CDP-событие: loadingFinished → activeRequests-- CDP-событие: loadingFailed → activeRequests-- Network Idle = (activeRequests == 0)
С точки зрения тестового кода это выглядело как подписка на события DevTools-сессии: при создании браузера код подключался к CDP, включал домен Network, и начинал считать запросы.
На этом этапе казалось, что для сетевого сигнала больше ничего не нужно.
Dom Idle через MutationObserver Если CDP в этой истории — рациональный выбор инструмента для сетевого слоя, то MutationObserver — это фундамент, без которого, наверное, не было бы данной статьи. MutationObserver — это стандартный браузерный API, который позволяет подписаться на изменения в DOM-дереве страницы. Если где-то в документе добавился элемент, удалился узел, изменился атрибут — MutationObserver вызовет callback и передаст список произошедших изменений. Важно понимать: MutationObserver не следит за конкретным элементом. Он может быть подключён к любому узлу DOM, включая document.body, и наблюдать за всем поддеревом. Это делает его пригодным для задач, где нужно отслеживать любые изменения на странице, а не искать конкретный элемент.
Описание принципа реализации
В браузер инжектировался JavaScript-скрипт, который:
- Создавал MutationObserver, подключённый к document.body с отслеживанием дочерних элементов, атрибутов и всего поддерева.
- При каждом срабатывании callback записывал текущийDate.now() в глобальную переменную window._lastDomMutationTimestamp
- Экспортировал функцию window.getLastMutationTimestamp(), которую C#-код мог вызвать через ExecuteJavaScript.
Таким образом, из тестового кода в любой момент можно было спросить: «когда последний раз менялся DOM?» — и получить timestamp.
const observer = new MutationObserver((mutations) => { window._lastDomMutationTimestamp = Date.now(); }); observer.observe(document.body, { childList: true, // отслеживать добавление/удаление дочерних узлов subtree: true, // во всём поддереве, а не только прямых потомках attributes: true // отслеживать изменения атрибутов }); // Функция для внешнего опроса window.getLastMutationTimestamp = () => window._lastDomMutationTimestamp;
DOM Idle определялся как ситуация, когда с момента последней мутации прошло достаточно времени — больше заданного порога (например, 500 мс). Если DOM не менялся дольше этого интервала, значит, интерфейс, вероятно, пришёл в устойчивое состояние.
Общая схема: как два сигнала сводились в один ответ С двумя источниками логика проверки стабильности в первой версии была простой:
Страница стабильна когда activeRequests == 0 AND domIdleTime >= threshold
На уровне C# это оборачивалось в цикл с поллингом:
while (stopwatch.Elapsed < timeout) { var activeRequests = GetActiveRequestsFromCdp(); var domIdleTime = ExecuteJs("return Date.now() - window._lastDomMutationTimestamp"); if (activeRequests == 0 && domIdleTime >= thresholdMs) { // Страница стабильна return; } Thread.Sleep(pollingIntervalMs); }
Каждые N миллисекунд тестовый код задавал два вопроса:
- Есть ли незавершённые сетевые запросы? (Через счётчик CDP);
- Давно ли последний раз менялся DOM? (Через вызов JS-функции).
Если оба условия выполнялись одновременно — сеть молчит и DOM не меняется уже достаточно долго — считалось, что страница достигла стабильного состояния. Можно продолжать тест. Если хотя бы одно из условий не выполнялось — цикл продолжал ждать. Визуально поведение можно представить примерно так:
Время → Network: ████░░░░████░░░░░░░░░░░░░░░░░░░░░ DOM: ░░░░████░░░░████████░░░░░░░░░░░░░ ↑ Оба сигнала = idle → страница стабильна
Где всё начало ломаться DOM, который никогда не молчит Первое, с чем столкнулась первая версия — DOM Idle не наступал. Вообще. Порог был выставлен в 500 мс. Казалось бы, полсекунды тишины — не слишком жёсткое требование. Но на практике MutationObserver фиксировал изменения почти непрерывно. Страница выглядела полностью загруженной, пользователь давно мог с ней работать, а callback продолжал срабатывать — десятки раз в секунду. Причина была в особенностях фреймворка. Приложение было построено на ангуляроподобном фреймворке, который агрессивно обновлял DOM-структуру даже тогда, когда фактически ничего видимого на странице не менялось. Проблема распадалась на две части. Во-первых, подписка childList: true, subtree: true, attributes: true ловила вообще всё. Каждое обновление любого атрибута у любого элемента во всём поддереве document.body — это мутация. Каждый раз, когда фреймворк пересчитывал биндинг и трогал, скажем, style или data-*-атрибут — это мутация. Даже если значение не изменилось. Во-вторых, MutationObserver в моей конфигурации не сообщает, что именно изменилось в атрибуте. Он сообщает только факт: «у этого элемента изменился атрибут class». Было ли значение до изменения таким же, как после, — по умолчанию неизвестно. А фреймворк часто переписывал атрибут тем же значением. С точки зрения MutationObserver это полноценная мутация. С точки зрения реального интерфейса — ничего не произошло. На странице из 2000+ DOM-узлов, где фреймворк постоянно пересчитывает биндинги, этот callback срабатывал сотни раз в секунду. DOM Idle не наступал никогда. В итоге observer видел бурный поток событий, хотя страница визуально стояла на месте. DOM Idle не наступал, и wait зависал до таймаута. Донастройка MutationObserver: фильтрация по типу мутаций Первый шаг — перестать реагировать на всё подряд и отбирать только те мутации, которые действительно отражают изменение интерфейса. Из всего потока событий значимыми были только два типа:
- childList — добавление или удаление дочерних узлов. Это реальное изменение структуры дерева: появился новый компонент, удалился блок, обновился список.
- attributes, но только для атрибута class.
Почему именно class
Почему именно class, а не style? В нашемфреймворке все значимые изменения состояния компонента сопровождались изменением CSS-класса. Переключение видимости, смена активного таба, появление ошибки валидации, состояние загрузки — всё это выражалось через добавление или удаление классов. Атрибут style, напротив, менялся при любом пересчёте layout'а, анимациях, ресайзах, то есть постоянно, без прямой связи с загрузкой данных. Фильтрация по class давала хороший баланс: все значимые переходы состояния ловились, а основной шум — отсекался.
function handleMutations(mutations) { const now = Date.now(); const dominated = mutations.filter(m => { // 1. Добавление / удаление узлов -- всегда значимо if (m.type === 'childList') return true; // 2. Из атрибутов берём ТОЛЬКО class. // style -- шум от layout/анимаций // data-* -- внутренняя кухня фреймворка // aria-* -- обновления accessibility-дерева // всё прочее -- не коррелирует с загрузкой данных if (m.type === 'attributes' && m.attributeName === 'class') return true; return false; // остальное -- игнорируем }); if (dominated.length > 0) { lastMutationTime = now; } }
Донастройка MutationObserver: oldValue Как ни странно, у MutationObserver есть опция attributeOldValue: true. Если её включить, каждая мутация атрибута будет содержать поле oldValue — значение атрибута до изменения. Это позволяло добавить ещё один уровень фильтрации: сравнить старое и новое значение, и если они совпадают — проигнорировать мутацию. Это позволило нам добавить функцию проверки реального изменения на фронте:
function isClassReallyChanged(mutation) { // mutation.oldValue доступен благодаря attributeOldValue: true const before = mutation.oldValue || ''; const after = mutation.target.getAttribute('class') || ''; // Фреймворк перезаписал class тем же значением -- не считаем мутацией return before !== after; }
Тут стоит сказать: мне повезло, что MutationObserver предоставляетoldValueиз коробки. Если бы этого механизма не было, пришлось бы строить собственный реестр предыдущих значений для каждого наблюдаемого элемента — а при подписке на весь document.body с subtree: true это было бы нечто невообразимо тяжёлое.
После этих двух изменений — фильтрация по типу + сравнение oldValue — поток мутаций сократился на порядки. DOM Idle начал наступать. CDP, который всегда опаздывает Со второй половиной — Network Idle через CDP — проблемы начались сразу. Уже на первых запусках было видно: проверка сетевой активности регулярно выдаёт результат, которому нельзя доверять. Проявлялось это двояко. Затянутый idle. В одних случаях CDP-событие loadingFinished приходило заметно позже реального завершения запроса. Счётчик activeRequests оставался ненулевым, хотя браузер уже получил ответ и начал его обрабатывать. Wait продолжал ждать — не потому что страница не готова, а потому что CDP не успел сообщить о завершении. Ложный idle. В других — и это было хуже — запрос уже ушёл из браузера, на экране крутился Loader, но CDP-событие requestWillBeSent ещё не пришло. Счётчик был равен нулю. DOM к этому моменту мог быть тихим (ответа ещё нет — нечему менять интерфейс). Оба условия — activeRequests == 0 и domIdleTime >= threshold — формально выполнялись. Механизм считал страницу стабильной. На деле — страница ждала данные. Почему CDP опаздывает Поначалу я списывал задержки на перегруженную инфраструктуру — контейнеры, сеть, общая просадка. Но те же эффекты воспроизводились и локально. Продолжительное общение с LLM-кой на данную тему привело меня к выводу, что CDP в том виде, в котором он мне доступен, конструктивно не предназначен для отслеживания сетевой активности в реальном времени. Это инструмент наблюдения постфактум, с гарантией полноты, но без гарантии латентности. Костыль: запрещённые элементы Первым ходом была донастройка на уровне DOM-проверки. Идея простая: если на странице присутствует элемент, который однозначно сигнализирует о незавершённой загрузке, — считать страницу нестабильной, что бы ни говорили остальные сигналы. Самый банальный пример — Loader. Если он есть на экране, страница точно не готова, даже если CDP молчит. Технически это реализовывалось как дополнительная проверка, встроенная прямо в функцию опроса DOM-таймстампа. При каждом вызове — прежде чем вернуть значение — скрипт пробегал по списку «запрещённых» селекторов и искал совпадения в текущем DOM:
// Список задаётся при инициализации const blockers = ['.spinner', '.loading-overlay']; function hasBlockingElements() { return blockers.some(sel => document.querySelector(sel) !== null); } function getIdleTimestamp() { // Нашли блокирующий элемент -- сбрасываем idle if (hasBlockingElements()) { lastMutationTime = Date.now(); // idle обнуляется } return lastMutationTime; }
Логика: если при опросе на странице обнаружен Loader (или любой другой элемент из списка) — таймстамп принудительно сбрасывается до текущего момента. Время простоя DOM становится равным нулю, условие domIdleTime >= threshold не выполняется, wait продолжает ждать — независимо от того, что говорит CDP.
Это помогало, но лишь частично. Loader покрывал основные сценарии загрузки, но далеко не все запросы сопровождались видимым индикатором. Ситуации, когда CDP опаздывал с событиями, а лодера при этом не было, оставались. Тесты продолжали ловить ложную стабильность.
Поиск замены CDP Стало понятно, что заплатками проблему не решить. Нужен принципиально другой источник сигналов о сетевой активности — такой, который фиксирует запрос в тот же момент, когда он возникает, без промежуточного канала с непредсказуемой задержкой. Я рассмотрел несколько вариантов. Performance API (PerformanceObserver). Браузер записывает все сетевые запросы в Performance Timeline. Через PerformanceObserver с entryTypes: ['resource'] можно получать уведомления о завершённых запросах. Но именно о завершённых — это ключевое ограничение. PerformanceObserver сообщает о запросе только после того, как он полностью загружен. Момент начала запроса через него не отслеживается. А для нашей задачи критически важен именно он: нужно знать, что запрос ушёл, даже если ответ ещё не пришёл. Иначе получаем ту же проблему, что и с CDP, — окно ложной стабильности. TCP-сниффер на уровне контейнера. Теоретически можно было перехватывать трафик ниже — на уровне сетевого стека. Развернуть внутри Selenoid-контейнера что-то вроде tcpdump или прокси, парсить HTTP-потоки, считать активные соединения. Но это совершенно другой уровень сложности: нестандартная конфигурация контейнеров, зависимость от сетевой топологии, отдельный парсинг протокола, проблемы с HTTPS. К тому же часть браузеров крутилась не в контейнерах, а на десктопных машинах — универсальность терялась полностью. Проксирование через Selenium. Ещё один вариант — пропускать весь трафик браузера через HTTP-прокси, который умеет отдавать HAR и считать активные соединения. Но это добавляло ещё один компонент в и без того перегруженную инфраструктуру, вносило дополнительную латентность в каждый запрос и требовало перенастройки запуска браузеров. В итоге все «внешние» варианты — те, что работают снаружи браузера или в отдельном потоке — не подходили. Либо не давали нужного сигнала (момент старта запроса), либо были слишком тяжелы для реализации. JS-перехват вместо CDP Но был вариант, который лежал на поверхности. Раз уж я и так инжектировал JavaScript для MutationObserver —, то можно было пойти тем же путём и для сети. Любой HTTP-запрос из веб-приложения в конечном счёте проходит через один из двух браузерных API:fetch илиXMLHttpRequest. Если подменить оба — получается полный перехват, который срабатывает синхронно с вызовом, без каких-либо внешних каналов и задержек доставки. Принцип одинаков для обоих API — сохранить оригинал, поставить обёртку.
Примерная реализация fetch
const nativeFetch = window.fetch; const pending = new Set(); window.fetch = function(input, init = {}) { const url = typeof input === 'string' ? input : input.url; const method = init.method || 'GET'; // Генерируем уникальный ключ запроса const id = method + ':' + url + '-' + Date.now(); // Запрос зафиксирован мгновенно -- в момент вызова, а не когда-нибудь потом pending.add(id); // Вызываем оригинальный fetch -- приложение не замечает подмены return nativeFetch(input, init) .then(response => { pending.delete(id); // запрос завершился штатно return response; // возвращаем оригинальный ответ }) .catch(error => { pending.delete(id); // запрос упал -- тоже убираем из реестра throw error; // пробрасываем ошибку как обычно }); };
Примерная реализация для XMLHttpRequest
Для XMLHttpRequest подход тот же, но реализация чуть сложнее — нужно подменить open (чтобы запомнить URL и метод) и подписаться на события жизненного цикла:
const nativeOpen = XMLHttpRequest.prototype.open; XMLHttpRequest.prototype.open = function(method, url) { this._trackingId = method + ':' + url + '-' + Date.now(); this.addEventListener('loadstart', () => { pending.add(this._trackingId); // запрос стартовал }); this.addEventListener('loadend', () => { pending.delete(this._trackingId); // завершён (успешно или нет) }); this.addEventListener('error', () => { pending.delete(this._trackingId); // ошибка сети }); this.addEventListener('timeout', () => { pending.delete(this._trackingId); // таймаут }); return nativeOpen.apply(this, arguments); };
С точки зрения приложения ничего не изменилось: fetch и XHR работают как обычно, возвращают те же ответы, выбрасывают те же ошибки. Но теперь у тестовой обвязки есть счётчик, который обновляется синхронно с каждым вызовом — без промежуточного канала, без задержки, без окна ложной стабильности. После этой замены вся логика Network Idle переехала из внешнего CDP-канала внутрь самого браузера. Отслеживание сетевой активности стало таким же, как отслеживание DOM — JavaScript-скрипт, живущий в контексте страницы и реагирующий мгновенно. Фликающий тултип, или элемент, который не даёт странице стабилизироваться В принципе, на этом этапе решение было уже готово, и я начал его тестировать. Мне очень повезло, что я почти сразу поймал очень нетривиальный кейс. Он не сильно влияет на основную реализацию, но занял много моего времени и желает быть увековеченным как и все остальные проблемы, найденные в процессе реализации.
Решение прятал под спойлер
После того как фильтрация мутаций и сравнение oldValue заработали, DOM Idle наконец начал наступать. Но при отладке мне повезло встретить особый вид боли.
Автотест открывает страницу. Курсор после предыдущего действия случайно оказывается на элементе. Элемент реагирует: появляется тултип. Тултип — это DOM-узел, он добавляется в дерево. MutationObserver фиксирует мутацию. Через долю секунды тултип исчезает (курсор не двигается — и фреймворк решает его скрыть, либо открывшийся тултип закрывает место, триггерящее появление тултипа, тут я могу только предполагать). Узел удаляется из DOM — ещё одна мутация. Потом фреймворк снова решает показать тултип. Узел добавляется. Удаляется. Добавляется. Удаляется.
Цикл бесконечный. DOM генерирует события без остановки. С точки зрения системы — страница никогда не стабилизируется. А если не стабилизируется — тест не пойдёт дальше.

Первое желание — найти разработчиков и аккуратно поломать им руки. Но это, к сожалению, не решает техническую проблему. Почему нельзя просто добавить в игнор
Второй идеей был быстрый фикс: завести список игнорируемых элементов и добавить туда тултипы. Мутации от элементов из списка — пропускать.
Но такой подход чинил бы один конкретный тест, а ослаблял бы механизм в целом. Если начать вручную игнорировать конкретные элементы, со временем этот список неизбежно разрастётся. Каждый новый «шумный» компонент — новая запись. А главное — если элемент попал в игнор, мы перестаём видеть любые его мутации, в том числе значимые. Тултип может фликать, но может и появиться один раз как результат загрузки — и это два совершенно разных сценария.
Нужно было решение, которое отличает одно от другого. Не «что это за элемент», а «как он себя ведёт»
Ключевое наблюдение было простым: фликающий элемент — это не определённый CSS-класс или тег. Это паттерн поведения. Элемент добавляется в DOM и почти мгновенно удаляется. Потом снова добавляется. И снова удаляется. Цикл жизни — десятки миллисекунд.
Нормальный элемент появляется и остаётся. Или исчезает и не возвращается. Фликающий — мелькает.
Значит, нужно было не перечислять конкретные элементы, а определять характер мутации: если элемент добавлен и удалён за время меньше порога — это flicker, и его мутацию можно проигнорировать. Всё, что живёт дольше порога, — считается настоящим изменением. Сигнатура. Чтобы понять, что тот же самый элемент добавился и удалился, нужен способ его идентифицировать. Прямая ссылка на DOM-узел не годится: после удаления из дерева и повторного добавления это технически другой объект. Поэтому используется сигнатура — строка, составленная из тега элемента, его классов и аналогичных данных родителя. Не идеальный идентификатор, но достаточный для детекции паттерна:
function getSignature(node) { if (!node || !node.parentElement) return ''; const tag = node.tagName || ''; const cls = node.className || ''; const parentTag = node.parentElement.tagName || ''; const parentCls = node.parentElement.className || ''; return parentTag + '.' + parentCls + ' > ' + tag + '.' + cls; }
Сигнатура вроде "DIV.tooltip-container > SPAN.tooltip-text" одинакова для каждого появления одного и того же тултипа, даже если конкретный DOM-узел каждый раз новый.
Реестр. Для каждой сигнатуры хранится запись с двумя таймстампами: когда элемент с такой сигнатурой был добавлен и когда удалён.
const history = new Map(); function recordMutation(type, node, now) { const key = getSignature(node); if (!key) return; const entry = history.get(key) || { added: null, removed: null }; entry[type] = now; history.set(key, entry); }
При каждой childList-мутации, перед принятием решения о её значимости, обновляются записи для всех затронутых узлов:
for (const node of mutation.addedNodes) { if (node.nodeType === Node.ELEMENT_NODE) recordMutation('added', node, now); } for (const node of mutation.removedNodes) { if (node.nodeType === Node.ELEMENT_NODE) recordMutation('removed', node, now); }
Детекция. После обновления реестра проверяем: все ли узлы в этой мутации — фликеры? Фликер — это элемент, для которого в реестре есть и added, и removed, причём между ними прошло меньше порога (например, 200 мс):
function isFlicker(node, now) { const entry = history.get(getSignature(node)); if (!entry) return false; return ( entry.added && entry.removed && entry.removed > entry.added && (entry.removed - entry.added) < flickerThresholdMs ); }
И финальная логика в обработчике мутаций:
const allNodes = [...mutation.addedNodes, ...mutation.removedNodes] .filter(n => n.nodeType === Node.ELEMENT_NODE); const allAreFlickers = allNodes.every(n => isFlicker(n, now)); if (allAreFlickers && allNodes.length > 0) { return false; // мутация целиком -- flicker, игнорируем } return true; // есть хотя бы один «настоящий» узел -- мутация значима
Обратите внимание на условие: мутация игнорируется только если все затронутые узлы — фликеры. Если в одной мутации одновременно добавился тултип (flicker) и реальный блок данных (не flicker) — мутация считается значимой. Механизм не отбрасывает лишнего.
Почему порог, а не счётчик
Может возникнуть вопрос: почему критерий — время между added и removed, а не количество повторений? Казалось бы, можно считать: если элемент добавился/удалился 5 раз за секунду — значит, фликер.
Проблема с подсчётом — в том, что нужно ждать накопления статистики. Первые два-три цикла будут пропущены, прежде чем счётчик наберёт порог. В это время DOM Idle будет сбрасываться.
Временной порог работает быстрее: уже на первом цикле added → removed за 50 мс понятно, что это не настоящее изменение интерфейса. Нормальный компонент, который появился в результате загрузки данных, не исчезнет через 50 мс. Результат
Бесконечный тултип перестал мешать тестам. DOM стабилизировался даже при таком шуме, потому что фликающие мутации больше не сбрасывали таймстамп. А реальные изменения — появление нового блока, обновление списка, исчезновение формы — продолжали учитываться, потому что их жизненный цикл превышал порог.
Итоговая архитектура
 После всех доработок — фильтрации DOM, замены CDP, добавления forbidden-элементов и детекции фликеров — сложилась архитектура, которую уже можно описать целиком. Она состоит из двух JS-скриптов, двух C#-обёрток и одного координатора, который сводит всё к единственному вызову. Координатор: WaitForStablePage Оба сигнала сводятся в методе WaitForStablePage, который вызывается перед значимыми действиями теста. Алгоритм:
- Подключение DOM-наблюдателя. Если скрипт DomStabilityObserver ещё не инжектирован в текущую страницу — инжектируется и инициализируется с конфигурацией (forbidden-селекторы, порог фликера, режим логирования).
- Ожидание первого запроса. Если в момент вызова нет активных запросов и последний завершённый был давно — система ждёт появления нового requestId. Это защита от ситуации, когда wait вызван раньше, чем страница успела начать загрузку данных.
- Поллинг. С интервалом 100 мс опрашиваются оба сигнала:
- activeRequestsCount == 0 — сеть пуста;
- Date.now() - lastMutationTimestamp >= idleThreshold — DOM молчит дольше порога.
- Если оба условия выполнены одновременно — страница считается стабильной.
- Таймаут. Если за 120 секунд стабильность не достигнута — логируются незавершённые запросы, и тест получает ошибку с диагностикой.
Условие стабильности в одну строку:
| activeRequests == 0 && (now - lastMutationTimestamp) >= idleThresholdMs |
Внедрение Проблема масштаба Механизм готов. Он работает в изолированных экспериментах. Но между «работает на одном тесте» и «работает на шестистах» есть небольшая разница. Чтобы внедрить WaitForStablePage правильно, нужно было бы пройтись по всему решению и заменить существующие ожидания на новый вызов. На тот момент это означало: 600 тест-методов, около 500 файлов Page Object-ов и Page Element'ов. Десятки точек, в которых тесты так или иначе ждут чего-то от страницы. Но даже если бы я начал заменять ожидания точечно — скажем, на десяти-двадцати методах — это не дало бы мне ответа. Проблема флаков заключалась в том, что падали разные тесты. Не было одного «больного» сценария, который можно было бы починить и убедиться в результате. Сегодня падает тест на карточку объекта, завтра — на таблицу, послезавтра — на совершенно другой экран. Единственный способ понять, работает ли механизм, — статистика больших чисел. Нужен полный прогон всего набора, несколько раз, в типичных условиях деградированной среды. Только тогда можно сравнить процент флаков «до» и «после». Точечные замены этого не давали. Десять тестов с новым wait'ом могли пройти идеально — просто потому, что в этот момент среда вела себя нормально. А потом те же десять могли упасть — потому что нагрузка выросла. Нужен был весь набор, целиком, чтобы статистика начала работать. Риск выглядел так: потратить месяц на рефакторинг и осознать, что сама концепция не жизнеспособна.
Короче говоря, цена ошибки была слишком высокой, а пруфов у меня на тот момент не было.
Костыль в помощь В проекте уже существовало одно место, которое оказалось спасительным. Задолго до всей этой истории в фреймворке появился метод, который все называли Wait for Loader. Логика его была предельно примитивной:
- Найти на странице элемент-лоадер.
- Если нашёл — ждать, пока он исчезнет.
- Если не нашёл — всё равно ждать, пока исчезнет.
Третий пункт звучит абсурдно, но именно в нём и была вся суть. Метод ожидал исчезновения лоадера безусловно. Если лоадера на странице уже не было — условие выполнялось мгновенно, и метод проходил «без задержки». Если лоадер был — метод честно ждал, пока тот пропадёт из DOM. Это был классический костыль. С точки зрения пользы — ограниченный: он помогал только в сценариях, где загрузка сопровождалась видимым лоадером. Но у него было одно важное свойство: он был абсолютно безопасным. Он никогда ничего не ломал. В лучшем случае — дожидался окончания загрузки. В худшем — проходил мгновенно и не мешал. Именно из-за этой безопасности его воткнули буквально везде: после навигаций, после кликов, после ввода данных. Более четырёхсот вызовов по всему фреймворку. Подмена Этот костыль и стал точкой входа для нового механизма. Всё, что я сделал, — заменил реализацию Wait for Loader на WaitForStablePage. Сигнатура метода осталась прежней. Все 400+ мест вызова — не тронуты. Ни один POM, ни один тест не потребовал изменений. В результате вся система тестов начала использовать новый механизм стабилизации без глобального рефакторинга. Четыреста точек, где раньше стоял слепой wait на лоадер, теперь дожидались реальной стабильности страницы — по Network Idle и DOM Idle. И главное — я сразу получил то, что было нужно: прогон на большом количестве тестов. Статистика, которую можно сравнивать. Это тот случай, когда архитектурный костыль, заложенный когда-то без особых амбиций, оказался именно тем швом, через который удалось протащить совершенно новую логику — и сразу проверить её на боевом объёме. Результаты Стабильность Тесты перестали сыпаться от мелких (и не очень) лагов среды. Даже если сервер подвисал на 10–15 секунд — это больше не приводило к падению. Механизм просто ждал: поллинг продолжал опрашивать состояние DOM и сети, и как только страница приходила в стабильное состояние — тест продолжался. Таймаут ожидания я установил в 120 секунд. По ощущениям, его можно было бы приравнять к таймауту самих HTTP-запросов (180 секунд в нашей конфигурации), но на практике 120 секунд хватало с запасом: если страница не стабилизировалась за это время — проблема была уже не в задержках, а в чём-то более серьёзном. Доля флаков - снизилась до минимума, обусловенного кривостью конкретного теста. Дневные прогоны стали давать результаты, сопоставимые с ночными. Это само по себе подтверждало, что механизм действительно закрывал именно тот класс проблем, который был связан с нестабильностью среды. Унификация ожиданий Но, на мой взгляд, самое ценное изменение было не в цифрах, а в архитектуре. Теперь в проекте появился единый общий wait. Не набор из десятков частных ожиданий под каждый экран, а один инфраструктурный метод, который отвечает на один вопрос: стабильна ли страница. Это упрощало саму логику написания POM-ов. Вместо того чтобы думать «какой элемент мне нужно дождаться и в каком состоянии», автор теста мог опираться на простое правило:
- Если действие обновляет интерфейс (клик, навигация, ввод данных) → ставим WaitForStablePage().
- Если действие не меняет DOM (чтение атрибута, проверка текста) → работаем напрямую, без дополнительных ожиданий.
Всё. Без конфигурации правил для каждого компонента, без учёта типов объектов, без отслеживания того, какие поля должны быть заполнены, а какие — нет. Фреймворк становится проще, прозрачнее и предсказуемее. А порог входа для новых участников команды — ниже: не нужно было разбираться в зоопарке ожиданий, достаточно было понять один принцип.
И в этом, на мой взгляд, главное достижение. Мы не просто закрыли проблему флаков — мы заложили основу для того, чтобы дальше развивать систему тестов более чисто и предсказуемо.
TL;DR
- Инфраструктура. Из-за урезания финансирования серверных мощностей автотесты, стенды разработки и ручное тестирование начали конкурировать за одни и те же ресурсы. В рабочие часы это приводило к сильной деградации производительности среды.
- Рандомные задержки сети. В условиях перегруженности время ответа HTTP-запросов, которые ранее выполнялись за сотни миллисекунд, могло вырастать до 10–40 секунд. Запросы завершались хаотично, без предсказуемой последовательности.
- Куча флаков. Асинхронный фронтенд оказывался в промежуточных состояниях: контейнеры элементов уже присутствовали в DOM, но данные в них ещё не догрузились. Стандартные ожидания Selenium срабатывали раньше времени, что вызывало до 20% падений днём (ночью, при низкой нагрузке, те же тесты проходили стабильно).
- Единый критерий готовности. Вместо зоопарка точечных wait внедрён один инфраструктурный метод WaitForStablePage(). Страница считается готовой только при одновременном выполнении двух условий: Network Idle (все данные получены) и DOM Idle (интерфейс перестал перестраиваться).
- DOM Idle через MutationObserver. Реализован инъекцией JS-скрипта, который отслеживает изменения в document.body. Чтобы отсечь технический шум фреймворка, наблюдатель фильтрует мутации: учитывает только childList и изменения атрибута class, сравнивает oldValue и newValue, а также игнорирует «фликающие» элементы (тултипы, мелькающие менее заданного порога).
- Network Idle через JS-перехват. От использования CDP отказались из-за непредсказуемой задержки доставки событий. Счётчик активных запросов теперь работает внутри контекста страницы через прозрачную подмену (monkey-patching) нативных window.fetch и XMLHttpRequest. Это даёт мгновенную и точную фиксацию старта/завершения запросов без внешних каналов связи.
Итог внедрения: Доля флаков упала до уровня ночных прогонов. Появился единый метод ожидания готовности страницы к тесту — упрощение написания будущих тестов. P.S. Когда JavaScript становится привычкой: трекинг временных уведомлений Механизм стабилизации был готов и работал. Но в процессе его отладки я вспомнил про задачу, которая мучила меня давно и никак не решалась в рамках стандартного Selenium. Спрячу её также под спойлер.
Когда начинаешь использовать JS в автотестах — остановиться уже невозможно.
 Проблема: поймать то, что уже исчезло
Представьте типичный сценарий. Тест нажимает кнопку «Сохранить». После этого нужно проверить два факта: что форма закрылась и что появился toast с текстом «Сохранено успешно».
Казалось бы — ничего сложного. Но на практике возникала дилемма.
После клика тесту нужно было проверить основное последствие действия: закрылась ли форма, обновилась ли таблица, появились ли данные. Это основной сценарий — его нельзя пропустить. Но пока тест занимался этими проверками, toast успевал всплыть и исчезнуть. К моменту, когда тест добирался до поиска уведомления, его уже не было в DOM.
А иногда toast вообще не нужен. Клик — проверка результата — всё. Никаких уведомлений ловить не надо.
Мы пытались решить это «в лоб»: передавали в метод клика специальный флаг — мол, после этого клика нужно ещё и забрать toast. Если флаг есть — метод сразу после клика хватал текст уведомления, прежде чем переходить к дальнейшим проверкам.
Но и это работало нестабильно. Иногда toast появлялся и исчезал именно в тот момент, когда Selenium переключался между командами. Иногда элемент формально ещё существовал, но уже находился в процессе анимации удаления. Иногда поиск просто не успевал. (За всеми этими витиеватыми формулировками скрывается просто - работало нестабильно) Почему нельзя просто запустить отдельный поток
Логичная идея: если основной поток теста занят проверками — давайте заранее запустим параллельный поток, который будет через Selenium следить за появлением toast'а. Основной поток кликает и проверяет форму, а фоновый — караулит уведомление.
Мы попробовали. Оказалось — так не работает.
Selenium устроен так, что все команды к одной браузерной сессии проходят через один канал. Даже если вы из двух потоков одновременно отправите FindElement, они не выполнятся параллельно — они встанут в очередь.
То есть проблема была не в нашем коде, а в самой архитектуре WebDriver: один драйвер — один поток команд. Параллельно слушать DOM через Selenium невозможно в принципе.
Вывод напрашивался сам собой: не нужно ловить элемент через Selenium в момент его появления. Нужно заранее поставить ловушку внутри самого браузера — и потом спокойно проверить, что она поймала. Идея: пусть браузер ловит, а тест читает
Решение строилось на том же принципе, что и стабилизация DOM: переложить работу, требующую параллельного наблюдения, на сам браузер.
MutationObserver внутри страницы работает асинхронно (по отношению к тесту) и ловит каждое добавление в дерево DOM — независимо от того, чем в этот момент занят Selenium. Мы просто даём браузеру задание: «записывай всё, что появляется по таким-то селекторам». А тест в любой удобный момент забирает результат.
Схема работы:
- Инициализация. При открытии страницы C#-обёртка подключает JS-скрипт. Скрипт вешает MutationObserver на document.body и начинает отслеживать добавление новых узлов, соответствующих заданным CSS-селекторам (toast-компоненты, alert-диалоги).
- Установка метки. Перед действием, после которого ожидается уведомление, тест вызывает SetTimestamp(). Это фиксирует текущий момент времени в JS-контексте браузера.
- Действие и проверки. Тест кликает кнопку, проверяет основные последствия, ждёт стабилизации — делает всё, что нужно по сценарию. В это время скрипт в фоне уже поймал toast и записал его данные в массив.
- Чтение ловушки. Когда тест готов — он запрашивает уведомления, появившиеся после метки. Неважно, исчез ли toast с экрана к этому моменту — данные уже сохранены.
Что делает JS-скрипт
Инициализация:
- Создать MutationObserver на document.body с параметрами childList: true, subtree: true. Больше ничего — ни attributes, ни characterData. Нас интересует только появление новых узлов.
- Подготовить два массива для хранения пойманных уведомлений: один для toast'ов, один для alert'ов.
- Подготовить WeakSet для уже обработанных DOM-элементов.
Отлов уведомлений (колбэк Observer-а):
- Из каждой мутации типа childList перебрать addedNodes.
- Для каждого добавленного элемента проверить: совпадает ли он (или его потомки) с одним из заданных CSS-селекторов (например, .toast-notification, .alert-dialog).
- Если совпал — извлечь из него текст сообщения и тип (success/danger/info) и сохранить в массив вместе с Date.now().
Защита от дубликатов (два уровня):
- По DOM-узлу: через WeakSet. Если элемент уже обработан — пропустить. Это защищает от ситуации, когда один и тот же узел попадает в несколько мутаций.
- По содержимому: если в массиве уже есть запись с таким же текстом и типом, и разница по времени меньше порога (например, 1000 мс) — считать дубликатом и пропустить. Это защищает от ситуации, когда фреймворк пересоздаёт контейнер уведомления: удаляет и вставляет заново. DOM-узел другой, но сообщение то же самое.
API (методы, доступные через window):
- setTimestamp(ts) — запомнить временную метку.
- getToastsSince() — вернуть из массива toast'ов только записи с timestamp >= сохранённая метка.
- getAlertsSince() — аналогично для alert'ов.
- clearData() — очистить массивы и сбросить метку.
По сути — это «чёрный ящик с магнитофоном»: записывает всё, что появляется, а тест потом прослушивает запись за нужный промежуток. Использование со стороны тестов
// Ставим ловушку tracker.SetTimestamp(); // Действие settingsPage.ClickSaveButton(); // Основные проверки -- toast нас пока не волнует settingsPage.AssertFormClosed(); settingsPage.AssertDataUpdatedInTable(); // Теперь спокойно читаем, что поймала ловушка var toasts = tracker.GetToastsSince(); Assert.That(toasts, Has.Some.Property("Message").Contains("успешно сохранено"));
Между SetTimestamp() и GetToastsSince() может пройти сколько угодно времени. Toast мог появиться и исчезнуть — не важно. Данные уже в массиве.
И — никаких флагов в методе клика. Никаких «если нужен toast — передай ключ». Ловушка работает всегда, а тест решает сам, когда и нужно ли ему читать результат.
-Источник
|