Явные и неявные ожидания

Главная болезнь UI-автотестов — «мигающие» падения. В девяти случаях из десяти лечится не перезапуском, а правильными ожиданиями. Разбираем implicit wait, WebDriverWait с expected_conditions и объясняем, почему time.sleep() в тестах — профессиональный позор.

Явное ожидание (explicit wait) — цикл, который каждые полсекунды проверяет заданное условие и завершается, как только оно выполнилось, либо падает с TimeoutException по истечении таймаута.

Откуда берутся мигающие тесты

Флаки-тест (flaky test) — тест, который на одном и том же коде то зелёный, то красный. Его причина почти всегда одна: гонка. Selenium работает быстрее человека и быстрее интерфейса. Драйвер отправил клик и тут же ищет результат, а браузер в это время только начал запрос к API, показал спиннер и собирается перерисовать половину страницы.

В эпоху статичных сайтов проблемы не было: страница загрузилась — значит, весь HTML на месте. Современный SPA (Single Page Application — одностраничное приложение на React, Vue, Angular) устроен иначе: сначала приезжает пустой каркас, потом JavaScript тянет данные, потом рисует контент, потом ещё догружает картинки, и всё это с анимациями. Момент «страница готова» размазан на секунды, а на нагруженном CI-сервере — и на десятки секунд.

Отсюда все любимые исключения: NoSuchElementException (элемента ещё нет в DOM), ElementNotInteractableException (элемент есть, но скрыт), ElementClickInterceptedException (по кнопке кликнул полупрозрачный оверлей, который как раз растворяется).

Почему time.sleep() — не решение

Первое, что делает новичок, увидев мигающий тест, — вставляет time.sleep(5). И тест зеленеет. Это ловушка.

import time

driver.find_element(By.CSS_SELECTOR, "[data-testid='login-submit']").click()
time.sleep(5)   # ТАК НЕ ДЕЛАЕМ
assert driver.find_element(By.CSS_SELECTOR, "[data-testid='dashboard']").is_displayed()

У этого кода два взаимоисключающих недостатка сразу. Он слишком долгий: обычно страница готова за 300 мс, а вы честно спите пять секунд. Сто таких мест — и прогон вырос на восемь минут пустого ожидания. И он слишком короткий: в день релиза, когда CI перегружен, ответ придёт за 6 секунд — и тест всё равно упадёт. Вы не убрали гонку, вы просто сдвинули её порог.

time.sleep() оправдан ровно в двух ситуациях: при отладке руками и когда нужно дождаться заведомо не-DOM-события, у которого нет наблюдаемого признака (например, дебаунс поля поиска). Во всём остальном коде его быть не должно, и хороший линтер это правило проверяет автоматически.

Неявное ожидание и почему его не любят

Selenium предлагает глобальный костыль — implicit wait. Одна строка, и все последующие вызовы find_element начинают ждать элемент указанное время.

driver.implicitly_wait(10)   # секунды, действует до конца сессии

# теперь этот вызов не упадёт сразу, а будет опрашивать DOM до 10 секунд
driver.find_element(By.CSS_SELECTOR, "[data-testid='dashboard']")

Звучит удобно, но в зрелых командах implicit wait обычно запрещён. Причин четыре.

  • Ждёт только присутствие в DOM. Элемент нашёлся — ожидание закончилось. А он ещё скрыт под спиннером, перекрыт модалкой или задизейблен. Клик упадёт.
  • Не умеет ждать исчезновения. «Дождаться, когда спиннер пропадёт» — самая частая задача в тестах. Implicit wait тут бессилен по определению.
  • Ломает негативные проверки. Проверка «корзина пуста» через find_elements вернёт пустой список — но только через 10 секунд, потому что драйвер честно ждал появления. Двадцать таких проверок — плюс три минуты к прогону.
  • Непредсказуемо смешивается с явными ожиданиями. Документация Selenium прямо предупреждает: не комбинируйте implicit и explicit wait. Внутри WebDriverWait каждый опрос сам ждёт implicit-таймаут, и вместо 10 секунд ожидание может растянуться на минуту, а таймауты станут плавающими.

Практический вывод: либо implicitly_wait(0) (значение по умолчанию — не трогаем), либо вообще ни строчки про него в коде. Ждём только явно.

WebDriverWait и expected_conditions

Правильный инструмент — WebDriverWait в паре с готовыми условиями из модуля expected_conditions (по традиции импортируется как EC).

from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC

SUBMIT = (By.CSS_SELECTOR, "[data-testid='login-submit']")
SPINNER = (By.CSS_SELECTOR, "[data-testid='spinner']")
DASHBOARD = (By.CSS_SELECTOR, "[data-testid='dashboard']")

wait = WebDriverWait(driver, timeout=10, poll_frequency=0.5)

# ждём, что кнопка не просто есть, а видима и кликабельна
button = wait.until(EC.element_to_be_clickable(SUBMIT))
button.click()

# ждём, что спиннер исчез (или его вовсе нет)
wait.until(EC.invisibility_of_element_located(SPINNER))

# и только теперь проверяем результат
dashboard = wait.until(EC.visibility_of_element_located(DASHBOARD))
assert "Личный кабинет" in dashboard.text

Разберём по строкам. WebDriverWait(driver, timeout=10, poll_frequency=0.5) создаёт объект ожидания: максимум 10 секунд, проверка каждые полсекунды. wait.until(condition) запускает цикл и возвращает результат условия — большинство EC отдают найденный элемент, поэтому отдельный find_element после ожидания не нужен (и вреден: лишний запрос к драйверу и лишний шанс поймать гонку).

Ключевая мысль урока: ожидание — это не «подожди немного», а «подожди, пока станет правдой конкретный факт». Факт нужно выбрать правильно.

УсловиеЧто гарантируетКогда брать
presence_of_element_locatedэлемент есть в DOM (может быть скрыт)проверить факт наличия, прочитать атрибут скрытого узла
visibility_of_element_locatedесть в DOM и видим (размер больше нуля)прочитать текст, убедиться, что блок показан
element_to_be_clickableвидим и не задизейбленперед любым кликом
invisibility_of_element_locatedскрыт или отсутствуетдождаться исчезновения спиннера, оверлея, тоста
text_to_be_present_in_elementв элементе появился нужный текстсчётчик корзины стал равен 3
url_contains / url_to_beадрес страницы сменилсяпосле редиректа, входа, оплаты
staleness_of(element)старый элемент выброшен из DOMдождаться перерисовки перед новым поиском
new_window_is_openedоткрылась новая вкладкаперед switch_to.window

Своё условие в одну строку

Условие — это просто вызываемый объект, который получает драйвер и возвращает «правдивое» значение (элемент, строку, True) или False/None. Значит, любое ожидание можно написать самому.

TOTAL = (By.CSS_SELECTOR, "[data-testid='cart-total']")
ROWS = (By.CSS_SELECTOR, "[data-testid='cart-row']")

# 1. Лямбда: ждём, пока в корзине появится хотя бы 3 строки
wait.until(lambda d: len(d.find_elements(*ROWS)) >= 3)

# 2. Ждём, пока сумма перестанет быть нулевой (пересчёт на бэкенде)
wait.until(lambda d: d.find_element(*TOTAL).text.strip() not in ("", "0 ₽"))

# 3. Класс-условие: сумма стала ровно такой, какую мы ждём
class total_equals:
    def __init__(self, locator, expected):
        self.locator = locator
        self.expected = expected

    def __call__(self, driver):
        element = driver.find_element(*self.locator)
        return element if element.text.strip() == self.expected else False

wait.until(total_equals(TOTAL, "2 480 ₽"))

Обратите внимание: в третьем примере условие возвращает элемент, а не True. Так принято — тогда wait.until отдаст его вызывающему коду, и элемент можно сразу использовать.

Как это работает

Внутри until нет никакой магии — это обычный цикл опроса (polling). Псевдокод почти дословно повторяет исходники Selenium:

deadline = now() + timeout
повторять:
    попробовать:
        значение = условие(driver)
        если значение правдиво: вернуть значение
    поймать исключение из ignored_exceptions:
        игнорировать и продолжить
    если now() > deadline: бросить TimeoutException
    спать poll_frequency

Отсюда три неочевидных следствия. Первое: WebDriverWait по умолчанию глотает NoSuchElementException — именно поэтому «элемента ещё нет» не роняет ожидание, а просто приводит к следующей итерации. Список игнорируемых исключений настраивается: WebDriverWait(driver, 10, ignored_exceptions=[StaleElementReferenceException]).

Второе: условие вызывается много раз, поэтому оно обязано быть быстрым и без побочных эффектов. Условие, которое кликает, отправляет форму или что-то пишет в базу, выстрелит вам в ногу: за 10 секунд оно выполнится двадцать раз.

Третье: таймаут — это потолок, а не пауза. Если условие выполнилось на первой же итерации, ожидание займёт миллисекунды. Поэтому большой таймаут (10–15 секунд) не замедляет зелёный тест — он лишь даёт запас на медленный CI. Замедляет он только падающие тесты, и это правильно.

Смоделируем этот цикл на чистом Python — с виртуальными часами, чтобы не ждать по-настоящему. Спиннер «исчезает» на 2,2-й секунде.

class VirtualClock:
    def __init__(self):
        self.now = 0.0

    def sleep(self, seconds):
        self.now += seconds


def wait_until(condition, clock, timeout=10.0, poll=0.5):
    deadline = clock.now + timeout
    while True:
        try:
            value = condition()
            if value:
                return value, clock.now
        except LookupError:
            pass  # элемента ещё нет — это ожидаемо, продолжаем опрос
        if clock.now >= deadline:
            raise TimeoutError("условие не выполнилось за %.1f c" % timeout)
        clock.sleep(poll)


clock = VirtualClock()

def buy_button_is_ready():
    if clock.now < 2.2:          # пока крутится спиннер
        raise LookupError("кнопки ещё нет в DOM")
    return "кнопка Купить"

element, spent = wait_until(buy_button_is_ready, clock, timeout=10.0, poll=0.5)
print("нашли:", element)
print("ждали секунд:", spent)
print("а time.sleep(10) отнял бы:", 10.0)

Результат:

нашли: кнопка Купить
ждали секунд: 2.5
а time.sleep(10) отнял бы: 10.0

Вот и вся разница: явное ожидание потратило 2,5 секунды вместо десяти — и при этом не упало бы, окажись страница медленнее.

Частые ошибки

  • time.sleep() вместо ожидания. Одновременно медленно и ненадёжно. Единственный законный sleep в тестовом коде — тот, что вы удалили.
  • Смешивание implicit и explicit wait. Таймауты начинают складываться и умножаться, ожидание в 5 секунд превращается в минуту. Выберите одно — и это explicit.
  • Ждать presence, а кликать. Элемент в DOM ≠ элемент кликабелен. Перед кликом — только element_to_be_clickable, иначе поймаете ElementClickInterceptedException от исчезающего оверлея.
  • Ждать появления спиннера. Классическая гонка: спиннер может мелькнуть быстрее, чем ваш опрос успеет его увидеть. Ждите не появления загрузчика, а результата — данных, текста, изменения URL.
  • Ловить TimeoutException и молча идти дальше. try/except: pass вокруг ожидания превращает падение теста в неверный вывод. Если ждали и не дождались — это провал, и он должен быть виден.
  • Таймаут в 60 секунд «на всякий случай». Он не чинит тест, а маскирует деградацию производительности продукта и растягивает разбор падений. Разумный диапазон — 5–15 секунд, отдельно и осознанно — для тяжёлых операций вроде генерации отчёта.
  • Новый WebDriverWait на каждый чих. Не ошибка, но шум. Держите один объект-ожидание на страницу (или фабрику с таймаутом из конфигурации) — код станет чище.

Итоги

  • Мигающие тесты — это гонка между быстрым драйвером и медленным интерфейсом; лечится ожиданиями, а не перезапусками.
  • time.sleep() в тестах запрещён: он одновременно и слишком долгий, и слишком короткий.
  • Implicit wait ждёт только присутствие в DOM, не умеет ждать исчезновения, ломает негативные проверки и опасно смешивается с явными ожиданиями.
  • WebDriverWait(driver, 10).until(EC....) — рабочий стандарт; условие возвращает элемент, повторный поиск не нужен.
  • Перед кликом ждите element_to_be_clickable, перед чтением текста — visibility_of_element_located, после действия — invisibility_of_element_located для спиннера.
  • Под капотом — цикл опроса с игнорируемыми исключениями; условие вызывается многократно, поэтому оно должно быть быстрым и без побочных эффектов.
  • Любое ожидание можно написать самому: условие — это функция от драйвера, возвращающая элемент или False.
Проверьте себя
1. Почему не стоит одновременно использовать implicitly_wait и WebDriverWait?
ASelenium бросит исключение при попытке создать WebDriverWait, если задан implicit wait
BТаймауты накладываются друг на друга: каждый опрос внутри WebDriverWait сам ждёт implicit-таймаут, и итоговое время становится непредсказуемым
CImplicit wait отключает expected_conditions
DНикакой проблемы нет, документация рекомендует комбинировать оба подхода
2. Тест кликает по кнопке Купить и иногда падает с ElementClickInterceptedException: клик перехватывает исчезающий оверлей. Какое ожидание поставить перед кликом?
AEC.presence_of_element_located — достаточно убедиться, что кнопка есть в DOM
Btime.sleep(3) — оверлей точно успеет исчезнуть
CEC.element_to_be_clickable — кнопка должна быть видима и активна
Ddriver.implicitly_wait(15) в начале теста