Явные и неявные ожидания
Главная болезнь 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.