Всем привет, меня зовут Денис, я 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();
Пример исключения
Рассмотрим абстрактный пример по шагам, чтобы разобрать причину появления исключения. Представим, у нас есть дерево папок и мы хотим раскрыть определённую папку в дереве по её имени.
Для этого мы написали бы примерный алгоритм:
Сохраняем в переменную список всех элементов, которые будут найдены по тегу для каждой папки в дереве.
В списке находим элемент с текстом, который совпадает с именем требуемой папки.
Сохраняем этот элемент в переменную и вызываем .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-тестов и повысить их стабильность, что, в конечном счёте, отразит состояние вашего приложения более достоверно.