Мы продолжаем делиться полезными материалами и сегодня поговорим о Selenium – инструменте тестирования web-приложений. Изучая его особенности, мы обнаружили в комьюнити ряд сообщений о различных ошибках: например, разработчики сталкивались с падением тестов, связанных с Shadow DOM, и получали ошибки в значениях root для Selenium. Однако, существует мнение, что такие сбои тестов могут оказаться не багом, а фичей. О своем подходе к работе с теневым DOM рассказал Титус Фортнер – core contributor в Selenium и автор материалов о тестировании. С разрешения автора переводим его статью, дополнив наблюдениями из нашей практики. Материал может быть полезен всем, кто занимается автоматизацией UI-тестирования с помощью Selenium.

Немного о теневом DOM в frontend-разработке

Разработчику бывают необходимы компоненты, которые генерируют много тегов и им требуется некоторая инкапсуляция. Здесь на помощь приходят браузерные компоненты. Они позволяют скрывать детали реализации и отображать минимум тегов в DOM, изолируя свое содержимое от основного DOM. Этот подход реализуется за счет так называемого “теневого” DOM. Мы довольно часто сталкиваемся с такими компонентами, это, например, теги <select>, <video> и т.д. Для того чтобы увидеть их внутреннее содержимое, скрытое от light DOM, нужны  соответствующие настройки браузера: например, в Chrome это выбор чекбокса “Show user agent shadow DOM”.

Shadow DOM – часть DOM API, с её помощью можно создавать уникальные изолированные элементы, сходные со встроенными браузерными элементами, со своей внутренней разметкой и стилями. «Теневой» DOM помогает контролировать размещение потомков сложных DOM-элементов в нужных местах их внутренней разметки, обозначенных специальным тегом slot. С «теневыми» участками документов можно столкнуться и в приложениях, и на страницах сайтов, даже если при их создании не использовались ни веб-компоненты, ни основанные на них библиотеки. Стандарты shadow DOM описаны в спецификации

Но что если нам требуется кастомная реализация? Что если мы хотим сделать собственную реализацию поверх предоставленной shadow DOM? При этом к тегам “теневого” DOM добраться не так просто, так как внешний DOM ничего “не знает” о внутреннем устройстве такого компонента. 

В различных спецификациях описаны методы, которые позволяют работать с теневым DOM, как с обычным, а для стилизации в теге pseudo предоставляется псевдокласс. Также можно создавать композицию обычного и теневого DOM, с данным подходом можно подробнее ознакомиться здесь

В фронтенд-разработке доступ к js и стилям инкапсулированных компонентов также реализуют различные библиотеки. Многие из них создают “копию” скрытого содержимого компонента и отрисовывают отдельно в документе, чтобы в дальнейшем беспрепятственно стилизовать. Именно “копия” в виде тегов светлого DOM будет принимать на себя различные манипуляции и давать пользователю обратную связь.

Сейчас мы кратко познакомились с тем, как работает “теневой” DOM. Однако, если он изолирован от обычного DOM, то как же проходят этапы его тестирования при применении автотестов? Рассмотрим далее.

Теневой DOM в Selenium

Чтобы получить доступ к элементам Shadow DOM в Selenium 4 с помощью браузеров Chromium (Microsoft Edge и Google Chrome) версии 96 или выше, можно использовать метод shadow root:

Java
WebElement shadowHost = driver.findElement(By.cssSelector("#shadow_host"));
SearchContext shadowRoot = shadowHost.getShadowRoot();
WebElement shadowContent = shadowRoot.findElement(By.cssSelector("#shadow_content"));
Ruby
shadow_host = @driver.find_element(css: '#shadow_host')
shadow_root = shadow_host.shadow_root
shadow_content = shadow_root.find_element(css: '#shadow_content')
Python
shadow_host = driver.find_element(By.CSS_SELECTOR, '#shadow_host')
shadow_root = shadow_host.shadow_root
shadow_content = shadow_root.find_element(By.CSS_SELECTOR, '#shadow_content')
C#
var shadowHost = _driver.FindElement(By.CssSelector("#shadow_host"));
var shadowRoot = shadowHost.GetShadowRoot();
var shadowContent = shadowRoot.FindElement(By.CssSelector("#shadow_content"));

С 96 версии и выше Chromium значения теневого корня сделал совместимыми с обновленной спецификацией W3C WebDriver. Теперь можно определять получение теневого root-элемента и местоположение элементов в теневом root. Мы можем ожидать, что и Firefox вскоре добавит такую же поддержку. Кроме того, команда Selenium работает над добавлением поддержки в WebKit, поэтому в конечном итоге это будет доступно будет и в Safari.

Доступ к элементам теневого DOM, как правило, предоставляется с помощью JavaScript. Если вы уже работаете с теневым root в Chrome, Edge или Safari, скорее всего, ваш код выглядит следующим образом:

Java
WebElement shadowHost = driver.findElement(By.cssSelector("#shadow_host"));
JavascriptExecutor jsDriver = (JavascriptExecutor) driver;

WebElement shadowRoot = (WebElement) jsDriver.executeScript("return arguments[0].shadowRoot", shadowHost);
WebElement shadowContent = shadowRoot.findElement(By.cssSelector("#shadow_content"));
Ruby
shadow_host = @driver.find_element(css: '#shadow_host')
shadow_root = @driver.execute_script('return arguments[0].shadowRoot', shadow_host)
shadow_content = shadow_root.find_element(css: '#shadow_content')
Python
shadow_host = driver.find_element_by_css_selector('#shadow_host')
shadow_root = driver.execute_script('return arguments[0].shadowRoot', shadow_host)
shadow_content = shadow_root.find_element_by_css_selector('#shadow_content')
C#
var shadowHost = _driver.FindElement(By.CssSelector("#shadow_host"));
var js = ((IJavaScriptExecutor)_driver);

var shadowRoot = (IWebElement)js.ExecuteScript("return arguments[0].shadowRoot", shadowHost);
var shadowContent = shadowRoot.FindElement(By.CssSelector("#shadow_content"));

Этот код работает для браузеров Chromium до версии v96 и Safari как в Selenium 3, так и в Selenium 4.

Проблема преобразования и приведения

Selenium просматривает возвращаемые значения из команд выполнения скрипта и, если обнаруживает элемент, автоматически преобразует его. С 96 версии Chromium JavaScript возвращает то, что не идентифицируется как элемент. 

И тут возникают две проблемы – преобразование и приведение. Преобразование объекта в теневой root реализовала только Java в версии 4.0. Другие языки ждут версии 4.1. Вторая проблема – приведение. Оно применимо только к строго типизированным Java и .NET. Вот что мы видим:

Java

В Java возвращаемое значение представляет собой Map без преобразования в WebElement, поэтому в Selenium 3 или 4 вы получите такую ошибку.

java.lang.ClassCastException: class com.google.common.collect.Maps$TransformedEntriesMap cannot be cast to
class org.openqa.selenium.WebElement (com.google.common.collect.Maps$TransformedEntriesMap and
org.openqa.selenium.WebElement are in unnamed module of loader 'app')
Ruby

В Selenium 3 и Selenium 4.0 вы получите сообщение об ошибке.

NoMethodError: undefined method `find_element' for #<Hash:0x00007fdc69997800>

Поскольку в Ruby нет строгой типизации, код корректно работает в Selenium 4.1.

Python

В Selenium 3 и 4.0 Python выдает ошибку.

AttributeError: 'dict' object has no attribute 'find_element_by_css_selector'

В Selenium 4.1 код будет работать, но ShadowRoot использует только самые новые методы find_element(), поэтому вам нужно обновитьcя, чтобы использовать новый класс By.

AttributeError: 'ShadowRoot' object has no attribute 'find_element_by_css_selector'
C#

В Selenium 3 и 4.0 вы получите ошибку.

System.InvalidCastException: Unable to cast object of type 'System.Collections.Generic.Dictionary`2[System.String,System.Object]' to type 'OpenQA.Selenium.IWebElement'.

В Selenium 4.1 вы увидите такую ошибку.

System.InvalidCastException: Unable to cast object of type 'OpenQA.Selenium.ShadowRoot' to type 'OpenQA.Selenium.IWebElement'.

В Selenium 4 быстро исправить ошибку приведения можно при использовании правильного интерфейса. Хотя это не оптимальный путь, такой подход имеет преимущество: он обратно совместим со старыми версиями Chromium и работает в других браузерах (в частности, Safari). Пример:

Java
SearchContext shadowRoot = (SearchContext) jsDriver.executeScript("return arguments[0].shadowRoot", shadowHost);

Обратите внимание, что сам класс shadowRoot хранится как закрытый для пакетов. Это делается для того, чтобы специалисты напрямую использовали API SearchContext. Само изменение обратно совместимо с тем, что вы делали, поскольку WebElement расширяет SearchContext.

C#
var shadowRoot = (ISearchContext)js.ExecuteScript("return arguments[0].shadowRoot", shadowHost);

Это изменение обратно совместимо с тем, что вы делали, поскольку IWebElement является подклассом ISearchContext.

Как справиться с теневым root

Самая сложная проблема здесь - как справиться с теневым root в 96 версии Chromium, используя Selenium 3. Лучше сразу обновить его до Selenium 4. Он предоставляет гораздо более чистый API для работы с элементами Shadow DOM без необходимости использования JavaScript. Чтобы воспользоваться этим преимуществом, достаточно просто провести обновление.

При этом вы все еще можете получить веб-элемент с помощью JavaScript в Chromium v96 с использованием Selenium 3. Приведем часть кода, а полный приглашаем посмотреть на GitHub.

Java
WebElement shadow_host = driver.findElement(By.cssSelector("#shadow_host"));

Object shadowRoot = ((JavascriptExecutor) driver).executeScript("return arguments[0].shadowRoot", shadow_host);
String id = (String) ((Map<String, Object>) shadowRoot).get("shadow-6066-11e4-a52e-4f735466cecf");
RemoteWebElement shadowRootElement = new RemoteWebElement();
shadowRootElement.setParent((RemoteWebDriver) driver);
shadowRootElement.setId(id);

WebElement shadowContent = shadowRootElement.findElement(By.cssSelector("#shadow_content"));
Ruby
shadow_host = @driver.find_element(css: '#shadow_host')

shadow_root_hash = @driver.execute_script('return arguments[0].shadowRoot', shadow_host)
shadow_root_id = shadow_root_hash['shadow-6066-11e4-a52e-4f735466cecf']
shadow_root = Selenium::WebDriver::Element.new(@driver.send(:bridge), shadow_root_id)

shadow_content = shadow_root.find_element(css: '#shadow_content')
Python
shadow_host = driver.find_element_by_css_selector('#shadow_host')

shadow_root_dict = driver.execute_script('return arguments[0].shadowRoot', shadow_host)
shadow_root_id = shadow_root_dict['shadow-6066-11e4-a52e-4f735466cecf']
shadow_root = WebElement(driver, shadow_root_id, w3c=True)

shadow_content = shadow_root.find_element_by_css_selector('#shadow_content')
C#
var shadowHost = _driver.FindElement(By.CssSelector("#shadow_host"));
var js = ((IJavaScriptExecutor)_driver);

var shadowRoot = (Dictionary<string, object>)js.ExecuteScript("return arguments[0].shadowRoot", shadowHost);
var id = (string)shadowRoot["shadow-6066-11e4-a52e-4f735466cecf"];
var shadowRootElement = new RemoteWebElement((RemoteWebDriver)_driver, id);

var shadowContent = shadowRootElement.FindElement(By.CssSelector("#shadow_content"));

До сих пор я не упоминал Firefox — потому что у него есть свои особенности. Пока Firefox не реализует W3C-совместимую поддержку shadow root (вероятно, это произойдет очень скоро), вам придется получать элементы shadow DOM из исполняемого скрипта непосредственно с помощью свойства children, а затем циклически перебирать элементы, чтобы найти тот, с которым вы хотите работать (подробнее см. на GitHub).

Java
WebElement shadowHost = driver.findElement(By.cssSelector("#shadow_host"));
JavascriptExecutor jsDriver = (JavascriptExecutor) driver;

List<WebElement> children = (List<WebElement>) jsDriver.executeScript("return arguments[0].shadowRoot.children", shadowHost);

WebElement shadowContent = null;
for (WebElement element : children) {
    if (element.getAttribute("id").equals("shadow_content")) {
        shadowContent = element;
        break;
    }
}
Ruby
 shadow_host = @driver.find_element(css: '#shadow_host')
children = @driver.execute_script('return arguments[0].shadowRoot.children', shadow_host)

shadow_content = children.first { |child| child.attribute('id') == 'shadow_content' }
Python
shadow_host = driver.find_element(By.CSS_SELECTOR, '#shadow_host')
children = driver.execute_script('return arguments[0].shadowRoot.children', shadow_host)

shadow_content = next(child for child in children if child.get_attribute('id') == 'shadow_content')
C#
var shadowHost = _driver.FindElement(By.CssSelector("#shadow_host"));
var js = ((IJavaScriptExecutor)_driver);

var children = (IEnumerable<IWebElement>)js.ExecuteScript("return arguments[0].shadowRoot.children", shadowHost);

IWebElement shadowContent = null;
foreach (IWebElement element in children) {
    if (element.GetAttribute("id").Equals("shadow_content")) {
        shadowContent = element;
        break;
    }
}

А здесь можно посмотреть пример того, как работать с элементами shadow DOM в Selenium 4.1+

Java

JUnit 5 Test:

@Test
public void recommendedCode() {
    WebDriverManager.chromedriver().setup();
    driver = new ChromeDriver();

    driver.get("http://watir.com/examples/shadow_dom.html");

    WebElement shadowHost = driver.findElement(By.cssSelector("#shadow_host"));
    SearchContext shadowRoot = shadowHost.getShadowRoot();
    WebElement shadowContent = shadowRoot.findElement(By.cssSelector("#shadow_content"));

    Assertions.assertEquals("some text", shadowContent.getText());
}
Ruby

RSpec Test:

it 'recommended code' do
  @driver = Selenium::WebDriver.for :chrome

  @driver.get('http://watir.com/examples/shadow_dom.html')

  shadow_host = @driver.find_element(css: '#shadow_host')
  shadow_root = shadow_host.shadow_root
  shadow_content = shadow_root.find_element(css: '#shadow_content')

  expect(shadow_content.text).to eq 'some text'
end
Python

PyTest test:

def test_recommended_code():
    driver = Chrome()

    driver.get('http://watir.com/examples/shadow_dom.html')

    shadow_host = driver.find_element(By.CSS_SELECTOR, '#shadow_host')
    shadow_root = shadow_host.shadow_root
    shadow_content = shadow_root.find_element(By.CSS_SELECTOR, '#shadow_content')

    assert shadow_content.text == 'some text'

    driver.quit()
C#

MS Test:

[TestMethod]
public void RecommendedCode()
{
    new DriverManager().SetUpDriver(new ChromeConfig());
    _driver = new ChromeDriver();

    _driver.Navigate().GoToUrl("http://watir.com/examples/shadow_dom.html");

    var shadowHost = _driver.FindElement(By.CssSelector("#shadow_host"));
    var shadowRoot = shadowHost.GetShadowRoot();
    var shadowContent = shadowRoot.FindElement(By.CssSelector("#shadow_content"));

    Assert.AreEqual("some text", shadowContent.Text);
}

Selenium 4 с Chromium 96 предоставляет гораздо более чистый API для работы с элементами Shadow DOM без необходимости использования JavaScript. Рекомендуем обновить ваш код, чтобы получить доступ к этому преимуществу. 

Спасибо за внимание! Надеемся, что материал был вам полезен. 

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