Знакомый сюжет в любом проекте с UI‑автотестами. Один и тот же тест на CI ведёт себя по‑разному: вчера прошёл, сегодня упал, завтра снова прошёл. Локально работает всегда. В логах непонятное «Element not interactable», в скриншоте на момент падения элемент вроде на месте.
Команда списывает на «flaky test», добавляет ретрай через JUnit Extension, через месяц добавляет ещё один уровень ретраев на CI. Пайплайн билда растёт с трёх минут до двадцати, потому что ретраи теперь срабатывают на половине прогонов. Доверие к автотестам падает, через полгода менеджмент возвращает ручное тестирование на критичные релизы.
В девяти случаях из десяти источник проблемы не в коде приложения и не в инфраструктуре. Тест просто ждёт не то, не там и не так. UI на современном фронтенде — это асинхронная штука с десятками одновременных операций: запросы к API, ленивая загрузка, анимации, рендеринг через React или Vue, который происходит когда движок решит.
Selenium же выполняется синхронно и не имеет встроенного понимания, когда страница «готова». Эту разницу закрывают ожидания, и именно в них накапливаются ошибки, которые потом превращаются в flaky‑тесты.
Разберём пять самых частых и болезненных ошибок, которые встречаются в работе с ожиданиями. Все примеры на Java + Selenium 4, но логика одинаковая для любой связки.
Thread.sleep вместо явных ожиданий
Самая распространённая ошибка начинающих. Логика на первый взгляд понятная: модалка появляется не сразу после клика, значит, подождём три секунды, и всё будет хорошо.
driver.findElement(By.id("buy-now")).click(); Thread.sleep(3000); WebElement modal = driver.findElement(By.cssSelector(".confirmation-modal")); modal.findElement(By.cssSelector(".btn-confirm")).click();
Проблем тут сразу несколько.
Первая — три секунды это либо много, либо мало, никогда не «как раз». В быстром окружении модалка появится через 200 миллисекунд, и тест зря простаивает 2.8 секунды. На CI‑агенте под нагрузкой та же модалка может появиться через 4–5 секунд, и тест упадёт, потому что элемента ещё нет.
Вторая проблема —
Thread.sleepждёт ровно указанное время, независимо от того, что происходит на странице. Если модалка уже видна через полсекунды, тест всё равно простоит три секунды.Третья проблема —
Thread.sleepбросаетInterruptedException, и его приходится либо обрабатывать try‑catch, либо пробрасывать черезthrows. И то, и другое засоряет код тестов.
Правильное решение — WebDriverWait с явным условием:
WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(10)); driver.findElement(By.id("buy-now")).click(); WebElement modal = wait.until( ExpectedConditions.visibilityOfElementLocated(By.cssSelector(".confirmation-modal")) ); wait.until(ExpectedConditions.elementToBeClickable( modal.findElement(By.cssSelector(".btn-confirm")) )).click();
Тест ждёт ровно столько, сколько нужно. Если модалка появилась через 200 миллисекунд — переходим дальше через 200 миллисекунд. Если за 10 секунд не появилась — падаем с понятным TimeoutException и указанием, какого именно элемента не дождались. Это в разы быстрее по среднему времени прогона и в разы информативнее при разборе падений.
Единственное оправданное применение Thread.sleep — это намеренное ожидание, не связанное с UI. Например, проверить, что после какого‑то действия в течение трёх секунд ничего не произошло. Тогда Thread.sleep уместен, но это редкая ситуация.
Один WebDriverWait на весь проект с большим таймаутом
Антипод предыдущей ошибки. Команда узнаёт про WebDriverWait, кто‑то почитал статью про flaky‑тесты и предложил «давайте поставим таймаут побольше, чтобы тесты не падали». В базовом классе появляется константа DEFAULT_TIMEOUT = 60 и единственный WebDriverWait на 60 секунд, который используется для всех ожиданий в проекте.
Внешне всё стало лучше — flaky‑тесты исчезли.
Но когда в приложении ломается фича, и кнопка перестаёт работать, тест теперь падает не за две секунды, а за минуту. Сюита из 200 тестов, в которой половина падает из‑за регрессии, прогоняется не 20 минут, а полтора часа. Разработчики ждут результаты тестов вместо того, чтобы фиксить баг. Когда асинхронная операция тормозит и реально занимает 30 секунд вместо обычных 2, тест всё равно дождётся и не подсветит проблему производительности.
Правильный подход — разные таймауты для разных типов операций:
public class WaitConfig { public static final Duration QUICK = Duration.ofSeconds(2); public static final Duration NORMAL = Duration.ofSeconds(5); public static final Duration SLOW = Duration.ofSeconds(15); public static final Duration VERY_SLOW = Duration.ofSeconds(30); }
Дальше в коде явно выбирается нужный:
new WebDriverWait(driver, WaitConfig.QUICK) .until(ExpectedConditions.elementToBeClickable(buttonLocator)); new WebDriverWait(driver, WaitConfig.SLOW) .until(ExpectedConditions.urlContains("/order-confirmation")); new WebDriverWait(driver, WaitConfig.VERY_SLOW) .until(ExpectedConditions.textToBePresentInElementLocated( By.cssSelector("[data-test='export-status']"), "Completed" ));
Появление кнопки после клика — две секунды максимум, иначе что‑то не так. Редирект на страницу подтверждения — пять‑пятнадцать секунд, потому что может быть пара промежуточных проверок на бэкенде. Завершение экспорта отчёта в Excel — до полуминуты, это реально долгая операция.
Конкретные числа подбираются под проект, но принцип неизменный: «один таймаут на всё» — всегда плохо.
Проверка только presence вместо visibility и clickability
В Selenium есть несколько похожих по названию, но разных по смыслу ExpectedConditions. presenceOfElementLocated отвечает только на один вопрос: есть ли элемент в DOM‑дереве страницы. Видимость, кликабельность, перекрытие другими элементами — это всё не его дело.
Типичный код с этой ошибкой:
wait.until(ExpectedConditions.presenceOfElementLocated(By.id("submit"))); driver.findElement(By.id("submit")).click();
Кнопка submit есть в DOM. Но прямо сейчас она может быть скрыта через display: none, потому что форма ещё не валидирована. Может быть перекрыта модалкой с куки‑баннером, которую тест не успел закрыть. Может находиться за пределами видимой области viewport, и Selenium не сможет на неё кликнуть. Может быть в disabled состоянии, потому что бэкенд ещё не подтвердил доступность операции.
Selenium попытается кликнуть и получит одно из исключений: ElementNotInteractableException, ElementClickInterceptedException, или, что хуже, кликнет «через» элемент в координату, где сейчас совсем не submit, и тест продолжит выполнение с совершенно непредсказуемым результатом.
Правильное использование:
// Перед кликом — проверяем, что кликабельно wait.until(ExpectedConditions.elementToBeClickable(By.id("submit"))).click(); // Перед чтением текста — проверяем видимость String price = wait.until(ExpectedConditions.visibilityOfElementLocated( By.cssSelector("[data-test='total-price']") )).getText(); // Перед заполнением поля — кликабельность плюс очистка WebElement input = wait.until(ExpectedConditions.elementToBeClickable( By.cssSelector("[data-test='email-input']") )); input.clear(); input.sendKeys("user@example.com");
elementToBeClickableпод капотом проверяет и наличие в DOM, и видимость, и активность, и отсутствие перекрытия. Это то, что нужно перед взаимодействием с элементом в 95% случаев.presenceOfElementLocatedимеет смысл, только когда вам действительно достаточно знать, что элемент в DOM, и взаимодействие с ним не планируется. Например, проверка, что hidden input с CSRF‑токеном существует на форме. Или проверка структуры DOM в JavaScript‑тестах. В обычных UI‑тестах его использование почти всегда ошибка.
Игнорирование исчезновения промежуточных элементов
Сценарий типичный для современных SPA. Тест нажимает «Сохранить», на форме появляется спиннер, фронтенд отправляет запрос, через секунду‑две спиннер исчезает и появляется тост‑уведомление «Сохранено». Тест проверяет тост.
saveButton.click(); WebElement toast = wait.until(ExpectedConditions.visibilityOfElementLocated( By.cssSelector(".toast-success") )); assertThat(toast.getText()).isEqualTo("Сохранено");
Работает в половине случаев. Во второй половине происходит одно из двух.
Первый вариант — на странице уже был тост от предыдущего действия, который ещё не успел исчезнуть. Тест ловит его, считает успехом, проходит, но реально ничего не проверил. Это особенно болезненно в тестах с несколькими сохранениями подряд: первое прошло, второе сломалось, но тест зелёный, потому что зацепился за тост от первого.
Второй вариант — приложение под нагрузкой отвечает медленнее. Спиннер крутится не одну секунду, а четыре. Тест ждёт тост, но Selenium слишком быстро забирает контекст, пока спиннер ещё активен, и блокирует UI. Тост появляется через секунду после таймаута теста.
Правильная последовательность для асинхронных интерфейсов всегда трёхтактная:
// 1. Действие saveButton.click(); // 2. Дождаться появления индикатора загрузки WebDriverWait quickWait = new WebDriverWait(driver, Duration.ofSeconds(2)); quickWait.until(ExpectedConditions.visibilityOfElementLocated(By.cssSelector(".spinner"))); // 3. Дождаться его исчезновения WebDriverWait slowWait = new WebDriverWait(driver, Duration.ofSeconds(15)); slowWait.until(ExpectedConditions.invisibilityOfElementLocated(By.cssSelector(".spinner"))); // 4. Теперь проверять результат WebElement toast = slowWait.until(ExpectedConditions.visibilityOfElementLocated( By.cssSelector(".toast-success") )); assertThat(toast.getText()).isEqualTo("Сохранено");
Лишние две строчки кода, которые экономят часы разборок с flaky‑тестами. Принцип универсальный: для любой асинхронной операции в UI должен быть какой‑то индикатор её начала и окончания, и тест должен опираться именно на этот индикатор, а не угадывать состояние по конечному результату.
Если в приложении нет явных индикаторов загрузки (что само по себе UX‑проблема), приходится ждать косвенные признаки: исчезновение disabled атрибута с кнопки, появление новых данных в таблице, изменение URL. Главное — найти какой‑то наблюдаемый признак завершения операции и ждать его.
ExpectedConditions без понимания семантики
В Selenium есть около пятидесяти готовых ExpectedConditions. Большинство автоматизаторов используют пять‑семь. Из оставшихся часть имеет неочевидное поведение, и когда такое условие применяется без понимания, тесты ломаются непредсказуемо.
textToBePresentInElementи его варианты проверяют вхождение подстроки, а не равенство. Если ждатьtextToBePresentInElementLocated(locator, "Сохранено"), условие сработает на тексте «Сохранено», но также сработает на «Не сохранено», «Сохранено с ошибкой», «Сохранено: 5 записей». В тестах с разными типами уведомлений это приводит к ложноположительным проходам.attributeContains— то же самое, ищет подстроку в значении атрибута. УсловиеattributeContains(locator, "class", "active")сработает не только на классеactive, но и наactive-state,inactive,activated. На страницах с динамическими классами это даёт случайные совпадения.stalenessOf— условие срабатывает только когда элемент полностью пропал из DOM или был перерисован. Если фронтенд использует виртуальный DOM, как React или Vue, элемент может обновиться без пересоздания, иstalenessOfникогда не сработает. Применять его имеет смысл только когда вы точно знаете, что элемент будет удалён физически — например, при смене страницы или после анимацииunmount.elementToBeClickableплюс анимации — отдельная история. Если кнопка анимируется и едет в свою позицию через CSS transition, Selenium считает её кликабельной в момент, когда анимация ещё идёт. Координаты элемента в этот момент меняются между запросом «где ты находишься» и реальным кликом. Результат — Selenium кликает в точку, где кнопка была 50 миллисекунд назад, промахивается, тест ловит непонятную ошибку или вообще тихо продолжает с неправильным состоянием.
Лечится отключением анимаций в начале каждого теста через CSS‑инъекцию:
public void disableAnimations() { String script = """ const style = document.createElement('style'); style.innerHTML = ` *, *::before, *::after { animation-duration: 0s !important; animation-delay: 0s !important; transition-duration: 0s !important; transition-delay: 0s !important; } `; document.head.appendChild(style); """; ((JavascriptExecutor) driver).executeScript(script); }
Эту инъекцию имеет смысл вызывать в @BeforeEach базового тестового класса или в методе открытия страницы. После такого автотесты перестают зависеть от скорости анимаций, и сразу пропадает значительная часть случайных падений.
Что делать со всем этим вместе
Перечисленные ошибки редко встречаются по одной. Обычно в проекте есть все пять сразу: где‑то Thread.sleep, где‑то один большой таймаут, где‑то presence вместо clickable, где‑то нет ожидания исчезновения спиннеров, где‑то flaky из‑за анимаций. Чинить их по очереди — долго и неблагодарно, потому что пока чините одно, оставшиеся четыре продолжают портить статистику.
Рабочая последовательность такая. Сначала пишется новый базовый класс с правильными ожиданиями, фабрикой драйверов с отключёнными анимациями и набором констант для таймаутов. Дальше один тест, который чаще всего падает, переписывается на новый базовый класс. Если на нём ничего не упало за неделю прогонов, переписываются следующие пять тестов. Параллельно убираются все Thread.sleep через регулярку по проекту (Thread\.sleep ищется грепом за минуту, заменить вручную тоже недолго).
Через месяц такой работы количество flaky‑тестов на проекте обычно падает раз в пять. Оставшиеся — это уже реальные проблемы: гонки на бэкенде, нестабильные тестовые данные, сетевые таймауты к сторонним сервисам. Их меньше, и с каждым из них работают точечно, а не «добавим ещё один ретрай».
Итого
Большинство flaky‑тестов в UI‑автоматизации чинятся не ретраями и не увеличением общего таймаута, а пересмотром того, чего именно ждёт тест. Меняем Thread.sleep на WebDriverWait с явными условиями, разделяем таймауты по типам операций: от двух секунд для кликабельности до тридцати для долгих фоновых задач, проверяем clickability перед кликом и visibility перед чтением, дожидаемся не только появления результата, но и исчезновения промежуточных индикаторов загрузки, отключаем анимации через CSS‑инъекцию и читаем документацию ExpectedConditions перед применением неочевидных условий.
После такой ревизии flaky‑тесты полностью не исчезают — остаются настоящие, связанные с гонками данных, сетью и нестабильным окружением. Но их становится мало, и каждый чинится осмысленно.

Хотите прокачать практику QA и лучше разобраться в тестировании сервисов и автотестах? Присмотритесь к бесплатным демо-урокам: их проводят преподаватели‑практики, можно познакомиться с форматом обучения и задать вопросы экспертам.
4 июня в 20:00 — «API под контролем: тестирование сервисов с помощью Postman». Записаться
Покажем, почему знание API стало обязательным навыком для инженера тестирования и как Postman помогает проверять сервисы осмысленно, а не наугад.16 июня в 20:00 — «ИИ в автотестах: помощник или угроза?». Записаться
Поговорим о том, как искусственный интеллект уже меняет работу QA‑инженеров, где он действительно помогает, а где может создать новые риски для качества тестов.
Полный список бесплатных уроков июня смотрите в дайджесте.