Всем привет, меня зовут Денис, я Software Developer Engineer in Test (SDET) в компании Bimeister. Я занимаюсь разработкой софта для тестирования — это фреймворки, автоматизированные тесты, настройка CI Pipeline’ов и многое другое.

В статье расскажу, как мы победили исключение Stale Element Reference Exception при разработке нашего фреймворка, используя Selenium WebDriver и C#.

Коротко о SPA

Single Page Application — это одностраничное веб-приложение, в котором роутинг осуществляется на стороне клиента. Вместо того, чтобы отправлять запрос к серверу и выкачивать новый HTML-документ при переходе на новый URL, в SPA URL подменяется программно, а контент на странице размещается динамически средствами JavaScript.

В процессе работы пользователю может показаться, что он запустил не веб-сайт, а desktop-приложение, так как оно мгновенно реагирует на все его действия без ощутимых задержек. Такого эффекта удается добиться с помощью современных web-фреймворков и библиотек: Angular, React, Vue и других.

SPA имеет множество преимуществ для пользователя, но для автотестов — это узкое место, из-за которого возникает одна из самых частых ошибок при использовании Selenium WebDriver — Stale Element Reference Exception.

Исключение Stale Element Reference Exception

Исключение Stale Element Reference Exception — это runtime-ошибка. Она возникает, когда код теста использует объект и в это время у объекта меняется состояние. Это может быть связано с обновлением страницы или событием, вызванным взаимодействием с пользователем, которое изменяет структуру документа. Измененный объект в памяти тестового приложения остался, ссылка на него действительна, но на странице этот объект уже отсутствует — реактивное приложение сформировало новый элемент DOM, поэтому обращение по имеющейся ссылке приведёт к исключению.

WebDriver выбрасывает исключение “устаревшей” ссылки на элемент в одном из двух случаев, первый из которых встречается чаще, чем второй:

  • элемент полностью удален — то есть больше не существует в DOM;

  • элемент больше не связан с DOM — элемент существует по его локатору, но его ссылка устарела.

Распространенная причина исключения — удаление элемента JavaScript-библиотекой и замена его другим с тем же ID или атрибутами. Хотя заменяющие элементы могут выглядеть идентично, они разные. WebDriver не определяет, что заменяющие элементы действительно соответствуют ожидаемым.

Ошибка появляется именно при выполнении действий с элементом, когда элемент уже найден и мы пытаемся выполнить какую-либо операцию с ним. Например, кликнуть или ввести текст:

webElement.Click();
webElement.SendKeys();

Пример исключения

Рассмотрим абстрактный пример по шагам, чтобы разобрать причину появления исключения. Представим, у нас есть дерево папок и мы хотим раскрыть определённую папку в дереве по её имени.

Для этого мы написали бы примерный алгоритм:

  1. Сохраняем в переменную список всех элементов, которые будут найдены по тегу для каждой папки в дереве.

  2. В списке находим элемент с текстом, который совпадает с именем требуемой папки.

  3. Сохраняем этот элемент в переменную и вызываем .Click() по найденному элементу, чтобы раскрыть папку.

Допустим, что за это время дерево полностью обновилось из-за особенностей реализации приложения. Если мы снова попытаемся вызвать .Click() по найденному элементу, то столкнёмся с исключением Stale ElementReference Exception. Хотя визуально ничего не изменилось, скрипты обновили структуру документа и элемента с нужной ссылкой уже не существует в DOM.

Решение проблемы

Мы можем избавиться от исключения Stale Element Reference Exception несколькими способами:

Самый простой способ — добавить явное ожидание через Thread.Sleep, прежде чем искать элемент. Но такой подход в действительности не решает проблему полностью:

  • ссылка на элемент все равно может устареть прежде, чем выполнится действие;

  • так как исключение может возникнуть в любом месте, придется добавлять явное ожидание перед каждым поиском элемента, что негативно отразится на производительности тестов, не увеличив их стабильность.

Универсальный способ — обрабатываем исключение Stale Element Reference Exception в цикле с повторной инициализацией WebElement и с использованием Try Catch-блока.

Обычно есть два метода с разной сигнатурой, так как мы можем выполнить действие или непосредственно на закрепленный элемент страницы по его локатору, например, элемент кнопка — или по элементу, найденным в цикле.

public interface IBrowser 
{
		void Click(string locator); // кликает по элементу найденному по его локатору
		void Click(IWebElement element); // кликает по элементу
		IWebElement WaitWebElement(string locator); // ждет появления элемента по тегу
}

public class Browser : IBrowser
{
		void Click(string locator) { /* тело метода */ };
		void Click(IWebElement element) { /* тело метода */ };
		IWebElement WaitWebElement(string locator) { /* тело метода */ };
}

Простая ситуация — когда мы передаём аргументом локатор. В этом случае пытаемся повторно найти новый элемент через .FindElement() в Try-блоке и проблема с “устаревшей” ссылкой на элемент будет побеждена:

public void Click(string locator)
{
    for (var i = 1; i <= RetryNumber; i++)
    {
        try
        {
            Logger.WriteLine($"Try #{i}/{RetryNumber} to click on element with locator: {locator}.");
            Driver.FindElement(By.CssSelector(locator)).Click();
            Logger.WriteLine($"Clicking on the element: {locator} was successful.");
            return;
        }
        catch (StaleElementReferenceException)
        {
            Logger.WriteLine($"StaleElementReferenceException was thrown: try #{i}/{RetryNumber}.");
        }
    }

    Logger.WriteLine($"Unable to click on element with locator: {locator}");
    throw new TestException(TestErrorMessages.NoSuchElementException);
}

Сложная ситуация — когда в качестве аргумента передаётся сам IWebElement, так как нет никакого способа обновить состояние элемента. Правда нет, мы проверили. В этом случае помогает замыкание в передаче callback-функции, у которой будет вызываться клик:

public void Click(Func<IWebElement> findElement)
{
    for (var i = 1; i <= RetryNumber; i++)
    {
        try
        {
            Logger.WriteLine($"Try #{i}/{RetryNumber} to click on element.");
            findElement().Click();
            Logger.WriteLine("Clicking on the element was successful.");
            return;
        }
        catch (StaleElementReferenceException)
        {
            Logger.WriteLine($"StaleElementReferenceException was thrown: try #{i}/{RetryNumber}.");
        }
    }

    Logger.WriteLine("Failed to click on element.");
    throw new TestException(TestErrorMessages.NoSuchElementException);
}

Так мы передаём аргументом функцию, которая дополнительно запросит для нас элемент по тегу, если будет поймано исключение Stale Element Reference Exception.

Пример использования метода с такой сигнатурой:

Browser.Click(() => Browser.WaitWebElement("someLocator"));

При таком решении внутри Browser.Click() метод .Click() выполняется по свежему элементу, полученному благодаря вызову callback-функции. Это помогает обработать ошибку, когда ссылка на элемент успевает устареть прежде, чем выполнится действие.

Заключение

При автоматизации тестирования с Selenium WebDriver современных SPA-приложений мы сталкиваемся с рядом проблем, поскольку DOM-элемент часто устаревает прежде, чем к нему обратиться. Например, скрипты могут обновить структуру DOM, из-за чего появится исключения Stale Element Reference Exception. Реализация необходимых методов для обработки такой ошибки поможет избавиться от flaky-тестов и повысить их стабильность, что, в конечном счёте, отразит состояние вашего приложения более достоверно.

Комментарии (6)


  1. nin-jin
    11.11.2022 13:14
    -1

    Ещё можно использовать фреймворк, который в принципе не пересоздаёт один и тот же элемент по 10 раз, а всегда использует один и тот же.. правда, это не спортивно.


    1. ScarletFlash
      11.11.2022 13:59

      Это может быть непроизводительно. Всякий виртуальный скроллинг целиком построен на удалении старых нод из DOM и добавлении новых.

      https://material.angular.io/cdk/scrolling

      https://svelte.dev/repl/1c36db7c1e7e4ef2bfb04874321412e5?version=3.53.1


      1. nin-jin
        11.11.2022 14:49
        -1

        Не всякий. Гляньте этот, например: https://mol.hyoo.ru/#!section=demos/filter=list/demo=mol_list_demo_table


        1. ScarletFlash
          11.11.2022 16:04

          И правда.)

          Интересно, пошел читать исходники.)


    1. Zlaylink Автор
      11.11.2022 15:17

      Спасибо за ответ! Действительно, можно использовать разные фрейморвки, но нужно учесть факторы:

      1. У каждого из них есть как свои преимущества, так и недостатки, поэтому так или иначе в каждом придется с чем-то бороться.
      2. Тестовый проект может быть большим, поэтом переход на другой фреймворк и стек будет не выгоден для решения единичной проблемы.

      И, вы правы, в каждой из таких задач есть "спортивная" составляющая.


  1. lxsmkv
    12.11.2022 18:15

    А по CDP нельзя спросить у браузера, типа, он окончательно успокоился, или еще что-то думает и пережевывает? Я не знаю возможностей CDP, поэтому и спрашиваю.