Работа с элементами

Элемент найден — что дальше? Читаем текст и атрибуты (и выясняем, почему 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

Элемент не найден. Причин ровно четыре, и перебирать их стоит именно в таком порядке:

  1. Ещё не отрисован. Самая частая. Лечится явным ожиданием, а не перезапуском.
  2. Локатор неверный. Проверьте в консоли браузера: document.querySelectorAll("ваш селектор").length.
  3. Элемент внутри iframe. WebDriver ищет только в текущем контексте. Нужен driver.switch_to.frame(...), а после работы — driver.switch_to.default_content().
  4. Элемент в 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.
Проверьте себя
1. В разметке чекбокс объявлен как checked, но пользователь снял галочку. Что вернёт element.get_attribute(checked) и почему это опасно?
AВернёт False — состояние актуальное, опасности нет
BВернёт строку true, потому что атрибут остался в разметке; в Python непустая строка истинна, и проверка снятой галочки пройдёт ошибочно
CВернёт None, так как атрибут удаляется при снятии галочки
DБросит StaleElementReferenceException
2. Тест собирает список строк корзины в переменную rows и в цикле удаляет каждую. На второй итерации падает StaleElementReferenceException. Почему?
AСписок rows нужно было получать через find_element, а не find_elements
BКончился таймаут WebDriverWait
CПосле первого удаления фронтенд перерисовал список: старые узлы DOM выброшены, а сохранённые ссылки указывают на них
DSelenium запрещает вызывать click() дважды в одном цикле