Работа с элементами
Элемент найден — что дальше? Читаем текст и атрибуты (и выясняем, почему get_attribute иногда врёт), проверяем видимость и доступность, а главное — учимся не бояться двух самых частых исключений Selenium.
WebElement — не копия узла страницы, а ссылка на него: уникальный идентификатор, по которому драйвер каждый раз обращается к живому DOM. Как только браузер заменяет узел, ссылка протухает — и любой вызов по ней падает.
find_element против find_elements
Два метода, отличающиеся одной буквой, ведут себя принципиально по-разному.
from selenium.common.exceptions import NoSuchElementException
ROW = (By.CSS_SELECTOR, "[data-testid='cart-row']")
# find_element: возвращает ПЕРВЫЙ найденный элемент,
# а если не нашёл — бросает NoSuchElementException
first_row = driver.find_element(*ROW)
# find_elements: возвращает СПИСОК (возможно, пустой) и НИКОГДА не бросает исключение
rows = driver.find_elements(*ROW)
print(len(rows)) # 0, 1, 42 — как повезёт
Отсюда главная идиома: проверять отсутствие элемента нужно через find_elements, а не через try/except NoSuchElementException.
# ПЛОХО: исключение как способ ветвления, читается тяжело
try:
driver.find_element(*ROW)
cart_empty = False
except NoSuchElementException:
cart_empty = True
# ХОРОШО: намерение видно с первого взгляда
cart_empty = len(driver.find_elements(*ROW)) == 0
assert cart_empty, "Корзина должна быть пуста после оформления заказа"
Важная оговорка: пустой список — это мгновенный ответ «сейчас ничего нет». Если контент подгружается асинхронно, ноль строк может означать не «корзина пуста», а «данные ещё не приехали». Негативную проверку всегда сочетайте с ожиданием стабильного состояния — например, сперва дождитесь исчезновения спиннера, и только потом считайте строки.
Читаем текст и атрибуты
Самая недооценённая тонкость Selenium: element.text возвращает только видимый текст, как его отрисовал браузер. У скрытого элемента (display: none, нулевые размеры, схлопнутый аккордеон) свойство text вернёт пустую строку — даже если внутри лежит целый абзац.
tooltip = driver.find_element(By.CSS_SELECTOR, "[data-testid='tooltip']")
print(tooltip.text) # '' — подсказка скрыта, текста нет
print(tooltip.get_attribute("textContent")) # 'Цена без учёта доставки' — сырой текст из DOM
Теперь про атрибуты. В Selenium есть три похожих метода, и разница между ними на собеседованиях спрашивают чаще, чем кажется.
| Метод | Что возвращает | Пример |
get_attribute(name) | сначала пробует свойство (property) JS-объекта, и только если его нет — HTML-атрибут | для поля ввода вернёт текущее введённое значение |
get_dom_attribute(name) | строго HTML-атрибут, как он записан в исходной разметке | вернёт value из HTML, даже если пользователь всё перепечатал |
get_property(name) | строго свойство JS-объекта (актуальное состояние) | checked у чекбокса: True/False |
# Разметка: <input data-testid="qty" value="1">, пользователь ввёл 5
qty = driver.find_element(By.CSS_SELECTOR, "[data-testid='qty']")
qty.get_attribute("value") # '5' — актуальное значение (взято из property)
qty.get_dom_attribute("value") # '1' — то, что написано в HTML
qty.get_property("value") # '5' — то же, что и первое, но без догадок
# Разметка: <input type="checkbox" data-testid="agree" checked>, пользователь снял галочку
agree = driver.find_element(By.CSS_SELECTOR, "[data-testid='agree']")
agree.get_attribute("checked") # 'true' — строка! потому что атрибут в разметке есть
agree.get_property("checked") # False — реальное состояние, булево значение
agree.is_selected() # False — человеческий способ спросить то же самое
Запомните правило: для проверки состояния берите is_selected() / get_property(), а не get_attribute(). Иначе тест «Галочка снята» пройдёт на снятой галочке — потому что строка 'true' в Python истинна.
Видимость и доступность
Три булевых метода, которыми проверяют состояние элемента:
button = driver.find_element(By.CSS_SELECTOR, "[data-testid='checkout']")
button.is_displayed() # виден ли пользователю: не display:none, не visibility:hidden, размер > 0
button.is_enabled() # активен ли: у form-элементов — отсутствие атрибута disabled
button.is_selected() # выбран ли: только для checkbox, radio, option
Здесь спрятаны две ловушки. Первая: is_displayed() не знает, что элемент перекрыт другим — модалкой, баннером о cookie, липкой шапкой. Кнопка «видима» по всем формальным признакам, а клик уходит в оверлей. Отсюда и ElementClickInterceptedException, о котором мы говорили в уроке про ожидания.
Вторая: is_enabled() осмысленно только для интерактивных элементов формы. Для <div> или <a> он всегда вернёт True, даже если фронтенд «дизейблит» их классом .is-disabled. Проверять такое нужно иначе:
link = driver.find_element(By.CSS_SELECTOR, "[data-testid='pay-link']")
link.is_enabled() # True — бесполезно, это тег a
# смотрим то, чем реально пользуется приложение
classes = link.get_attribute("class").split()
assert "is-disabled" in classes
assert link.get_attribute("aria-disabled") == "true"
Два исключения, которые встретят вас первыми
NoSuchElementException
Элемент не найден. Причин ровно четыре, и перебирать их стоит именно в таком порядке:
- Ещё не отрисован. Самая частая. Лечится явным ожиданием, а не перезапуском.
- Локатор неверный. Проверьте в консоли браузера:
document.querySelectorAll("ваш селектор").length. - Элемент внутри
iframe. WebDriver ищет только в текущем контексте. Нуженdriver.switch_to.frame(...), а после работы —driver.switch_to.default_content(). - Элемент в Shadow DOM (изолированное дерево веб-компонента). Обычный CSS-селектор туда не пробивается — нужен
element.shadow_root.
StaleElementReferenceException
«Устаревшая ссылка на элемент» — исключение, которое доводит новичков до слёз, потому что элемент виден на экране, а Selenium клянётся, что его нет.
Дело в том, что WebElement — это ссылка на конкретный узел DOM, а не на «кнопку Купить вообще». Когда React или Vue перерисовывает часть страницы, он выбрасывает старые узлы и создаёт новые — визуально идентичные, но физически другие. Ваша ссылка указывает на выброшенный узел, и драйвер честно сообщает: объект протух.
# КЛАССИЧЕСКАЯ ЛОВУШКА
rows = driver.find_elements(By.CSS_SELECTOR, "[data-testid='cart-row']")
for row in rows:
row.find_element(By.CSS_SELECTOR, "[data-testid='remove']").click()
# после первого удаления фронтенд перерисовал ВЕСЬ список,
# и оставшиеся ссылки в rows мгновенно протухли
# → StaleElementReferenceException на втором витке цикла
# РЕШЕНИЕ: не хранить ссылки между действиями — искать заново
while driver.find_elements(By.CSS_SELECTOR, "[data-testid='cart-row']"):
row = driver.find_element(By.CSS_SELECTOR, "[data-testid='cart-row']")
row.find_element(By.CSS_SELECTOR, "[data-testid='remove']").click()
wait.until(EC.staleness_of(row)) # дожидаемся, что старый узел действительно выброшен
Второй способ — переложить проблему на WebDriverWait, который умеет игнорировать это исключение и просто повторить попытку:
from selenium.common.exceptions import StaleElementReferenceException
wait = WebDriverWait(
driver,
timeout=10,
ignored_exceptions=(NoSuchElementException, StaleElementReferenceException),
)
# условие вызывается снова и снова; если узел протух — просто следующая итерация
wait.until(lambda d: d.find_element(*TOTAL).text.strip() == "2 480 ₽")
Как это работает
Когда драйвер находит элемент, он не пересылает вам HTML. Он кладёт узел DOM в свою внутреннюю карту и отдаёт клиенту ссылку — по спецификации W3C WebDriver это JSON вида {"element-6066-11e4-a52e-4f735466cecf": "f.7A2B..."}, где длинный ключ — просто договорённость протокола, а значение — идентификатор узла в этой карте.
Дальше каждый вызов вроде element.click() превращается в HTTP-запрос POST /session/<id>/element/<element-id>/click. Драйвер берёт идентификатор, лезет в карту, достаёт узел — и вот тут возможны два исхода. Если узел всё ещё в документе, всё работает. Если браузер его выбросил (перерисовка, удаление, переход на другую страницу), драйвер возвращает ошибку с кодом stale element reference, которая в Python превращается в StaleElementReferenceException.
Отсюда единственно верная стратегия: относитесь к WebElement как к скоропортящемуся продукту. Нашли — сразу использовали. Не храните элементы в полях класса, не кешируйте списки между действиями, не передавайте их через границы тестов. Хранить нужно локаторы, а элементы получать заново.
Смоделируем механику протухания и повторной попытки на чистом Python — без Selenium, но по той же логике, что живёт внутри реальных обёрток:
class StaleElementReferenceError(Exception):
pass
class FakePage:
def __init__(self):
self.lookups = 0
def find(self, locator):
# каждый поиск выдаёт СВЕЖУЮ ссылку на узел
self.lookups += 1
return {"ref": "node-%d" % self.lookups, "text": "Товар добавлен в корзину"}
def read_text(self, element):
# страница успела перерисоваться: самый первый узел выброшен из DOM
if element["ref"] == "node-1":
raise StaleElementReferenceError("ссылка на элемент устарела")
return element["text"]
def with_retry(action, attempts=3):
for attempt in range(1, attempts + 1):
try:
return action(), attempt
except StaleElementReferenceError:
print("попытка %d: узел протух, ищем элемент заново" % attempt)
raise RuntimeError("элемент так и не стабилизировался")
page = FakePage()
def read_cart_message():
element = page.find("[data-testid=cart-message]") # ищем заново на КАЖДОЙ попытке
return page.read_text(element)
text, attempt = with_retry(read_cart_message)
print("прочитали:", text)
print("успех с попытки:", attempt)
Результат:
попытка 1: узел протух, ищем элемент заново
прочитали: Товар добавлен в корзину
успех с попытки: 2
Ключевая деталь — page.find(...) вызывается внутри повторяемой функции. Если бы элемент искали один раз снаружи, повторные попытки бесконечно натыкались бы на ту же протухшую ссылку. Именно эту ошибку чаще всего допускают, когда пишут свою retry-обёртку.
Частые ошибки
- Цикл по сохранённому списку элементов, который меняет страницу. Удаление, сортировка, пагинация — после первого действия список протух. Итерируйте по индексам с повторным поиском или в стиле «пока есть строки — удаляй первую».
- Проверка отсутствия через
try/except NoSuchElementException. Используйтеfind_elementsи длину списка — это идиома, а не хак. get_attribute("checked")для проверки чекбокса. Вернёт строку'true'из разметки, а не реальное состояние. Беритеis_selected().- Ассерт на пустой
.textу скрытого элемента. Тест «сообщение об ошибке пустое» пройдёт просто потому, что блок скрыт. Проверяйте видимость отдельно или читайтеtextContent. - Хранение
WebElementв полях Page Object.self.submit_button = driver.find_element(...)в конструкторе — прямая дорога кStaleElementReferenceException. В полях держите только локаторы. - Глухой retry «на всё подряд». Обёртка, которая ретраит любое исключение, спрячет настоящий баг продукта. Повторять можно
StaleElementReferenceException— и, с осторожностью,ElementClickInterceptedException. Ассерты не ретраят никогда. - Забытый возврат из
iframe. Послеswitch_to.frame()все последующие поиски идут внутри фрейма, и элементы основной страницы «пропадают». Возвращайтесь черезswitch_to.default_content().
Итоги
find_elementбросаетNoSuchElementException,find_elementsвозвращает список (возможно, пустой) и не падает — отсутствие проверяем вторым.element.text— только видимый текст; у скрытого элемента он пуст, сырой текст берите черезget_attribute("textContent").get_attributeотдаёт приоритет свойству JS-объекта,get_dom_attribute— исходной разметке,get_property— актуальному состоянию.- Состояние чекбоксов и радиокнопок проверяйте через
is_selected():get_attribute("checked")вернёт истинную строку даже у снятой галочки. is_displayed()не знает о перекрытии оверлеем,is_enabled()бесполезен дляdivиa.WebElement— ссылка на конкретный узел DOM; перерисовка страницы делает её протухшей, и вы получаетеStaleElementReferenceException.- Лекарство одно: хранить локаторы, а не элементы; искать заново перед каждым действием; при необходимости —
EC.staleness_ofилиignored_exceptionsвWebDriverWait.