StaleElementReferenceException - z czego wynika

Wyjątek StaleElementReferenceException dla klasycznego Page Objecta z adnotacją @FindBy

Każdy początkujący automatyk tworząc testy w Selenium, w pewnym momencie swojej kariery zaczyna wykorzystywać wzorzecPage Object Pattern , w skrócie POP.

Wzorzec ten ułatwia i przyspiesza tworzenie testów, oraz zwiększa utrzymywalność kodu. Niestety ma on też kilka wad, o których każdy kto, tworzy testy wykorzystując ten wzorzec, szybko się przekonuje.

W artykule, omówimy problem wyjątku **StaleElementReferenceException,**który może wystąpić, kiedy nieodpowiednio projektujemy testy.

Opisany niżej problem, składa się z kilku poziomów. Rozpatrzymy go na przykładzie klasycznego Page Objectu, wykorzystującego adnotację @FindBy.

import io.qameta.allure.Step;
 import org.openqa.selenium.WebElement;
 import org.openqa.selenium.support.FindBy;
 
 import static getting.find.by.AssertWebElement.assertThat;
 
 public class LoginPage extends BasePage {
 
     @FindBy(name = "username")
     private WebElement usernameField;
 
     @FindBy(name = "password")
     private WebElement passwordField;
 
     @FindBy(name = "signon")
     private WebElement signOnButton;
 
     @FindBy(css = "#Content ul[class='messages'] li")
     private WebElement messageLabel;
 
     @Step("Type into User Name Field {username}")
     public LoginPage typeIntoUserNameField(String username) {
         WaitForElement.waitUntilElementIsVisible(usernameField);
         usernameField.sendKeys(username);
         log().info("Typed into User Name Field {}", username);
         return this;
     }
 
     @Step("Type into Password Field {password}")
     public LoginPage typeIntoPasswordField(String password) {
         passwordField.clear();
         passwordField.sendKeys(password);
         log().info("Typed into Password Field {}", password);
         return this;
     }
 
     @Step("Click on Login Button")
     public void clickOnLoginButton() {
         signOnButton.click();
         log().info("Clicked on Login Button");
     }
 
     @Step("Assert that warning message {warningMessage} is displayed")
     public LoginPage assertThatWarningIsDisplayed(String warningMessage) {
         log().info("Checking if warning message {} is displayed", warningMessage);
         WaitForElement.waitUntilElementIsVisible(messageLabel);
         assertThat(messageLabel).isDisplayed().hasText(warningMessage);
         return this;
     }
 
 }
 

W przykładzie powyżej widzimy klasę o nazwie LoginPage z czterema WebElementami oraz metodami do ich obsługi. Analizując kod powyżej widzimy, że wewnątrz metody typeIntoUserNameField(), najpierw wywołujemy metodę waitUntilElementIsVisible(), która to czeka aż, WebElement będzie widoczny. Dopiero później na WebElemencie usernameField wywołujemy metodę sendKeys();.

Metoda waitUntilElementIsVisible(), jest metodą chowającą pod spodem oczekiwanie na WebElement. Przyjmuje ona w argumencie obiekt WebElementu, tak jak na fragmencie, poniżej:

    public static void waitUntilElementIsVisible(WebElement element){
         WebDriverWait webDriverWait = getWebDriverWait();
         webDriverWait.until(ExpectedConditions.visibilityOf(element));
     }
 

Analizując przepływ pracy dalej. Wywołując metodęwaitUntilElementIsVisible() , najpierw tworzymy obiekt WebDriverWait z Selenium, na którym wywołujemy metodę until(). Metoda until() przyjmuję metodę z klasy ExpectedConditions o nazwie visibilityOf(), która też przyjmuje w argumencie WebElement.

Na pierwszy rzut oka, nie ma nic niepoprawnego w tym kodzie, ot zwykła implementacja wykorzystująca Selenium. Gdzie leży w takim razie problem? Sedno problemu leży kawałek dalej. Aby go zrozumieć, musimy zejść jeszcze niżej, do implementacji Selenium.

Analizując dalej metodę visibilityOf(), z klasy ExpectedConditions z Selenium widzimy następującą implementację:

  public static ExpectedCondition<WebElement> visibilityOf(final WebElement element) {
     return new ExpectedCondition<WebElement>() {
       @Override
       public WebElement apply(WebDriver driver) {
         return elementIfVisible(element);
       }
 
       @Override
       public String toString() {
         return "visibility of " + element;
       }
     };
   }
 
 
   private static WebElement elementIfVisible(WebElement element) {
     return element.isDisplayed() ? element : null;
   }

I tutaj pojawia się nam rzeczony problem, a jest nim fakt, że metody z klasy ExpectedConditions , które przyjmują w argumencie WebElement, pracują na już zainicjalizowanym WebElemnecie. Oznacza to, że jeśli w trakcie wykonywania powyższego kodu, element w DOM zostanie przeładowany/odświeżony, to niestety prawdopodobnie zostanie rzucony wyjątek StaleElementReferenceException .

Kurs Selenium

StaleElementReferenceException

Czym jest StaleElementReferenceException ? Jest to jeden z najtrudniejszych w diagnozie i poprawnej obsłudze wyjątków w Selenium. Występuje wtedy gdy, na skutek jakiegoś działania element w DOMie, nie znajduję się już na swoim pierwotnym miejscu. Struktura drzewka DOM zostaje częściowo lub całkowicie odświeżona. StaleElementReferenceException oznacza, że element mógł zniknąć z pierwotnej pozycji i już nie występuję lub został przeładowany (jego pozycja w drzewku DOM częściowo zmieniła się).

Przykład

Aby dobrze zrozumieć StaleElementReferenceException przeanalizujmy sobie przykład ze strony jaką jest **github.com.**Mamy więc kod:

//Przechodzimy do strony www.github.com
 driver.get("http://www.github.com");
 
//Wyszukujemy pole Search Field po atrybucie name
 WebElement searchField = driver.findElement(By.name("q"));
 
 //Wpisujemy w pole Hello i naciskamy klawisz Enter
 searchField.sendKeys("Hello");
 searchField.submit();
 
 //Zostaje rzucony wyjątek StaleElementReferenceException, pomimo tego, że element o atrybucie name z wartością 'q' dalej jest dostępny w DOM
 searchField.clear();

Pomimo tego, że element dalej jest w DOMie na skutek akcji, czyli wyszukania słowa „Hello" oraz naciśnięcia klawisza Enter, DOM uległ zmianie. Pozycja elementu w strukturze drzewa DOM zmieniła się. Tutaj należy zaznaczyć, że wyjątek StaleElementReferenceException występuje też często, nie tylko na skutek działania użytkownika, ale samych skryptów wewnątrz strony. Poniżej gif z opisanym wyżej problemem:

StaleElementReferenceException

Jak się bronić w takim razie przez wyjątkiem StaleElementReferenceException? Z reguły wystarczy ponownie wyszukać WebElement, po fakcie przeładowania DOMu. Dla przykładu powyżej, było by to:

//Przechodzimy do strony www.github.com
 driver.get("http://www.github.com");
 
 //Wyszukujemy pole Search Field po atrybucie name
 WebElement searchField = driver.findElement(By.name("q"));
 
 //Wpisujemy w pole Hello i naciskamy klawisz Enter
 searchField.sendKeys("Hello");
 searchField.submit();
 
 //Ponowne wyszukanie WebElementu o lokatorze q, przeciwdziała wyjątkowi StaleElementReferenceException
 searchField = driver.findElement(By.name("q"));
 searchField.clear();

Jak w takim razie uodpornić testy na rzeczony wyjątek, gdy stosujemy adnotację @FindBy oraz nie chcemy ręcznie wyszukiwać ponownie WebElementu?

Odpowiedzi na to pytanie jest kilka, my zaś dziś zajmiemy się jedną z najskuteczniejszych. A jest nią użycie waitów z Selenium, wykorzystujących lokator, czyli klasę By . Wystarczy, że zamiast metody visibilityOf() z klasy ExpectedConditions, wykorzystamy metodę **visibilityOfElementLocated()**też z tej klasy. Dlaczego?

Odpowiedz na to pytanie leży w implementacji metody visibilityOfElementLocated(), tak jak we fragmencie poniżej:

  public static ExpectedCondition<WebElement> visibilityOfElementLocated(final By locator) {
     return new ExpectedCondition<WebElement>() {
       @Override
       public WebElement apply(WebDriver driver) {
         try {
           return elementIfVisible(driver.findElement(locator));
         } catch (StaleElementReferenceException e) {
           return null;
         }
       }
 
       @Override
       public String toString() {
         return "visibility of element located by " + locator;
       }
     };
   }

Jak widzimy, w metodzie tej Selenium najpierw wyszukuje WebElement wykorzystując driver.findElement(locator), a dopiero później wywołuje na nim prywatną metodę elementIfVisible() , która sprawdza czy WebElement jest widoczny. W przypadku gdy zostanie rzucony wyjątek StaleElementReferenceException, zostanie on złapany, a cała operacja zostanie powtórzona. Krótko mówiąc, zabezpieczamy się w ten sposób przed wyjątkiem StaleElementReferenceException.


Klasa By a @FindBy

Wiemy, już jak się zabezpieczyć, ale powstaje pytanie jak dostać się do lokatora, w momencie w którym korzystamy z adnotacji @FindBy.

W najprostszym podejściu moglibyśmy po prostu wyciągać lokator do stałej, tak jak poniżej:

    private static final String USERNAME_LOCATOR = "username";
     
     @FindBy(name = USERNAME_LOCATOR)
     private WebElement usernameField;
 
     @Step("Type into User Name Field {username}")
     public LoginPage typeIntoUserNameField(String username) {
         WaitForElement.waitUntilElementIsVisible(By.name("USERNAME_LOCATOR"));
         usernameField.sendKeys(username);
         log().info("Typed into User Name Field {}", username);
         return this;
     }
 

Niestety takie rozwiązanie jest mało eleganckie. Jak w takim razie dostać się do lokatora, a w zasadzie do obiektu klasy By z WebElementu, utworzonego przez adnotację @FindBy?

Na ratunek przychodzi nam refleksja.

Refleksja, DefaultElementLocator oraz Proxy

Czym jest refleksja?

Jest to mechanizm, który pozwala na dostęp do metod i pól dowolnych obiektów do których posiadamy referencje. Refleksja pozwala na używanie obiektów, których definicji nie znamy w momencie wykonania kodu.

Definicja brzmi trochę zawile, ale prosty przykład z WebElementem rozjaśni nam sprawę. Przejdźmy w takim razie do kodu. Do mechanizmu refleksji będziemy potrzebowali dodatkowej biblioteki, jaką jest biblioteka commons-lang3z fundacji Apache:

<dependency>
     <groupId>org.apache.commons</groupId>
     <artifactId>commons-lang3</artifactId>
     <version>3.9</version>
 </dependency>
 

Mając dodaną bibliotekę do projektu, możemy utworzyć następujące dwie klasy. Pierwszą z nich jest ByFinder , drugą zaś ByFromString, i tak mamy.

ByFinder:

import org.apache.commons.lang3.reflect.FieldUtils;
 import org.openqa.selenium.By;
 import org.openqa.selenium.WebElement;
 import org.openqa.selenium.support.pagefactory.DefaultElementLocator;
 
 import java.lang.reflect.Proxy;
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
 
 import static getting.find.by.ByFromString.getByFromString;
 
 public class ByFinder {
 
     private static final String BY = "by";
     private static final String H = "h";
     private static final String LOCATOR = "locator";
     private static final String UNDERLYING_ELEMENT = "underlyingElement";
     private static final String FOUND_BY = "foundBy";
 
     public By getByFromWebElement(WebElement element) {
         try {
             //Przypadek dla DefaultElementLocator
             if (element instanceof DefaultElementLocator) {
 
                 // W klasie DefaultElementLocator mamy bezpośredni dostęp do pola By
                 return (By) FieldUtils.readField(element, BY, true);
 
             //Przypadek dla klasy Proxy
             } else if (element instanceof Proxy) {
 
                 // Najpierw pobieramy obiekty pośredni
                 Object proxyOrigin = getField(element, H);
 
                 Object locator = FieldUtils.readField(proxyOrigin, LOCATOR, true);
 
                 // A dopiero później możemy dostać się do klasy obiektu By
                 return (By) FieldUtils.readField(locator, BY, true);
 
             // Przypadek dla RemoteWebElement
             } else {
                 Object underlyingElement = getField(element, UNDERLYING_ELEMENT);
                 String foundByString;
                 try {
                     foundByString = getFoundBy(underlyingElement);
                 } catch (IllegalArgumentException e) {
                     Object secondUnderLyingElement = getField(underlyingElement, UNDERLYING_ELEMENT);
                     foundByString = getFoundBy(secondUnderLyingElement);
                 }
                 //Pattern is from RemoteWebElement class
                 //"[%s] -> %s: %s"
                 String foundByPattern = "(?<=\\-> ).*";
 
                 Pattern pattern = Pattern.compile(foundByPattern);
                 Matcher matcher = pattern.matcher(foundByString);
                 int locatorDefinitionIndex = 0;
                 String locatorDefinition = matcher.group(locatorDefinitionIndex);
                 return getByFromString(locatorDefinition);
             }
 
         } catch (IllegalAccessException e) {
             throw new IllegalStateException("Failed to get locator from WebElement, due to: ", e);
         }
     }
 
     private Object getField(Object element, String fieldName) throws IllegalAccessException {
         return FieldUtils.readField(element, fieldName, true);
     }
 
     private String getFoundBy(Object element) throws IllegalAccessException {
         return (String) FieldUtils.readField(element, FOUND_BY, true);
     }
 
 }

ByFromString:


 import org.openqa.selenium.By;
 
 public class ByFromString {
 
     public static By getByFromString(String locatorToString) {
 
         //Definicja lokatora
         //css selector: [class='confirm-button confirm-button-element idps-not-found'] span]
         String[] locatorSplit = locatorToString.split(": ");
 
         if (locatorSplit.length != 2) {
             throw new IllegalStateException(String.format("Locator definition does not had 2 elements for %s locator", locatorToString));
         }
         String locatorType = locatorSplit[0];
         String locatorValue = locatorSplit[1];
         switch (locatorType) {
 
             case "css selector":
                 return By.cssSelector(locatorValue);
             case "id":
                 return By.id(locatorValue);
             case "link text":
                 return By.linkText(locatorValue);
             case "partial link text":
                 return By.partialLinkText(locatorValue);
             case "tag name":
                 return By.tagName(locatorValue);
             case "name":
                 return By.name(locatorValue);
             case "class name":
                 return By.className(locatorValue);
             case "xpath":
                 return By.xpath(locatorValue);
 
             default:
                 throw new IllegalStateException("Cannot define locator for WebElement definition: " + locatorToString);
         }
     }
 
 }

Mając gotową implementację możemy zmodyfikować metodę waitUntilElementIsVisible(), tak jak na przykładzie poniżej:

    public static void waitUntilElementIsVisible(WebElement element){
         By byFromWebElement = new ByFinder().getByFromWebElement(element);
         WebDriverWait webDriverWait = getWebDriverWait();
         webDriverWait.until(ExpectedConditions.visibilityOfElementLocated(byFromWebElement));
     }

Dzięki takiej implementacji, najpierw wyciągamy lokator z WebElementu. Następnie wywołujemy metodę na oczekiwanie ze modyfikowaną metodą wewnątrz until(), czyli visibilityOfElementLocated() . Tym samym zabezpieczamy się przed wyjątkiem StaleElementReferenceException.

Ale zaraz, zaraz jak to wszystko działa? Dlaczego pomimo zastosowania adnotacji @FindBy, nie dostajemy wyjątku NoSuchElementException w trakcie działania na już zainicjalizowanym WebElemencie. Czym jest klasa DefaultElementLocator oraz Proxy?

Tego wszystkiego dowiesz się w kolejnych odsłonach na javastart.pl

Potrzebujesz więcej informacji?

Artykuł powstał na bazie kursu Automatyzacja testów z wykorzystaniem Selenium!

O Autorze

Nazywam się Mateusz Ciołek i od 2011 roku zajmuję się testowaniem oprogramowania ze specjalizacją w automatyzacji testów.

Dyskusja i komentarze

Masz pytania do tego wpisu? Może chcesz się podzielić spostrzeżeniami? Zapraszamy dyskusji na naszej grupie na Facebooku.